PageRenderTime 54ms CodeModel.GetById 26ms RepoModel.GetById 1ms app.codeStats 0ms

/bin/send-notifications.php

https://bitbucket.org/pmjones/mtrack
PHP | 592 lines | 496 code | 53 blank | 43 comment | 79 complexity | 82c8c8fab25e44242aa383829e4d274b 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. include dirname(__FILE__) . '/../inc/common.php';
  4. $DEBUG = strlen(getenv('DEBUG_NOTIFY')) ? true : false;
  5. $MAX_DIFF = 200 * 1024;
  6. $USE_BATCHING = false;
  7. if (!$DEBUG) {
  8. /* only allow one instance to run concurrently */
  9. $lockfp = fopen(dirname(__FILE__) . '/../var/.notifier.lock', 'w');
  10. if (!$lockfp) {
  11. exit(1);
  12. }
  13. if (!flock($lockfp, LOCK_EX|LOCK_NB)) {
  14. echo "Another instance is already running\n";
  15. exit(1);
  16. }
  17. /* "leak" $lockfp, so that the lock is held while we continue to run */
  18. }
  19. $db = MTrackDB::get();
  20. // default to the last 10 minutes, but prefer the last recorded run time
  21. $last = MTrackDB::unixtime(time() - 600);
  22. foreach (MTrackDB::q('select last_run from last_notification')->fetchAll()
  23. as $row) {
  24. $last = $row[0];
  25. }
  26. $LATEST = strtotime($last);
  27. if ($DEBUG) {
  28. $dtime = strtotime(getenv('DEBUG_TIME'));
  29. if ($dtime > 0) {
  30. $LATEST = $dtime;
  31. $last = MTrackDB::unixtime($LATEST);
  32. echo "Using $last as last time (specified via DEBUG_TIME var)\n";
  33. }
  34. }
  35. $by_object = array();
  36. // Gather pertinent change information en-masse
  37. $changes = MTrackDB::q("
  38. select * from changes where changedate > ? order by changedate asc", $last)
  39. ->fetchAll(PDO::FETCH_OBJ);
  40. $cids = array();
  41. $cs_by_cid = array();
  42. foreach ($changes as $CS) {
  43. $cids[] = $CS->cid;
  44. $cs_by_cid[$CS->cid] = $CS;
  45. $t = strtotime($CS->changedate);
  46. if ($t > $LATEST) {
  47. $LATEST = $t;
  48. }
  49. list($object, $id) = explode(':', $CS->object, 3);
  50. switch ($object) {
  51. case 'ticket':
  52. case 'wiki':
  53. case 'milestone':
  54. $by_object[$object][$id][] = $CS->cid;
  55. break;
  56. }
  57. }
  58. $cidlist = join(',', $cids);
  59. $change_audit = array();
  60. $effort_audit = array();
  61. if (count($cids)) {
  62. foreach (MTrackDB::q("select * from change_audit where cid in ($cidlist)")
  63. ->fetchAll(PDO::FETCH_ASSOC) as $citem) {
  64. $change_audit[$citem['cid']][] = $citem;
  65. }
  66. foreach (MTrackDB::q(
  67. "select * from effort where cid in ($cidlist)")
  68. ->fetchAll(PDO::FETCH_ASSOC) as $eff) {
  69. $effort_audit[$eff['cid']][] = $eff;
  70. }
  71. }
  72. $comp_by_id = array();
  73. foreach (MTrackDB::q('select c.compid, c.name, p.name from components c left join components_by_project cbp on (c.compid = cbp.compid) left join projects p on (cbp.projid = p.projid) where deleted <> 1 order by c.name')
  74. ->fetchAll(PDO::FETCH_NUM) as $row) {
  75. if (strlen($row[2])) {
  76. $row[1] .= " ($row[2])";
  77. }
  78. $comp_by_id[$row[0]] = $row[1];
  79. }
  80. $milestone_by_id = array();
  81. foreach (MTrackDB::q('select mid, name from milestones')->fetchAll() as $m)
  82. {
  83. $milestone_by_id[$m[0]] = $m[1];
  84. }
  85. $proj_by_comp = array();
  86. foreach (MTrackDB::q('select projid, compid from components_by_project')
  87. ->fetchAll() as $r) {
  88. $proj_by_comp[$r['compid']] = $r['projid'];
  89. }
  90. $proj_by_id = array();
  91. foreach (MTrackDB::q('select projid from projects')->fetchAll() as $r) {
  92. $proj_by_id[$r[0]] = MTrackProject::loadById($r[0]);
  93. }
  94. function get_user_info($name)
  95. {
  96. $name = mtrack_canon_username($name);
  97. static $info = array();
  98. if (isset($info[$name])) {
  99. return $info[$name];
  100. }
  101. foreach (MTrackDB::q('select * from userinfo where userid = ?', $name)
  102. ->fetchAll(PDO::FETCH_ASSOC) as $data) {
  103. $info[$name] = $data;
  104. return $data;
  105. }
  106. // fake it
  107. $data = array(
  108. 'userid' => $name,
  109. 'email' => $name . '@' . MTrackConfig::get('core', 'default_email_domain'),
  110. 'fullname' => $name,
  111. );
  112. $info[$name] = $data;
  113. return $data;
  114. }
  115. // Need to assess changesets from the various repos to catch changes
  116. // that don't reference tickets
  117. // TODO: For mercurial and presumably other DVCS, we need to track changegroups
  118. // independently, because the timestamp of the push is not stored in the repo.
  119. // This means that it is possible to sit on a commit for a couple of days
  120. // before pushing it, and this script will not see it because the log date in
  121. // the repo will always be far in the past even though the push just happened.
  122. $repo_changes_by_ticket = array();
  123. $repos_by_name = array();
  124. foreach (MTrackDB::q('select shortname from repos order by shortname')
  125. ->fetchAll(PDO::FETCH_COLUMN, 0) as $reponame) {
  126. $repo = MTrackRepo::loadByName($reponame);
  127. $repos_by_name[$reponame] = $repo;
  128. $checker = new MTrackCommitChecker($repo);
  129. foreach ($repo->history(null, $last) as $e) {
  130. // the SCM may give us information on an item older than the date
  131. // we requested, so we need to filter here too
  132. $t = strtotime($e->ctime);
  133. if ($t <= strtotime($last)) {
  134. echo "[$e->rev] exclude $e->ctime $last\n";
  135. continue;
  136. }
  137. echo "[$e->rev] include $e->ctime $last\n";
  138. if ($t > $LATEST) {
  139. $LATEST = $t;
  140. echo " update latest to $t\n";
  141. }
  142. $pid = $repo->projectFromPath($e->files);
  143. if ($pid > 1) {
  144. $proj = $proj_by_id[$pid];
  145. $e->changelog = $proj->adjust_links($e->changelog, true);
  146. }
  147. $actions = $checker->parseCommitMessage($e->changelog);
  148. $tickets = array();
  149. foreach ($actions as $act) {
  150. $tkt = $act[1];
  151. $tickets[$tkt] = $tkt;
  152. $repo_changes_by_ticket[$tkt][$reponame][$e->rev] = $e->rev;
  153. }
  154. if (count($tickets) == 0) {
  155. // This changeset is not represented by a change in a ticket
  156. $by_object['changeset'][$reponame][$e->rev] = $e;
  157. }
  158. }
  159. }
  160. $batch_fields = array(
  161. 'owner' => true,
  162. 'status' => true,
  163. 'priority' => true,
  164. '@milestones' => true,
  165. );
  166. function major_contributor($list)
  167. {
  168. $major = null;
  169. $count = 0;
  170. foreach ($list as $user => $input) {
  171. if ($input > $count) {
  172. $major = $user;
  173. $count = $input;
  174. }
  175. }
  176. return $major;
  177. }
  178. if (isset($by_object['ticket'])) {
  179. // If tickets were subject to roadmap/pri change only, say as part of
  180. // a mass update, then we recognize those and send out a batch notification
  181. // separately
  182. $batch_tkt = array();
  183. $tkt_by_tid = array();
  184. foreach ($by_object['ticket'] as $tid => $cslist) {
  185. $T = MTrackIssue::loadById($tid);
  186. $fields = array();
  187. $oid = "ticket:$tid";
  188. $is_batch = true;
  189. $old_values = array();
  190. $comments = array();
  191. $field_changers = array();
  192. $contributors = array();
  193. $projects = array(); // defines who gets notified
  194. foreach ($cslist as $cid) {
  195. $contributors[$cs_by_cid[$cid]->who]++;
  196. if (isset($change_audit[$cid])) foreach ($change_audit[$cid] as $C) {
  197. // fieldname is of the form: "ticket:id:fieldname"
  198. $field = substr($C['fieldname'], strlen($oid)+1);
  199. if (!isset($batch_fields[$field])) {
  200. $is_batch = false;
  201. }
  202. if ($field == '@comment') {
  203. $comments[] = "Comment by " .
  204. $cs_by_cid[$cid]->who . ":\n" . $C['value'];
  205. } elseif ($field != 'spent') {
  206. $field_changers[$field] = $cs_by_cid[$cid]->who;
  207. if (!isset($old_values[$field])) {
  208. $old_values[$field] = $C['oldvalue'];
  209. }
  210. }
  211. $contributors[$cs_by_cid[$cid]->who]++;
  212. }
  213. }
  214. if ($is_batch && $USE_BATCHING) {
  215. $batch_tkt[$tid] = $cslist;
  216. $tkt_by_tid[$tid] = $T;
  217. continue;
  218. }
  219. /* changes were not suitable for batching, so now we represent them
  220. * as history on that ticket */
  221. $plain = '';
  222. $plain .= MTrackConfig::get('core', 'weburl')
  223. . "ticket.php/$T->nsident\n\n";
  224. $plain .= "#$T->nsident: $T->summary ($T->status $T->classification)\n";
  225. $owner = strlen($T->owner) == 0 ? 'nobody' : $T->owner;
  226. $plain .= "Responsible: $owner ($T->priority / $T->severity)\n";
  227. $plain .= "Milestone: " .
  228. join(', ', $T->getMilestones()) . "\n";
  229. $plain .= "Component: " .
  230. join(', ', $T->getComponents()) . "\n";
  231. $plain .= "\n";
  232. foreach ($T->getComponents() as $compid => $comp) {
  233. $projects[$proj_by_comp[$compid]]++;
  234. }
  235. // Display changed fields grouped by the person that last changed them
  236. $who_changed = array();
  237. foreach ($field_changers as $field => $who) {
  238. $who_changed[$who][] = $field;
  239. }
  240. foreach ($who_changed as $who => $fieldlist) {
  241. $plain .= "Changes by $who:\n";
  242. foreach ($fieldlist as $field) {
  243. $old = $old_values[$field];
  244. if (!strlen($old) && $field == 'nsident') {
  245. continue;
  246. }
  247. $value = null;
  248. switch ($field) {
  249. case '@components':
  250. $old = array();
  251. foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
  252. $old[] = $comp_by_id[$id];
  253. $projects[$proj_by_comp[$id]]++;
  254. }
  255. $value = array();
  256. foreach ($T->getComponents() as $id => $compname) {
  257. $value[$id] = $comp_by_id[$id];
  258. }
  259. $field = 'Component';
  260. break;
  261. case '@milestones':
  262. $old = array();
  263. foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
  264. $old[] = $milestone_by_id[$id];
  265. }
  266. $value = $T->getMilestones();
  267. $field = 'Milestone';
  268. break;
  269. case '@keywords':
  270. $field = 'Keywords';
  271. $value = $T->getKeywords();
  272. break;
  273. default:
  274. $value = $T->$field;
  275. }
  276. if (is_array($value)) {
  277. $value = join(', ', $value);
  278. }
  279. if (is_array($old)) {
  280. $old = join(', ', $old);
  281. }
  282. if ($value == $old) {
  283. continue;
  284. }
  285. if ($field == 'description') {
  286. $lines = count(explode("\n", $old));
  287. $diff = mtrack_diff_strings($old, $value);
  288. $diff_add = 0;
  289. $diff_rem = 0;
  290. foreach (explode("\n", $diff) as $line) {
  291. if ($line[0] == '-') {
  292. $diff_rem++;
  293. } else if ($line[0] == '+') {
  294. $diff_add++;
  295. }
  296. }
  297. if (abs($diff_add - $diff_rem) > $lines / 2) {
  298. $plain .= "Description changed to:\n" . $value . "\n\n";
  299. } else {
  300. $plain .= "Description changed:\n" . $diff . "\n\n";
  301. }
  302. } else {
  303. $plain .= "$field $old -> $value\n";
  304. }
  305. }
  306. }
  307. foreach ($comments as $comment) {
  308. // parse the description to see if it matches the "(In
  309. // [changeset:repo,id], [changeset:repo,id]...)" form. This is needed because
  310. // there is a possibility that we will opt to exclude a changeset at the top of
  311. // this script based on date. We want to pluck out all the changesets that are
  312. // referenced in such a way and add them to the $repo_changes_by_ticket array
  313. // for this ticket
  314. // FIXME: need to handle the multiple changeset case for DVCS like hg.
  315. if (preg_match_all(
  316. "/\(In \[changeset:(([^,]+),([a-zA-Z0-9]+))\]\)/sm",
  317. $comment, $CSM)) {
  318. // $CSM[2] => repo
  319. // $CSM[3] => changeset
  320. foreach ($CSM[2] as $csm => $csm_repo) {
  321. $csm_rev = $CSM[3][$csm];
  322. if (!isset($repo_changes_by_ticket[$T->nsident]) ||
  323. !in_array($csm_rev,
  324. $repo_changes_by_ticket[$T->nsident][$csm_repo])) {
  325. $repo_changes_by_ticket[$T->nsident][$csm_repo][] = $csm_rev;
  326. }
  327. }
  328. }
  329. $plain .= "\n" . $comment . "\n";
  330. }
  331. /* bundle up the changesets along with the ticket changes */
  332. if (isset($repo_changes_by_ticket[$T->nsident])) {
  333. foreach ($repo_changes_by_ticket[$T->nsident] as $reponame => $revlist) {
  334. $plain .= "\nChanges in $reponame:\n";
  335. $repo = $repos_by_name[$reponame];
  336. /* TODO: 'compress' a run of changes so that we show the net change */
  337. // all affected files in this repo for this notification
  338. $files = array();
  339. $ent_by_rev = array();
  340. foreach ($revlist as $rev) {
  341. list($ent) = $repo->history(null, 1, 'rev', $rev);
  342. $ent_by_rev[$rev] = $ent;
  343. $projects[$repo->projectFromPath($ent->files)]++;
  344. foreach ($ent->files as $file) {
  345. $files[$file] = "$file->status $file->name";
  346. }
  347. }
  348. $plain .= " Affected files:\n " . join("\n ", $files) . "\n";
  349. $too_big = false;
  350. foreach ($ent_by_rev as $ent) {
  351. $plain .= "\n[$ent->rev] by $ent->changeby\n";
  352. $plain .= MTrackConfig::get('core', 'weburl')
  353. . "changeset.php/$reponame/$ent->rev\n\n";
  354. if (strlen($plain) < $MAX_DIFF) {
  355. foreach ($ent->files as $file) {
  356. $diff = stream_get_contents($repo->diff($file, $ent->rev));
  357. if (strlen($plain) + strlen($diff) < $MAX_DIFF) {
  358. $plain .= $diff . "\n";
  359. } else {
  360. $too_big = true;
  361. }
  362. }
  363. } else {
  364. $too_big = true;
  365. }
  366. }
  367. if ($too_big) {
  368. $plain .= " ** Diff exceeds configured limit\n";
  369. }
  370. }
  371. }
  372. $plain .= "\n" . MTrackConfig::get('core', 'weburl')
  373. . "ticket.php/$T->nsident\n";
  374. $to = preg_split("/(\s+|\s*[,;]\s*)/", $T->cc);
  375. $fromuser = major_contributor($contributors);
  376. $U = get_user_info($fromuser);
  377. $from = "From: <$U[email]> \"$U[fullname]\"\n";
  378. $to[$U['email']] = $U['email'];
  379. $repto = array("<$U[email]> \"$U[fullname]\"");
  380. foreach ($contributors as $c => $count) {
  381. if ($c !== $fromuser) {
  382. $U = get_user_info($c);
  383. if (strlen($U['email'])) {
  384. $repto[] = "<$U[email]> \"$U[fullname]\"";
  385. $to[$U['email']] = $U['email'];
  386. }
  387. }
  388. }
  389. if (count($repto) > 1) {
  390. $repto = "Reply-To: " . join(", ", $repto) . "\n";
  391. } else {
  392. $repto = '';
  393. }
  394. $pnames = array();
  395. if (count($projects) == 0) {
  396. $projects[1] = 1; // force in a default project
  397. }
  398. foreach ($projects as $pid => $count) {
  399. $P = $proj_by_id[$pid];
  400. if (is_object($P)) {
  401. $pnames[$P->shortname] = $P->shortname;
  402. $to[$P->notifyemail] = $P->notifyemail;
  403. }
  404. }
  405. natsort($pnames);
  406. // compute base message-id
  407. $mid = $T->tid . '@' . php_uname('n');
  408. // if one of the changesets we saw in this run matches the created cid
  409. // then we are the initial message
  410. $is_initial = false;
  411. foreach ($by_object['ticket'][$T->tid] as $cid) {
  412. if ($cid == $T->created) {
  413. $is_initial = true;
  414. }
  415. }
  416. if ($is_initial) {
  417. $mid = "Message-ID: <$mid>\n";
  418. } else {
  419. $mid = "Message-ID: <$T->updated.$mid>\n" .
  420. "In-Reply-To: <$mid>\n" .
  421. "References: <$mid>\n";
  422. }
  423. $phdrs = "X-mtrack-project-list: " . join(' ', $pnames) . "\n";
  424. foreach ($pnames as $p) {
  425. $phdrs .= "X-mtrack-project-$p: $p\n";
  426. $phdrs .= "X-mtrack-project: $p\n";
  427. }
  428. foreach ($to as $_ => $recip) {
  429. if (!strlen($recip)) {
  430. unset($to[$_]);
  431. }
  432. }
  433. $mail = $from . $repto . $mid .
  434. "To: " . join(', ', $to) . "\n" .
  435. 'Subject: =?UTF-8?B?' . base64_encode('[' . join(' ', $pnames) . "] #$T->nsident $T->summary ($T->status $T->classification)") . '?=' . "\n" .
  436. "MIME-Version: 1.0\n" .
  437. "Content-Type: text/plain; charset=utf-8\n" .
  438. "X-mtrack-ticket: $T->nsident\n" .
  439. $phdrs .
  440. "\n" .
  441. $plain
  442. ;
  443. $reciplist = array();
  444. foreach ($to as $recip) {
  445. $reciplist[] = escapeshellarg($recip);
  446. }
  447. $reciplist = join(' ', $reciplist);
  448. if ($DEBUG) {
  449. echo "would mail: $reciplist\n$mail\n";
  450. } else {
  451. $pipe = popen("/usr/sbin/sendmail $reciplist", 'w');
  452. fwrite($pipe, $mail);
  453. pclose($pipe);
  454. }
  455. }
  456. if (count($batch_tkt) > 0) { // FIXME: implement batched ticket overview
  457. $plain = '';
  458. echo "\nBatched ticket notifications\n";
  459. foreach ($batch_tkt as $tid => $cslist) {
  460. $T = $tkt_by_tid[$tid];
  461. echo " $T->nsident";
  462. }
  463. echo "\n";
  464. }
  465. }
  466. if (isset($by_object['changeset']))
  467. foreach ($by_object['changeset'] as $reponame => $revlist) {
  468. $repo = $repos_by_name[$reponame];
  469. foreach ($revlist as $rev => $ent) {
  470. $plain = "Changes in $reponame [$ent->rev] by $ent->changeby:\n";
  471. $plain .= MTrackConfig::get('core', 'weburl')
  472. . "changeset.php/$reponame/$ent->rev\n\n";
  473. $plain .= "\n" . $ent->changelog . "\n\n";
  474. $flist = array();
  475. foreach ($ent->files as $file) {
  476. $flist[] = "$file->status $file->name";
  477. }
  478. $plain .= " Affected files:\n " . join("\n ", $flist) . "\n\n";
  479. if (strlen($plain) < $MAX_DIFF) {
  480. foreach ($ent->files as $file) {
  481. $diff = stream_get_contents($repo->diff($file, $ent->rev));
  482. if (strlen($plain) + strlen($diff) < $MAX_DIFF) {
  483. $plain .= $diff . "\n";
  484. } else {
  485. $too_big = true;
  486. }
  487. }
  488. } else {
  489. $too_big = true;
  490. }
  491. if ($too_big) {
  492. $plain .= " ** Diff exceeds configured limit\n";
  493. }
  494. $U = get_user_info($ent->changeby);
  495. $from = "From: <$U[email]> \"$U[fullname]\"\n";
  496. $pid = $repo->projectFromPath($ent->files);
  497. if ($pid === null) $pid = 1;
  498. $P = $proj_by_id[$pid];
  499. $mail = $from .
  500. "To: $P->notifyemail\n" .
  501. 'Subject: =?UTF-8?B?' . base64_encode("[$P->shortname] $reponame commit [$ent->rev]") . '?=' . "\n" .
  502. "MIME-Version: 1.0\n" .
  503. "Content-Type: text/plain; charset=utf-8\n" .
  504. "\n" .
  505. $plain
  506. ;
  507. if ($DEBUG) {
  508. echo "Would mail: $P->notifyemail\n";
  509. echo $mail . "\n";
  510. } else {
  511. $pipe = popen("/usr/sbin/sendmail " .
  512. escapeshellarg($P->notifyemail), 'w');
  513. fwrite($pipe, $mail);
  514. pclose($pipe);
  515. }
  516. }
  517. }
  518. if (isset($by_object['wiki'])) {
  519. foreach ($by_object['wiki'] as $pagename => $cslist) {
  520. //echo "Wiki $pagename\n";
  521. // TODO: implement wiki page change notification
  522. }
  523. }
  524. if (isset($by_object['milestone'])) {
  525. foreach ($by_object['milestone'] as $mname => $cslist) {
  526. //echo "Milestone $mname\n";
  527. // TODO: implement milestone change notification
  528. }
  529. }
  530. if (!$DEBUG) {
  531. // Now we are done, update the last run time
  532. $last_change = end($changes);
  533. $db->beginTransaction();
  534. $db->exec("delete from last_notification");
  535. $t = MTrackDB::unixtime($LATEST);
  536. echo "updating last run to $t $LATEST\n";
  537. $db->exec("insert into last_notification (last_run) values ('$t')");
  538. $db->commit();
  539. }