import
[web.mtrack] / inc / commit-hook.php
1 <?php # vim:ts=2:sw=2:et:
2 /* For licensing and copyright terms, see the file named LICENSE */
3
4 interface IMTrackCommitHookBridge {
5   function enumChangedOrModifiedFileNames();
6   function getFileStream($filename);
7   function getCommitMessage();
8   /* returns a tracklink describing the change (eg: [123]) */
9   function getChangesetDescriptor();
10 }
11
12 class MTrackCommitHookChangeEvent {
13   /** Revision or changeset identifier for this particular item,
14    * in wiki syntax */
15   public $rev;
16
17   /** commit message associated with this revision */
18   public $changelog;
19
20   /** who committed this revision */
21   public $changeby;
22
23   /** when this revision was committed */
24   public $ctime;
25
26   /** a hash value that will be consistent when being merged from multiple
27    * repos */
28   public $hash;
29 }
30
31 interface IMTrackCommitHookBridge2 extends IMTrackCommitHookBridge {
32   /* returns an array; each element is an MTrackCommitHookChangeEvent */
33   function getChanges();
34 }
35
36 /* The listener protocol is to return true if all is good,
37  * or to return either a string or an array of strings that
38  * detail why a change is not allowed to proceed */
39 interface IMTrackCommitListener {
40   function vetoCommit($msg, $files, $actions);
41   function postCommit($msg, $files, $actions);
42 }
43
44 class MTrackCommitCheck_NoEmptyLogMessage implements IMTrackCommitListener {
45   function __construct() {
46     MTrackCommitChecker::registerListener($this);
47   }
48
49   function vetoCommit($msg, $files, $actions) {
50     if (!strlen(trim($msg))) {
51       return "Empty log messages are not allowed.\n";
52     }
53     return true;
54   }
55
56   function postCommit($msg, $files, $actions) {
57     return true;
58   }
59 }
60
61 class MTrackCommitCheck_RequiresTimeReference implements IMTrackCommitListener {
62   function __construct() {
63     MTrackCommitChecker::registerListener($this);
64   }
65
66   function vetoCommit($msg, $files, $actions) {
67     $spent = false;
68     foreach ($actions as $act) {
69       if (isset($act[2])) {
70         return true;
71       }
72     }
73     return "You must include at least one ticket and time reference in your\n".
74       "commit message, using the \"refs #123 (spent 2.5)\" notation.\n"
75       ;
76   }
77
78   function postCommit($msg, $files, $actions) {
79     return true;
80   }
81 }
82
83 class MTrackCommitChecker {
84   static $fileChecks = array(
85     'php' => 'checkPHP',
86   );
87   static $listeners = array();
88   var $repo;
89
90   static function registerListener(IMTrackCommitListener $l)
91   {
92     self::$listeners[] = $l;
93   }
94
95   function checkVeto()
96   {
97     $args = func_get_args();
98     $method = array_shift($args);
99     $reasons = array();
100
101     foreach (self::$listeners as $l) {
102       $v = call_user_func_array(array($l, $method), $args);
103       if ($v !== true) {
104         if ($v === null || $v === false) {
105           $reasons[] = sprintf("%s:%s() returned %s",
106             get_class($l), $method, $v === null ? 'null' : 'false');
107         } elseif (is_array($v)) {
108           foreach ($v as $m) {
109             $reasons[] = $m;
110           }
111         } else {
112           $reasons[] = $v;
113         }
114       }
115     }
116     if (count($reasons)) {
117       throw new MTrackVetoException($reasons);
118     }
119   }
120
121   function __construct($repo) {
122     $this->repo = $repo;
123   }
124
125   function parseCommitMessage($msg) {
126     // Parse the commit message and look for commands;
127     // returns each recognized command and its args in an array
128
129     $close = array('resolves', 'resolved', 'close', 'closed',
130                    'closes', 'fix', 'fixed', 'fixes');
131     $refs = array('addresses', 'references', 'referenced',
132                   'refs', 'ref', 'see', 're');
133
134     $cmds = join('|', $close) . '|' . join('|', $refs);
135     $timepat = '(?:\s*\((?:spent|sp)\s*(-?[0-9]*(?:\.[0-9]+)?)\s*(?:hours?|hrs)?\s*\))?';
136     $tktref = "(?:#|(?:(?:ticket|issue|bug):?\s*))([a-z]*[0-9]+)$timepat";
137
138     $pat = "(?P<action>(?:$cmds))\s*(?P<ticket>$tktref(?:(?:[, &]*|\s+and\s+)$tktref)*)";
139
140     $M = array();
141     $actions = array();
142
143     if (preg_match_all("/$pat/smi", $msg, $M, PREG_SET_ORDER)) {
144       foreach ($M as $match) {
145         if (in_array($match['action'], $close)) {
146           $action = 'close';
147         } else {
148           $action = 'ref';
149         }
150         $tickets = array();
151         $T = array();
152         if (preg_match_all("/$tktref/smi", $match['ticket'],
153             $T, PREG_SET_ORDER)) {
154
155           foreach ($T as $tmatch) {
156             if (isset($tmatch[2])) {
157               // [ action, ticket, spent ]
158               $actions[] = array($action, $tmatch[1], $tmatch[2]);
159             } else {
160               // [ action, ticket ]
161               $actions[] = array($action, $tmatch[1]);
162             }
163           }
164         }
165       }
166     }
167     return $actions;
168   }
169
170   function preCommit(IMTrackCommitHookBridge $bridge) {
171     MTrackACL::requireAllRights("repo:" . $this->repo->repoid, 'commit');
172     $files = $bridge->enumChangedOrModifiedFileNames();
173     $fqfiles = array();
174     foreach ($files as $filename) {
175       $fqfiles[] = $this->repo->shortname . '/' . $filename;
176       $pi = pathinfo($filename);
177       if (isset(self::$fileChecks[$pi['extension']])) {
178         $lint = self::$fileChecks[$pi['extension']];
179         $fp = $bridge->getFileStream($filename);
180         $this->$lint($filename, $fp);
181         $fp = null;
182       }
183     }
184     $changes = $this->_getChanges($bridge);
185     foreach ($changes as $c) {
186       $log = $c->changelog;
187       $actions = $this->parseCommitMessage($log);
188
189       // check permissions on the tickets
190       $tickets = array();
191       foreach ($actions as $act) {
192         $tkt = $act[1];
193         $tickets[$tkt] = $tkt;
194       }
195       $reasons = array();
196       foreach ($tickets as $tkt) {
197         if (strlen($tkt) == 32) {
198           $T = MTrackIssue::loadById($tkt);
199         } else {
200           $T = MTrackIssue::loadByNSIdent($tkt);
201         }
202
203         if ($T === null) {
204           $reasons[] = "#$tkt is not a valid ticket\n";
205           continue;
206         }
207
208         $accounted = false;
209         if ($c->hash !== null) {
210           list($accounted) = MTrackDB::q(
211               'select count(hash) from ticket_changeset_hashes
212               where tid = ? and hash = ?',
213             $T->tid, $c->hash)->fetchAll(PDO::FETCH_COLUMN, 0);
214           if ($accounted) {
215             continue;
216           }
217         }
218
219         if (!MTrackACL::hasAllRights("ticket:$T->tid", "modify")) {
220           $reasons[] = MTrackAuth::whoami() . " does not have permission to modify #$tkt\n";
221         } else if (!$T->isOpen()) {
222           $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";
223         }
224       }
225     }
226     if (count($reasons) > 0) {
227       throw new MTrackVetoException($reasons);
228     }
229     $this->checkVeto('vetoCommit', $log, $files, $actions);
230   }
231
232   private function _getChanges(IMTrackCommitHookBridge $bridge)
233   {
234     $changes = array();
235     if ($bridge instanceof IMTrackCommitHookBridge2) {
236       $changes = $bridge->getChanges();
237     } else {
238       $c = new MTrackCommitHookChangeEvent;
239       $c->rev = $bridge->getChangesetDescriptor();
240       $c->changelog = $bridge->getCommitMessage();
241       $c->changeby = MTrackAuth::whoami();
242       $c->ctime = time();
243       $changes[] = $c;
244     }
245     return $changes;
246   }
247
248   function postCommit(IMTrackCommitHookBridge $bridge)
249   {
250     $files = $bridge->enumChangedOrModifiedFileNames();
251     $fqfiles = array();
252     foreach ($files as $filename) {
253       $fqfiles[] = $this->repo->shortname . '/' . $filename;
254     }
255
256     // build up overall picture of what needs to be applied to tickets
257     $changes = $this->_getChanges($bridge);
258
259     // Deferred by tid
260     $deferred = array();
261     $T_by_tid = array();
262     $hashed = array();
263
264     // For correct attribution of spent time
265     $spent_by_tid_by_user = array();
266
267     // Changes that didn't ref a ticket; we want to show something
268     // on the timeline
269     $no_ticket = array();
270
271     $me = mtrack_canon_username(MTrackAuth::whoami());
272
273     foreach ($changes as $c) {
274       $tickets = array();
275       $log = $c->changelog;
276
277       $actions = $this->parseCommitMessage($log);
278       foreach ($actions as $act) {
279         $what = $act[0];
280         $tkt = $act[1];
281         $tickets[$tkt][$what] = $what;
282         if (isset($act[2])) {
283           $tickets[$tkt]['spent'] += $act[2];
284         }
285       }
286       if (count($tickets) == 0) {
287         $no_ticket[] = $c;
288         continue;
289       }
290       // apply changes to tickets
291       foreach ($tickets as $tkt => $act) {
292         if (strlen($tkt) == 32 && isset($T_by_tid[$tkt])) {
293           $T = $T_by_tid[$tkt];
294         } else {
295           if (strlen($tkt) == 32) {
296             $T = MTrackIssue::loadById($tkt);
297           } else {
298             $T = MTrackIssue::loadByNSIdent($tkt);
299           }
300           $T_by_tid[$T->tid] = $T;
301         }
302
303         $accounted = false;
304         if ($c->hash !== null) {
305           if (isset($hashed[$T->tid][$c->hash])) {
306             $accounted = true;
307           } else {
308             list($accounted) = MTrackDB::q(
309               'select count(hash) from ticket_changeset_hashes
310               where tid = ? and hash = ?',
311               $T->tid, $c->hash)->fetchAll(PDO::FETCH_COLUMN, 0);
312             if (!$accounted) {
313               $hashed[$T->tid][$c->hash] = $c->hash;
314             }
315           }
316         }
317
318         if ($accounted) {
319           $deferred[$T->tid]['comments'][] =
320             "(In $c->rev) merged to [repo:" . 
321               $this->repo->getBrowseRootName() . "]";
322           continue;
323         }
324         $log = "(In " . $c->rev . ") ";
325         if ($c->changeby != $me) {
326           $log .= " (on behalf of [user:$c->changeby]) ";
327         }
328         $log .= $c->changelog;
329         $deferred[$T->tid]['comments'][] = $log;
330         if (isset($act['spent']) && $c->changeby != $me) {
331           $spent_by_tid_by_user[$T->tid][$c->changeby][] = $act['spent'];
332           unset($act['spent']);
333         }
334         $deferred[$T->tid]['act'][] = $act;
335
336       }
337       $this->checkVeto('postCommit', $log, $fqfiles, $actions);
338     }
339
340     foreach ($deferred as $tid => $info) {
341       $T = $T_by_tid[$tid];
342
343       $log = join("\n\n", $info['comments']);
344
345       $CS = MTrackChangeset::begin("ticket:" . $T->tid, $log);
346
347       if (isset($hashed[$T->tid])) {
348         foreach ($hashed[$T->tid] as $hash) {
349           MTrackDB::q(
350             'insert into ticket_changeset_hashes(tid, hash) values (?, ?)',
351             $T->tid, $hash);
352         }
353       }
354
355       $T->addComment($log);
356       if (isset($info['act'])) foreach ($info['act'] as $act) {
357         if (isset($act['close'])) {
358           $T->resolution = 'fixed';
359           $T->close();
360         }
361         if (isset($act['spent'])) {
362           $T->addEffort($act['spent']);
363         }
364       }
365       $T->save($CS);
366       $CS->commit();
367     }
368     foreach ($spent_by_tid_by_user as $tid => $sdata) {
369       // Load it fresh here, as there seems to be an issue with saving
370       // a second set of changes on a pre-existing object
371       $T = MTrackIssue::loadById($tid);
372       foreach ($sdata as $user => $time) {
373         MTrackAuth::su($user);
374         $CS = MTrackChangeset::begin("ticket:" . $T->tid,
375           "Tracking time from prior push");
376         MTrackAuth::drop();
377         foreach ($time as $spent) {
378           $T->addEffort($spent);
379         }
380         $T->save($CS);
381         $CS->commit();
382       }
383     }
384     $log = '';
385     foreach ($no_ticket as $c) {
386       $log .= "(In " . $c->rev . ") ";
387       if ($c->changeby != $me) {
388         $log .= " (on behalf of [user:$c->changeby]) ";
389       }
390       $log .= $c->changelog . "\n\n";
391     }
392     $CS = MTrackChangeset::begin("repo:" . $this->repo->repoid, $log);
393     $CS->commit();
394   }
395
396   function checkPHP($filename, $fp) {
397     $pipes = null;
398     $proc = proc_open(MTrackConfig::get('tools', 'php') . " -l", array(
399         0 => array('pipe', 'r'),
400         1 => array('pipe', 'w'),
401         2 => array('pipe', 'w')
402       ), $pipes);
403
404     // send in data
405     stream_copy_to_stream($fp, $pipes[0]);
406     $fp = null;
407     $pipes[0] = null;
408
409     $output = stream_get_contents($pipes[1]);
410     $output .= stream_get_contents($pipes[2]);
411     $st = proc_get_status($proc);
412     if ($st['running']) {
413       proc_terminate($proc);
414       sleep(1);
415       $st = proc_get_status($proc);
416     }
417     if ($st['exitcode'] != 0) {
418       throw new Exception("$filename: $output");
419     }
420     return true;
421   }
422 }
423