PageRenderTime 38ms CodeModel.GetById 14ms RepoModel.GetById 0ms app.codeStats 0ms

/inc/commit-hook.php

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