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