MTrack/CommitChecker.php
[web.mtrack] / MTrack / CommitChecker.php
1 <?php 
2
3 require_once 'MTrack/Interface/CommitListener.php'; 
4 require_once 'MTrack/Interface/CommitHookBridge.php'; 
5 require_once 'MTrack/Interface/CommitHookBridge2.php'; 
6
7 //require_once 'MTrack/Issue.php'; 
8  //require_once 'MTrack/Changeset.php'; 
9   
10
11  
12
13 class MTrack_CommitChecker {
14     
15     static $fileChecks = array(
16         'php' => 'checkPHP',
17     );
18     
19     
20     static $listeners = array();
21    
22     static function addCheck($name)
23     {
24         require_once "MTrack/CommitCheck/$name.php";
25         $cls = "MTrackCommitCheck_$name";
26         self::$listeners[] = new $cls;
27     }
28      
29     
30     var $repo;
31     var $bridge;
32     var $authUser;
33     
34     var $checks = array();
35     
36     function __construct($ar) {
37         foreach($ar as $k=>$v) {
38             $this->$k = $v;
39         }
40         foreach($this->checks as $chk) {
41             self::addCheck($chk);
42         }
43         
44         
45     }
46     
47
48     function checkVeto()
49     {
50         $args = func_get_args();
51         $method = array_shift($args);
52         $reasons = array();
53
54         foreach (self::$listeners as $l) {
55           $v = call_user_func_array(array($l, $method), $args);
56           if ($v !== true) {
57             if ($v === null || $v === false) {
58               $reasons[] = sprintf("%s:%s() returned %s",
59                 get_class($l), $method, $v === null ? 'null' : 'false');
60             } elseif (is_array($v)) {
61               foreach ($v as $m) {
62                 $reasons[] = $m;
63               }
64             } else {
65               $reasons[] = $v;
66             }
67           }
68         }
69         if (count($reasons)) {
70             require_once 'MTrack/Exception/Veto.php';
71             throw new MTrackVetoException($reasons);
72         }
73     }
74
75     function parseCommitMessage($msg) 
76     {
77         // Parse the commit message and look for commands;
78         // returns each recognized command and its args in an array
79
80         $close = array('resolves', 'resolved', 'close', 'closed',
81                        'closes', 'fix', 'fixed', 'fixes');
82         $refs = array('addresses', 'references', 'referenced',
83                       'refs', 'ref', 'see', 're');
84
85         $cmds = join('|', $close) . '|' . join('|', $refs);
86         
87         
88         $timepat = ''; //'(?:\s*\((?:spent|sp)\s*(-?[0-9]*(?:\.[0-9]+)?)\s*(?:hours?|hrs)?\s*\))?';
89         $tktref = "(?:#|(?:(?:ticket|issue|bug):?\s*))([a-z]*[0-9]+)$timepat";
90
91         $pat = "(?P<action>(?:$cmds))\s*(?P<ticket>$tktref(?:(?:[, &]*|\s+and\s+)$tktref)*)";
92
93          
94         $M = array();
95         $actions = array();
96
97         if (preg_match_all("/$pat/smi", $msg, $M, PREG_SET_ORDER)) {
98              
99           foreach ($M as $match) {
100             if (in_array($match['action'], $close)) {
101               $action = 'ref'; // 'close'; - commits need reviewing before they can close something.
102             } else {
103               $action = 'ref';
104             }
105             $tickets = array();
106             $T = array();
107             if (preg_match_all("/$tktref/smi", $match['ticket'],
108                 $T, PREG_SET_ORDER)) {
109
110               foreach ($T as $tmatch) {
111                 if (isset($tmatch[2])) {
112                   // [ action, ticket, spent ]
113                   $actions[] = array($action, $tmatch[1], $tmatch[2]);
114                 } else {
115                   // [ action, ticket ]
116                   $actions[] = array($action, $tmatch[1]);
117                 }
118               }
119             }
120           }
121         }
122        
123         return $actions;
124     }
125
126     function preCommit(IMTrackCommitHookBridge $bridge) 
127     {
128         die("NOT SUPPORTED YET!");
129        
130         //echo "Pre-commit";
131         $this->bridge = $bridge;
132         MTrackACL::requireAllRights("repo:" . $this->repo->id, 'commit');
133         
134         
135         $files = $bridge->enumChangedOrModifiedFileNames();
136          
137         $changes = $this->_getChanges($bridge);
138         foreach ($changes as $c) {
139             $log = $c->changelog;
140             $actions = $this->parseCommitMessage($log);
141
142               // check permissions on the tickets
143             $tickets = array();
144             foreach ($actions as $act) {
145                 $tkt = $act[1];
146                 $tickets[$tkt] = $tkt;
147             }
148             $reasons = array();
149             foreach ($tickets as $tkt) {
150                 if (strlen($tkt) == 32) {
151                   $T = MTrackIssue::loadById($tkt);
152                 } else {
153                   $T = MTrackIssue::loadByNSIdent($tkt);
154                 }
155
156                 if ($T === null) {
157                   $reasons[] = "#$tkt is not a valid ticket\n";
158                   continue;
159                 }
160
161                 $accounted = false;
162                 if ($c->hash !== null) {
163                   list($accounted) = MTrackDB::q(
164                       'select count(hash) from ticket_changeset_hashes
165                       where tid = ? and hash = ?',
166                     $T->tid, $c->hash)->fetchAll(PDO::FETCH_COLUMN, 0);
167                   if ($accounted) {
168                     continue;
169                   }
170                 }
171
172                 if (!MTrackACL::hasAllRights("ticket:$T->tid", "modify")) {
173                   $reasons[] = MTrackAuth::whoami() . " does not have permission to modify #$tkt\n";
174                 } else if (!$T->isOpen()) {
175                   $reasons[] = " ** #$tkt is already closed.\n ** You must either re-open it (if it has not already shipped)\n ** or open a new ticket to track this issue\n";
176                 }
177             }
178         }
179         if (count($reasons) > 0) {
180             require_once 'MTrack/Exception/Veto.php';
181           throw new MTrackVetoException($reasons);
182         }
183         $this->checkVeto('vetoCommit', $log, $files, $actions, $this);
184     }
185
186     private function _getChanges(IMTrackCommitHookBridge $bridge)
187     {
188         $changes = array();
189         if ($bridge instanceof IMTrackCommitHookBridge2) {
190             // this is HG only at present.
191           $changes = $bridge->getChanges();
192         } else {
193             require_once 'MTrack/CommitHookChangeEvent.php';
194             $c = new MTrackCommitHookChangeEvent;
195             $c->rev = $bridge->getChangesetDescriptor();
196             $c->changelog = $bridge->getCommitMessage();
197             $c->changeby = $this->authUser->email; //???
198             $c->changeby_id = $this->authUser->id; //???
199             $c->ctime = time();
200             $changes[] = $c;
201         }
202         return $changes;
203     }
204
205     function postCommit(IMTrackCommitHookBridge $bridge)
206     {
207        
208         // this might be run on multiple commits (big push...)
209         
210         
211         $files = $bridge->enumChangedOrModifiedFileNames();
212         
213         $fqfiles = array();
214         foreach ($files as $filename) {
215             $fqfiles[] = $this->repo->shortname . '/' . $filename;
216         }
217
218         // build up overall picture of what needs to be applied to tickets
219         $changes = $this->_getChanges($bridge);
220         
221         print_R($changes);
222         
223
224         // Deferred by tid
225         $deferred = array();
226         $T_by_tid = array();
227         $hashed = array();
228
229         // For correct attribution of spent time
230         $spent_by_tid_by_user = array();
231
232         // Changes that didn't ref a ticket; we want to show something
233         // on the timeline
234         $no_ticket = array();
235         
236         $me = $this->authUser;
237
238         foreach ($changes as $c) {
239             $tickets = array();
240             $log = $c->changelog;
241
242             $actions = $this->parseCommitMessage($log);
243             foreach ($actions as $act) {
244                 $what = $act[0];
245                 $tkt = $act[1];
246                 $tickets[$tkt][$what] = $what;
247                 if (isset($act[2])) {
248                   $tickets[$tkt]['spent'] += $act[2];
249                 }
250             }
251             if (count($tickets) == 0) {
252                 $no_ticket[] = $c;
253                 continue;
254             }
255             
256             // apply changes to tickets
257             foreach ($tickets as $tkt => $act) {
258                 // removed all the code that handles hashed ticked ids...
259                 $T = clone($this->ticketProvider);
260                 if (!$T->get($tkt)) {
261                     continue;
262                 }
263                 
264                 $T_by_tid[$T->id] = $T;
265             }
266             /*
267             $accounted = false;
268             
269             if ($c->hash !== null) {
270                 if (isset($hashed[$T->tid][$c->hash])) {
271                     $accounted = true;
272                 } else {
273                     // see if we already have a reference
274                     
275                     
276                     list($accounted) = MTrackDB::q(
277                           'select count(hash) from ticket_changeset_hashes
278                       where tid = ? and hash = ?',
279                   $T->tid, $c->hash)->fetchAll(PDO::FETCH_COLUMN, 0);
280                 if (!$accounted) {
281                   $hashed[$T->tid][$c->hash] = $c->hash;
282                 }
283               }
284             }
285
286             if ($accounted) {
287               $deferred[$T->tid]['comments'][] =
288                 "(In $c->rev) merged to [repo:" . 
289                   $this->repo->getBrowseRootName() . "]";
290               continue;
291             }
292             
293             */
294             
295             $log = "(In " . $c->rev . ") ";
296                 /*
297                  .. we do not support commits on behalf of yet..
298                  .. to fix this we need to change the auth code to
299                  .. really pick up auth data..
300                  
301                 if ($c->changeby != $me) {
302                   $log .= " (on behalf of [user:$c->changeby]) ";
303                 }
304                 */
305             
306             $log .= $c->changelog;
307             
308             $deferred[$T->id]['comments'][] = $log;
309             
310             if (isset($act['spent']) && $c->changeby != $me) {
311                 $spent_by_tid_by_user[$T->id][$c->changeby_id][] = $act['spent'];
312                 unset($act['spent']);
313             }
314             $deferred[$T->tid]['act'][] = $act;
315  
316             //??? 
317             $this->checkVeto('postCommit', $log, $fqfiles, $actions);
318         }
319         
320         // defered is a list of actions...
321         
322         $this->deferred = $deferred;
323         $this->spent_by_tid_by_user = $spent_by_tid_by_user;
324         
325         
326         return  true;
327     
328     /*    
329         $ret = array();
330        // print_r($deferred);
331         foreach ($deferred as $tid => $info) {
332             $T = $T_by_tid[$tid];
333
334             $log = join("\n\n", $info['comments']);
335             
336             
337           
338           
339           
340           $CS = MTrackChangeset::begin("ticket:" . $T->tid, $log);
341
342           if (isset($hashed[$T->tid])) {
343             foreach ($hashed[$T->tid] as $hash) {
344               MTrackDB::q(
345                 'insert into ticket_changeset_hashes(tid, hash) values (?, ?)',
346                 $T->tid, $hash);
347             }
348           }
349
350           $T->addComment($log);
351           if (isset($info['act'])) foreach ($info['act'] as $act) {
352             if (isset($act['close'])) {
353               $T->resolution = 'fixed';
354               $T->close();
355             }
356             if (isset($act['spent'])) {
357               $T->addEffort($act['spent']);
358             }
359           }
360           $T->save($CS);
361           $CS->commit();
362         }
363         foreach ($spent_by_tid_by_user as $tid => $sdata) {
364           // Load it fresh here, as there seems to be an issue with saving
365           // a second set of changes on a pre-existing object
366           $T = MTrackIssue::loadById($tid);
367           foreach ($sdata as $user => $time) {
368             MTrackAuth::su($user);
369             $CS = MTrackChangeset::begin("ticket:" . $T->tid,
370               "Tracking time from prior push");
371             MTrackAuth::drop();
372             foreach ($time as $spent) {
373               $T->addEffort($spent);
374             }
375             $T->save($CS);
376             $CS->commit();
377           }
378         }
379         $log = '';
380         foreach ($no_ticket as $c) {
381           $log .= "(In " . $c->rev . ") ";
382           if ($c->changeby != $me) {
383             $log .= " (on behalf of [user:$c->changeby]) ";
384           }
385           $log .= $c->changelog . "\n\n";
386         }
387         $CS = MTrackChangeset::begin("repo:" . $this->repo->id, $log);
388         $CS->commit();
389         
390         */
391     }
392
393   
394 }