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