1 <?php # vim:ts=2:sw=2:et:
2 /* For licensing and copyright terms, see the file named LICENSE */
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();
12 class MTrackCommitHookChangeEvent {
13 /** Revision or changeset identifier for this particular item,
17 /** commit message associated with this revision */
20 /** who committed this revision */
23 /** when this revision was committed */
26 /** a hash value that will be consistent when being merged from multiple
31 interface IMTrackCommitHookBridge2 extends IMTrackCommitHookBridge {
32 /* returns an array; each element is an MTrackCommitHookChangeEvent */
33 function getChanges();
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);
44 class MTrackCommitCheck_NoEmptyLogMessage implements IMTrackCommitListener {
45 function __construct() {
46 MTrackCommitChecker::registerListener($this);
49 function vetoCommit($msg, $files, $actions) {
50 if (!strlen(trim($msg))) {
51 return "Empty log messages are not allowed.\n";
56 function postCommit($msg, $files, $actions) {
61 class MTrackCommitCheck_RequiresTimeReference implements IMTrackCommitListener {
62 function __construct() {
63 MTrackCommitChecker::registerListener($this);
66 function vetoCommit($msg, $files, $actions) {
68 foreach ($actions as $act) {
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"
78 function postCommit($msg, $files, $actions) {
83 class MTrackCommitChecker {
84 static $fileChecks = array(
87 static $listeners = array();
90 static function registerListener(IMTrackCommitListener $l)
92 self::$listeners[] = $l;
97 $args = func_get_args();
98 $method = array_shift($args);
101 foreach (self::$listeners as $l) {
102 $v = call_user_func_array(array($l, $method), $args);
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)) {
116 if (count($reasons)) {
117 throw new MTrackVetoException($reasons);
121 function __construct($repo) {
125 function parseCommitMessage($msg) {
126 // Parse the commit message and look for commands;
127 // returns each recognized command and its args in an array
129 $close = array('resolves', 'resolved', 'close', 'closed',
130 'closes', 'fix', 'fixed', 'fixes');
131 $refs = array('addresses', 'references', 'referenced',
132 'refs', 'ref', 'see', 're');
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";
138 $pat = "(?P<action>(?:$cmds))\s*(?P<ticket>$tktref(?:(?:[, &]*|\s+and\s+)$tktref)*)";
143 if (preg_match_all("/$pat/smi", $msg, $M, PREG_SET_ORDER)) {
144 foreach ($M as $match) {
145 if (in_array($match['action'], $close)) {
152 if (preg_match_all("/$tktref/smi", $match['ticket'],
153 $T, PREG_SET_ORDER)) {
155 foreach ($T as $tmatch) {
156 if (isset($tmatch[2])) {
157 // [ action, ticket, spent ]
158 $actions[] = array($action, $tmatch[1], $tmatch[2]);
160 // [ action, ticket ]
161 $actions[] = array($action, $tmatch[1]);
170 function preCommit(IMTrackCommitHookBridge $bridge) {
171 MTrackACL::requireAllRights("repo:" . $this->repo->repoid, 'commit');
172 $files = $bridge->enumChangedOrModifiedFileNames();
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);
184 $changes = $this->_getChanges($bridge);
185 foreach ($changes as $c) {
186 $log = $c->changelog;
187 $actions = $this->parseCommitMessage($log);
189 // check permissions on the tickets
191 foreach ($actions as $act) {
193 $tickets[$tkt] = $tkt;
196 foreach ($tickets as $tkt) {
197 if (strlen($tkt) == 32) {
198 $T = MTrackIssue::loadById($tkt);
200 $T = MTrackIssue::loadByNSIdent($tkt);
204 $reasons[] = "#$tkt is not a valid ticket\n";
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);
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";
226 if (count($reasons) > 0) {
227 throw new MTrackVetoException($reasons);
229 $this->checkVeto('vetoCommit', $log, $files, $actions);
232 private function _getChanges(IMTrackCommitHookBridge $bridge)
235 if ($bridge instanceof IMTrackCommitHookBridge2) {
236 $changes = $bridge->getChanges();
238 $c = new MTrackCommitHookChangeEvent;
239 $c->rev = $bridge->getChangesetDescriptor();
240 $c->changelog = $bridge->getCommitMessage();
241 $c->changeby = MTrackAuth::whoami();
248 function postCommit(IMTrackCommitHookBridge $bridge)
250 $files = $bridge->enumChangedOrModifiedFileNames();
252 foreach ($files as $filename) {
253 $fqfiles[] = $this->repo->shortname . '/' . $filename;
256 // build up overall picture of what needs to be applied to tickets
257 $changes = $this->_getChanges($bridge);
264 // For correct attribution of spent time
265 $spent_by_tid_by_user = array();
267 // Changes that didn't ref a ticket; we want to show something
269 $no_ticket = array();
271 $me = mtrack_canon_username(MTrackAuth::whoami());
273 foreach ($changes as $c) {
275 $log = $c->changelog;
277 $actions = $this->parseCommitMessage($log);
278 foreach ($actions as $act) {
281 $tickets[$tkt][$what] = $what;
282 if (isset($act[2])) {
283 $tickets[$tkt]['spent'] += $act[2];
286 if (count($tickets) == 0) {
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];
295 if (strlen($tkt) == 32) {
296 $T = MTrackIssue::loadById($tkt);
298 $T = MTrackIssue::loadByNSIdent($tkt);
300 $T_by_tid[$T->tid] = $T;
304 if ($c->hash !== null) {
305 if (isset($hashed[$T->tid][$c->hash])) {
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);
313 $hashed[$T->tid][$c->hash] = $c->hash;
319 $deferred[$T->tid]['comments'][] =
320 "(In $c->rev) merged to [repo:" .
321 $this->repo->getBrowseRootName() . "]";
324 $log = "(In " . $c->rev . ") ";
325 if ($c->changeby != $me) {
326 $log .= " (on behalf of [user:$c->changeby]) ";
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']);
334 $deferred[$T->tid]['act'][] = $act;
337 $this->checkVeto('postCommit', $log, $fqfiles, $actions);
340 foreach ($deferred as $tid => $info) {
341 $T = $T_by_tid[$tid];
343 $log = join("\n\n", $info['comments']);
345 $CS = MTrackChangeset::begin("ticket:" . $T->tid, $log);
347 if (isset($hashed[$T->tid])) {
348 foreach ($hashed[$T->tid] as $hash) {
350 'insert into ticket_changeset_hashes(tid, hash) values (?, ?)',
355 $T->addComment($log);
356 if (isset($info['act'])) foreach ($info['act'] as $act) {
357 if (isset($act['close'])) {
358 $T->resolution = 'fixed';
361 if (isset($act['spent'])) {
362 $T->addEffort($act['spent']);
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");
377 foreach ($time as $spent) {
378 $T->addEffort($spent);
385 foreach ($no_ticket as $c) {
386 $log .= "(In " . $c->rev . ") ";
387 if ($c->changeby != $me) {
388 $log .= " (on behalf of [user:$c->changeby]) ";
390 $log .= $c->changelog . "\n\n";
392 $CS = MTrackChangeset::begin("repo:" . $this->repo->repoid, $log);
396 function checkPHP($filename, $fp) {
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')
405 stream_copy_to_stream($fp, $pipes[0]);
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);
415 $st = proc_get_status($proc);
417 if ($st['exitcode'] != 0) {
418 throw new Exception("$filename: $output");