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