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         // in our system, we not only log commits that are against a
212         // ticket, but also ones that are not..
213         
214         
215         $files = $bridge->enumChangedOrModifiedFileNames();
216         
217         $fqfiles = array();
218         foreach ($files as $filename) {
219             $fqfiles[] = $this->repo->shortname . '/' . $filename;
220         }
221
222         // build up overall picture of what needs to be applied to tickets
223         $changes = $this->_getChanges($bridge);
224         
225         
226         print_R($changes);
227         
228
229         // Deferred by tid
230         $deferred = array();
231         $T_by_tid = array();
232         $hashed = array();
233
234         // For correct attribution of spent time
235         $spent_by_tid_by_user = array();
236
237         // Changes that didn't ref a ticket; we want to show something
238         // on the timeline
239         $no_ticket = array();
240         
241         $me = $this->authUser;
242
243         foreach ($changes as $c) {
244             $tickets = array();
245             $log = $c->changelog;
246
247             $actions = $this->parseCommitMessage($log);
248             foreach ($actions as $act) {
249                 $what = $act[0];
250                 $tkt = $act[1];
251                 $tickets[$tkt][$what] = $what;
252                 if (isset($act[2])) {
253                   $tickets[$tkt]['spent'] += $act[2];
254                 }
255             }
256             if (count($tickets) == 0) {
257                 $no_ticket[] = $c;
258                 
259                 continue;
260             }
261             
262             // apply changes to tickets
263             foreach ($tickets as $tkt => $act) {
264                 // removed all the code that handles hashed ticked ids...
265                 $T = clone($this->ticketProvider);
266                 if (!$T->get($tkt)) {
267                     continue;
268                 }
269                 
270                 $T_by_tid[$T->id] = $T;
271             }
272             /*
273             $accounted = false;
274             
275             if ($c->hash !== null) {
276                 if (isset($hashed[$T->tid][$c->hash])) {
277                     $accounted = true;
278                 } else {
279                     // see if we already have a reference
280                     
281                     
282                     list($accounted) = MTrackDB::q(
283                           'select count(hash) from ticket_changeset_hashes
284                       where tid = ? and hash = ?',
285                   $T->tid, $c->hash)->fetchAll(PDO::FETCH_COLUMN, 0);
286                 if (!$accounted) {
287                   $hashed[$T->tid][$c->hash] = $c->hash;
288                 }
289               }
290             }
291
292             if ($accounted) {
293               $deferred[$T->tid]['comments'][] =
294                 "(In $c->rev) merged to [repo:" . 
295                   $this->repo->getBrowseRootName() . "]";
296               continue;
297             }
298             
299             */
300             
301             $log = "(In " . $c->rev . ") ";
302                 /*
303                  .. we do not support commits on behalf of yet..
304                  .. to fix this we need to change the auth code to
305                  .. really pick up auth data..
306                  
307                 if ($c->changeby != $me) {
308                   $log .= " (on behalf of [user:$c->changeby]) ";
309                 }
310                 */
311             
312             $log .= $c->changelog;
313             
314             $deferred[$T->id]['comments'][] = $log;
315             
316             if (isset($act['spent']) && $c->changeby != $me) {
317                 $spent_by_tid_by_user[$T->id][$c->changeby_id][] = $act['spent'];
318                 unset($act['spent']);
319             }
320             $deferred[$T->tid]['act'][] = $act;
321  
322             //??? 
323             $this->checkVeto('postCommit', $log, $fqfiles, $actions);
324         }
325         
326         
327         // defered is a list of actions...
328         $this->no_ticket = $no_ticket;
329         $this->deferred = $deferred;
330         $this->spent_by_tid_by_user = $spent_by_tid_by_user;
331         
332         
333         return  true;
334     
335     /*    
336         $ret = array();
337        // print_r($deferred);
338         foreach ($deferred as $tid => $info) {
339             $T = $T_by_tid[$tid];
340
341             $log = join("\n\n", $info['comments']);
342             
343             
344           
345           
346           
347           $CS = MTrackChangeset::begin("ticket:" . $T->tid, $log);
348
349           if (isset($hashed[$T->tid])) {
350             foreach ($hashed[$T->tid] as $hash) {
351               MTrackDB::q(
352                 'insert into ticket_changeset_hashes(tid, hash) values (?, ?)',
353                 $T->tid, $hash);
354             }
355           }
356
357           $T->addComment($log);
358           if (isset($info['act'])) foreach ($info['act'] as $act) {
359             if (isset($act['close'])) {
360               $T->resolution = 'fixed';
361               $T->close();
362             }
363             if (isset($act['spent'])) {
364               $T->addEffort($act['spent']);
365             }
366           }
367           $T->save($CS);
368           $CS->commit();
369         }
370         foreach ($spent_by_tid_by_user as $tid => $sdata) {
371           // Load it fresh here, as there seems to be an issue with saving
372           // a second set of changes on a pre-existing object
373           $T = MTrackIssue::loadById($tid);
374           foreach ($sdata as $user => $time) {
375             MTrackAuth::su($user);
376             $CS = MTrackChangeset::begin("ticket:" . $T->tid,
377               "Tracking time from prior push");
378             MTrackAuth::drop();
379             foreach ($time as $spent) {
380               $T->addEffort($spent);
381             }
382             $T->save($CS);
383             $CS->commit();
384           }
385         }
386         $log = '';
387         foreach ($no_ticket as $c) {
388           $log .= "(In " . $c->rev . ") ";
389           if ($c->changeby != $me) {
390             $log .= " (on behalf of [user:$c->changeby]) ";
391           }
392           $log .= $c->changelog . "\n\n";
393         }
394         $CS = MTrackChangeset::begin("repo:" . $this->repo->id, $log);
395         $CS->commit();
396         
397         */
398     }
399
400   
401 }