/bin/send-notifications.php
PHP | 592 lines | 496 code | 53 blank | 43 comment | 79 complexity | 82c8c8fab25e44242aa383829e4d274b MD5 | raw file
Possible License(s): BSD-3-Clause, Apache-2.0
- <?php # vim:ts=2:sw=2:et:
- /* For licensing and copyright terms, see the file named LICENSE */
- include dirname(__FILE__) . '/../inc/common.php';
- $DEBUG = strlen(getenv('DEBUG_NOTIFY')) ? true : false;
- $MAX_DIFF = 200 * 1024;
- $USE_BATCHING = false;
- if (!$DEBUG) {
- /* only allow one instance to run concurrently */
- $lockfp = fopen(dirname(__FILE__) . '/../var/.notifier.lock', 'w');
- if (!$lockfp) {
- exit(1);
- }
- if (!flock($lockfp, LOCK_EX|LOCK_NB)) {
- echo "Another instance is already running\n";
- exit(1);
- }
- /* "leak" $lockfp, so that the lock is held while we continue to run */
- }
- $db = MTrackDB::get();
- // default to the last 10 minutes, but prefer the last recorded run time
- $last = MTrackDB::unixtime(time() - 600);
- foreach (MTrackDB::q('select last_run from last_notification')->fetchAll()
- as $row) {
- $last = $row[0];
- }
- $LATEST = strtotime($last);
- if ($DEBUG) {
- $dtime = strtotime(getenv('DEBUG_TIME'));
- if ($dtime > 0) {
- $LATEST = $dtime;
- $last = MTrackDB::unixtime($LATEST);
- echo "Using $last as last time (specified via DEBUG_TIME var)\n";
- }
- }
- $by_object = array();
- // Gather pertinent change information en-masse
- $changes = MTrackDB::q("
- select * from changes where changedate > ? order by changedate asc", $last)
- ->fetchAll(PDO::FETCH_OBJ);
- $cids = array();
- $cs_by_cid = array();
- foreach ($changes as $CS) {
- $cids[] = $CS->cid;
- $cs_by_cid[$CS->cid] = $CS;
- $t = strtotime($CS->changedate);
- if ($t > $LATEST) {
- $LATEST = $t;
- }
- list($object, $id) = explode(':', $CS->object, 3);
- switch ($object) {
- case 'ticket':
- case 'wiki':
- case 'milestone':
- $by_object[$object][$id][] = $CS->cid;
- break;
- }
- }
- $cidlist = join(',', $cids);
- $change_audit = array();
- $effort_audit = array();
- if (count($cids)) {
- foreach (MTrackDB::q("select * from change_audit where cid in ($cidlist)")
- ->fetchAll(PDO::FETCH_ASSOC) as $citem) {
- $change_audit[$citem['cid']][] = $citem;
- }
- foreach (MTrackDB::q(
- "select * from effort where cid in ($cidlist)")
- ->fetchAll(PDO::FETCH_ASSOC) as $eff) {
- $effort_audit[$eff['cid']][] = $eff;
- }
- }
- $comp_by_id = array();
- 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')
- ->fetchAll(PDO::FETCH_NUM) as $row) {
- if (strlen($row[2])) {
- $row[1] .= " ($row[2])";
- }
- $comp_by_id[$row[0]] = $row[1];
- }
- $milestone_by_id = array();
- foreach (MTrackDB::q('select mid, name from milestones')->fetchAll() as $m)
- {
- $milestone_by_id[$m[0]] = $m[1];
- }
- $proj_by_comp = array();
- foreach (MTrackDB::q('select projid, compid from components_by_project')
- ->fetchAll() as $r) {
- $proj_by_comp[$r['compid']] = $r['projid'];
- }
- $proj_by_id = array();
- foreach (MTrackDB::q('select projid from projects')->fetchAll() as $r) {
- $proj_by_id[$r[0]] = MTrackProject::loadById($r[0]);
- }
- function get_user_info($name)
- {
- $name = mtrack_canon_username($name);
- static $info = array();
- if (isset($info[$name])) {
- return $info[$name];
- }
- foreach (MTrackDB::q('select * from userinfo where userid = ?', $name)
- ->fetchAll(PDO::FETCH_ASSOC) as $data) {
- $info[$name] = $data;
- return $data;
- }
- // fake it
- $data = array(
- 'userid' => $name,
- 'email' => $name . '@' . MTrackConfig::get('core', 'default_email_domain'),
- 'fullname' => $name,
- );
- $info[$name] = $data;
- return $data;
- }
- // Need to assess changesets from the various repos to catch changes
- // that don't reference tickets
- // TODO: For mercurial and presumably other DVCS, we need to track changegroups
- // independently, because the timestamp of the push is not stored in the repo.
- // This means that it is possible to sit on a commit for a couple of days
- // before pushing it, and this script will not see it because the log date in
- // the repo will always be far in the past even though the push just happened.
- $repo_changes_by_ticket = array();
- $repos_by_name = array();
- foreach (MTrackDB::q('select shortname from repos order by shortname')
- ->fetchAll(PDO::FETCH_COLUMN, 0) as $reponame) {
- $repo = MTrackRepo::loadByName($reponame);
- $repos_by_name[$reponame] = $repo;
- $checker = new MTrackCommitChecker($repo);
- foreach ($repo->history(null, $last) as $e) {
- // the SCM may give us information on an item older than the date
- // we requested, so we need to filter here too
- $t = strtotime($e->ctime);
- if ($t <= strtotime($last)) {
- echo "[$e->rev] exclude $e->ctime $last\n";
- continue;
- }
- echo "[$e->rev] include $e->ctime $last\n";
- if ($t > $LATEST) {
- $LATEST = $t;
- echo " update latest to $t\n";
- }
- $pid = $repo->projectFromPath($e->files);
- if ($pid > 1) {
- $proj = $proj_by_id[$pid];
- $e->changelog = $proj->adjust_links($e->changelog, true);
- }
- $actions = $checker->parseCommitMessage($e->changelog);
- $tickets = array();
- foreach ($actions as $act) {
- $tkt = $act[1];
- $tickets[$tkt] = $tkt;
- $repo_changes_by_ticket[$tkt][$reponame][$e->rev] = $e->rev;
- }
- if (count($tickets) == 0) {
- // This changeset is not represented by a change in a ticket
- $by_object['changeset'][$reponame][$e->rev] = $e;
- }
- }
- }
- $batch_fields = array(
- 'owner' => true,
- 'status' => true,
- 'priority' => true,
- '@milestones' => true,
- );
- function major_contributor($list)
- {
- $major = null;
- $count = 0;
- foreach ($list as $user => $input) {
- if ($input > $count) {
- $major = $user;
- $count = $input;
- }
- }
- return $major;
- }
- if (isset($by_object['ticket'])) {
- // If tickets were subject to roadmap/pri change only, say as part of
- // a mass update, then we recognize those and send out a batch notification
- // separately
- $batch_tkt = array();
- $tkt_by_tid = array();
- foreach ($by_object['ticket'] as $tid => $cslist) {
- $T = MTrackIssue::loadById($tid);
- $fields = array();
- $oid = "ticket:$tid";
- $is_batch = true;
- $old_values = array();
- $comments = array();
- $field_changers = array();
- $contributors = array();
- $projects = array(); // defines who gets notified
- foreach ($cslist as $cid) {
- $contributors[$cs_by_cid[$cid]->who]++;
- if (isset($change_audit[$cid])) foreach ($change_audit[$cid] as $C) {
- // fieldname is of the form: "ticket:id:fieldname"
- $field = substr($C['fieldname'], strlen($oid)+1);
- if (!isset($batch_fields[$field])) {
- $is_batch = false;
- }
- if ($field == '@comment') {
- $comments[] = "Comment by " .
- $cs_by_cid[$cid]->who . ":\n" . $C['value'];
- } elseif ($field != 'spent') {
- $field_changers[$field] = $cs_by_cid[$cid]->who;
- if (!isset($old_values[$field])) {
- $old_values[$field] = $C['oldvalue'];
- }
- }
- $contributors[$cs_by_cid[$cid]->who]++;
- }
- }
- if ($is_batch && $USE_BATCHING) {
- $batch_tkt[$tid] = $cslist;
- $tkt_by_tid[$tid] = $T;
- continue;
- }
- /* changes were not suitable for batching, so now we represent them
- * as history on that ticket */
- $plain = '';
- $plain .= MTrackConfig::get('core', 'weburl')
- . "ticket.php/$T->nsident\n\n";
- $plain .= "#$T->nsident: $T->summary ($T->status $T->classification)\n";
- $owner = strlen($T->owner) == 0 ? 'nobody' : $T->owner;
- $plain .= "Responsible: $owner ($T->priority / $T->severity)\n";
- $plain .= "Milestone: " .
- join(', ', $T->getMilestones()) . "\n";
- $plain .= "Component: " .
- join(', ', $T->getComponents()) . "\n";
- $plain .= "\n";
- foreach ($T->getComponents() as $compid => $comp) {
- $projects[$proj_by_comp[$compid]]++;
- }
- // Display changed fields grouped by the person that last changed them
- $who_changed = array();
- foreach ($field_changers as $field => $who) {
- $who_changed[$who][] = $field;
- }
- foreach ($who_changed as $who => $fieldlist) {
- $plain .= "Changes by $who:\n";
- foreach ($fieldlist as $field) {
- $old = $old_values[$field];
- if (!strlen($old) && $field == 'nsident') {
- continue;
- }
- $value = null;
- switch ($field) {
- case '@components':
- $old = array();
- foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
- $old[] = $comp_by_id[$id];
- $projects[$proj_by_comp[$id]]++;
- }
- $value = array();
- foreach ($T->getComponents() as $id => $compname) {
- $value[$id] = $comp_by_id[$id];
- }
- $field = 'Component';
- break;
- case '@milestones':
- $old = array();
- foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
- $old[] = $milestone_by_id[$id];
- }
- $value = $T->getMilestones();
- $field = 'Milestone';
- break;
- case '@keywords':
- $field = 'Keywords';
- $value = $T->getKeywords();
- break;
- default:
- $value = $T->$field;
- }
- if (is_array($value)) {
- $value = join(', ', $value);
- }
- if (is_array($old)) {
- $old = join(', ', $old);
- }
- if ($value == $old) {
- continue;
- }
- if ($field == 'description') {
- $lines = count(explode("\n", $old));
- $diff = mtrack_diff_strings($old, $value);
- $diff_add = 0;
- $diff_rem = 0;
- foreach (explode("\n", $diff) as $line) {
- if ($line[0] == '-') {
- $diff_rem++;
- } else if ($line[0] == '+') {
- $diff_add++;
- }
- }
- if (abs($diff_add - $diff_rem) > $lines / 2) {
- $plain .= "Description changed to:\n" . $value . "\n\n";
- } else {
- $plain .= "Description changed:\n" . $diff . "\n\n";
- }
- } else {
- $plain .= "$field $old -> $value\n";
- }
- }
- }
- foreach ($comments as $comment) {
- // parse the description to see if it matches the "(In
- // [changeset:repo,id], [changeset:repo,id]...)" form. This is needed because
- // there is a possibility that we will opt to exclude a changeset at the top of
- // this script based on date. We want to pluck out all the changesets that are
- // referenced in such a way and add them to the $repo_changes_by_ticket array
- // for this ticket
- // FIXME: need to handle the multiple changeset case for DVCS like hg.
- if (preg_match_all(
- "/\(In \[changeset:(([^,]+),([a-zA-Z0-9]+))\]\)/sm",
- $comment, $CSM)) {
- // $CSM[2] => repo
- // $CSM[3] => changeset
- foreach ($CSM[2] as $csm => $csm_repo) {
- $csm_rev = $CSM[3][$csm];
- if (!isset($repo_changes_by_ticket[$T->nsident]) ||
- !in_array($csm_rev,
- $repo_changes_by_ticket[$T->nsident][$csm_repo])) {
- $repo_changes_by_ticket[$T->nsident][$csm_repo][] = $csm_rev;
- }
- }
- }
- $plain .= "\n" . $comment . "\n";
- }
- /* bundle up the changesets along with the ticket changes */
- if (isset($repo_changes_by_ticket[$T->nsident])) {
- foreach ($repo_changes_by_ticket[$T->nsident] as $reponame => $revlist) {
- $plain .= "\nChanges in $reponame:\n";
- $repo = $repos_by_name[$reponame];
- /* TODO: 'compress' a run of changes so that we show the net change */
- // all affected files in this repo for this notification
- $files = array();
- $ent_by_rev = array();
- foreach ($revlist as $rev) {
- list($ent) = $repo->history(null, 1, 'rev', $rev);
- $ent_by_rev[$rev] = $ent;
- $projects[$repo->projectFromPath($ent->files)]++;
- foreach ($ent->files as $file) {
- $files[$file] = "$file->status $file->name";
- }
- }
- $plain .= " Affected files:\n " . join("\n ", $files) . "\n";
- $too_big = false;
- foreach ($ent_by_rev as $ent) {
- $plain .= "\n[$ent->rev] by $ent->changeby\n";
- $plain .= MTrackConfig::get('core', 'weburl')
- . "changeset.php/$reponame/$ent->rev\n\n";
- if (strlen($plain) < $MAX_DIFF) {
- foreach ($ent->files as $file) {
- $diff = stream_get_contents($repo->diff($file, $ent->rev));
- if (strlen($plain) + strlen($diff) < $MAX_DIFF) {
- $plain .= $diff . "\n";
- } else {
- $too_big = true;
- }
- }
- } else {
- $too_big = true;
- }
- }
- if ($too_big) {
- $plain .= " ** Diff exceeds configured limit\n";
- }
- }
- }
- $plain .= "\n" . MTrackConfig::get('core', 'weburl')
- . "ticket.php/$T->nsident\n";
- $to = preg_split("/(\s+|\s*[,;]\s*)/", $T->cc);
- $fromuser = major_contributor($contributors);
- $U = get_user_info($fromuser);
- $from = "From: <$U[email]> \"$U[fullname]\"\n";
- $to[$U['email']] = $U['email'];
- $repto = array("<$U[email]> \"$U[fullname]\"");
- foreach ($contributors as $c => $count) {
- if ($c !== $fromuser) {
- $U = get_user_info($c);
- if (strlen($U['email'])) {
- $repto[] = "<$U[email]> \"$U[fullname]\"";
- $to[$U['email']] = $U['email'];
- }
- }
- }
- if (count($repto) > 1) {
- $repto = "Reply-To: " . join(", ", $repto) . "\n";
- } else {
- $repto = '';
- }
- $pnames = array();
- if (count($projects) == 0) {
- $projects[1] = 1; // force in a default project
- }
- foreach ($projects as $pid => $count) {
- $P = $proj_by_id[$pid];
- if (is_object($P)) {
- $pnames[$P->shortname] = $P->shortname;
- $to[$P->notifyemail] = $P->notifyemail;
- }
- }
- natsort($pnames);
- // compute base message-id
- $mid = $T->tid . '@' . php_uname('n');
- // if one of the changesets we saw in this run matches the created cid
- // then we are the initial message
- $is_initial = false;
- foreach ($by_object['ticket'][$T->tid] as $cid) {
- if ($cid == $T->created) {
- $is_initial = true;
- }
- }
- if ($is_initial) {
- $mid = "Message-ID: <$mid>\n";
- } else {
- $mid = "Message-ID: <$T->updated.$mid>\n" .
- "In-Reply-To: <$mid>\n" .
- "References: <$mid>\n";
- }
- $phdrs = "X-mtrack-project-list: " . join(' ', $pnames) . "\n";
- foreach ($pnames as $p) {
- $phdrs .= "X-mtrack-project-$p: $p\n";
- $phdrs .= "X-mtrack-project: $p\n";
- }
- foreach ($to as $_ => $recip) {
- if (!strlen($recip)) {
- unset($to[$_]);
- }
- }
- $mail = $from . $repto . $mid .
- "To: " . join(', ', $to) . "\n" .
- 'Subject: =?UTF-8?B?' . base64_encode('[' . join(' ', $pnames) . "] #$T->nsident $T->summary ($T->status $T->classification)") . '?=' . "\n" .
- "MIME-Version: 1.0\n" .
- "Content-Type: text/plain; charset=utf-8\n" .
- "X-mtrack-ticket: $T->nsident\n" .
- $phdrs .
- "\n" .
- $plain
- ;
- $reciplist = array();
- foreach ($to as $recip) {
- $reciplist[] = escapeshellarg($recip);
- }
- $reciplist = join(' ', $reciplist);
- if ($DEBUG) {
- echo "would mail: $reciplist\n$mail\n";
- } else {
- $pipe = popen("/usr/sbin/sendmail $reciplist", 'w');
- fwrite($pipe, $mail);
- pclose($pipe);
- }
- }
- if (count($batch_tkt) > 0) { // FIXME: implement batched ticket overview
- $plain = '';
- echo "\nBatched ticket notifications\n";
- foreach ($batch_tkt as $tid => $cslist) {
- $T = $tkt_by_tid[$tid];
- echo " $T->nsident";
- }
- echo "\n";
- }
- }
- if (isset($by_object['changeset']))
- foreach ($by_object['changeset'] as $reponame => $revlist) {
- $repo = $repos_by_name[$reponame];
- foreach ($revlist as $rev => $ent) {
- $plain = "Changes in $reponame [$ent->rev] by $ent->changeby:\n";
- $plain .= MTrackConfig::get('core', 'weburl')
- . "changeset.php/$reponame/$ent->rev\n\n";
- $plain .= "\n" . $ent->changelog . "\n\n";
- $flist = array();
- foreach ($ent->files as $file) {
- $flist[] = "$file->status $file->name";
- }
- $plain .= " Affected files:\n " . join("\n ", $flist) . "\n\n";
- if (strlen($plain) < $MAX_DIFF) {
- foreach ($ent->files as $file) {
- $diff = stream_get_contents($repo->diff($file, $ent->rev));
- if (strlen($plain) + strlen($diff) < $MAX_DIFF) {
- $plain .= $diff . "\n";
- } else {
- $too_big = true;
- }
- }
- } else {
- $too_big = true;
- }
- if ($too_big) {
- $plain .= " ** Diff exceeds configured limit\n";
- }
- $U = get_user_info($ent->changeby);
- $from = "From: <$U[email]> \"$U[fullname]\"\n";
- $pid = $repo->projectFromPath($ent->files);
- if ($pid === null) $pid = 1;
- $P = $proj_by_id[$pid];
- $mail = $from .
- "To: $P->notifyemail\n" .
- 'Subject: =?UTF-8?B?' . base64_encode("[$P->shortname] $reponame commit [$ent->rev]") . '?=' . "\n" .
- "MIME-Version: 1.0\n" .
- "Content-Type: text/plain; charset=utf-8\n" .
- "\n" .
- $plain
- ;
- if ($DEBUG) {
- echo "Would mail: $P->notifyemail\n";
- echo $mail . "\n";
- } else {
- $pipe = popen("/usr/sbin/sendmail " .
- escapeshellarg($P->notifyemail), 'w');
- fwrite($pipe, $mail);
- pclose($pipe);
- }
- }
- }
- if (isset($by_object['wiki'])) {
- foreach ($by_object['wiki'] as $pagename => $cslist) {
- //echo "Wiki $pagename\n";
- // TODO: implement wiki page change notification
- }
- }
- if (isset($by_object['milestone'])) {
- foreach ($by_object['milestone'] as $mname => $cslist) {
- //echo "Milestone $mname\n";
- // TODO: implement milestone change notification
- }
- }
- if (!$DEBUG) {
- // Now we are done, update the last run time
- $last_change = end($changes);
- $db->beginTransaction();
- $db->exec("delete from last_notification");
- $t = MTrackDB::unixtime($LATEST);
- echo "updating last run to $t $LATEST\n";
- $db->exec("insert into last_notification (last_run) values ('$t')");
- $db->commit();
- }