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