1 <?php # vim:ts=2:sw=2:et:
2 /* For licensing and copyright terms, see the file named LICENSE */
4 die("make a class of me");
6 if (function_exists('date_default_timezone_set')) {
7 date_default_timezone_set('UTC');
10 include dirname(__FILE__) . '/../inc/common.php';
12 // Force this to be the configure value or something that will guide it
14 $ABSWEB = MTrackConfig::get('core', 'weburl');
15 if (!strlen($ABSWEB)) {
16 $ABSWEB = "(configure [core] weburl in config.ini)";
18 $vardir = MTrackConfig::get('core', 'vardir');
20 $DEBUG = strlen(getenv('DEBUG_NOTIFY')) ? true : false;
21 $NO_MAIL = strlen(getenv('DEBUG_NOMAIL')) ? true : false;
23 $MAX_DIFF = 200 * 1024;
24 $USE_BATCHING = false;
27 /* only allow one instance to run concurrently */
28 $lockfp = fopen($vardir . '/.notifier.lock', 'w');
32 if (!flock($lockfp, LOCK_EX|LOCK_NB)) {
33 echo "Another instance is already running\n";
36 /* "leak" $lockfp, so that the lock is held while we continue to run */
39 $db = MTrackDB::get();
41 // default to the last 10 minutes, but prefer the last recorded run time
42 $last = MTrackDB::unixtime(time() - 600);
43 foreach (MTrackDB::q('select last_run from last_notification')->fetchAll()
47 $LATEST = strtotime($last);
48 if (getenv('DEBUG_TIME')) {
49 $dtime = strtotime(getenv('DEBUG_TIME'));
52 $last = MTrackDB::unixtime($LATEST);
53 echo "Using $last as last time (specified via DEBUG_TIME var)\n";
57 class CanonicalLineEndingFilter extends php_user_filter {
58 function filter($in, $out, &$consumed, $closing)
60 while ($bucket = stream_bucket_make_writeable($in)) {
61 $bucket->data = preg_replace("/\r?\n/", "\r\n", $bucket->data);
62 $consumed += $bucket->datalen;
63 stream_bucket_append($out, $bucket);
68 class UnixLineEndingFilter extends php_user_filter {
69 function filter($in, $out, &$consumed, $closing)
71 while ($bucket = stream_bucket_make_writeable($in)) {
72 $bucket->data = preg_replace("/\r?\n/", "\n", $bucket->data);
73 $consumed += $bucket->datalen;
74 stream_bucket_append($out, $bucket);
79 stream_filter_register("mtrackcanonical", 'CanonicalLineEndingFilter');
80 stream_filter_register("mtrackunix", 'UnixLineEndingFilter');
82 $watched = MTrackWatch::getWatchedItemsAndWatchers($last, 'email');
83 printf("Got %d watchers\n", count($watched));
85 /* For each watcher, compute the changes.
86 * Group changes by ticket, sending one email per ticket.
87 * Group tickets into batch updates if the only fields that changed are
88 * bulk update style (milestone, assignment etc.)
90 * For the wiki repo, group by file so that serial edits within the batch
91 * period show up as a single email.
94 foreach ($watched as $user => $objects) {
95 $udata = MTrackAuth::getUserData($user);
97 foreach ($objects as $object => $items) {
98 list($otype, $oid) = explode(':', $object, 2);
100 $fname = "notify_$otype";
101 if (function_exists($fname)) {
102 call_user_func($fname, $object, $oid, $items, $user, $udata);
104 echo "WARN: no notifier for $otype $oid\n";
106 foreach ($items as $o) {
107 if ($o instanceof MTrackSCMEvent) {
108 $t = strtotime($o->ctime);
110 $t = strtotime($o->changedate);
119 function get_change_audit($items)
124 foreach ($items as $obj) {
125 if (!($obj instanceof MTrackSCMEvent)) {
126 $all_cs[$obj->cid] = $obj;
127 if (!isset($obj->audit)) {
128 $obj->audit = array();
129 $cid_list[] = $obj->cid;
134 if (count($cid_list)) {
135 $cid_list = join(',', $cid_list);
136 foreach (MTrackDB::q("select * from change_audit where cid in ($cid_list)")
137 ->fetchAll(PDO::FETCH_OBJ) as $aud) {
140 $all_cs[$cid]->audit[] = $aud;
147 function compute_contributor($items)
149 $contributors = array();
150 foreach ($items as $obj) {
151 if (isset($obj->who)) {
152 $contributors[$obj->who]++;
153 } elseif (isset($obj->changeby)) {
154 $contributors[$obj->changeby]++;
159 foreach ($contributors as $user => $input) {
160 if ($input > $count) {
165 unset($contributors[$major]);
168 $res[] = array($major, MTrackAuth::getUserData($major));
169 foreach ($contributors as $user => $input) {
170 $res[] = array($user, MTrackAuth::getUserData($user));
176 function encode_header($string)
179 foreach (preg_split("/\s+/", $string) as $portion) {
180 if (!preg_match("/[\x80-\xff]/", $portion)) {
181 $result[] = $portion;
185 $result[] = '=?UTF-8?B?' . base64_encode($portion) . '?=';
187 return join(' ', $result);
190 function make_email($uname, $uinfo)
192 $email = $uinfo['email'];
193 $name = $uinfo['fullname'];
194 if ($name == $email) {
197 return encode_header($name) . " <$email>";
200 function _sort_mx($A, $B)
202 $diff = $A->weight - $B->weight;
203 if ($diff) return $diff;
204 return strncmp($A->host, $B->host);
207 function get_weighted_mx($domain)
209 static $cache = array();
211 if (preg_match("/^\d+\.\d+\.\d+\.\d+$/", $domain)) {
215 $mx->a = array($domain);
216 $cache[$domain] = array($mx);
217 return $cache[$domain];
220 /* ensure that we don't things as local */
221 $domain = rtrim($domain, '.') . '.';
223 if (isset($cache[$domain])) {
224 return $cache[$domain];
227 if (!getmxrr($domain, $hosts, $weight)) {
231 $mx->a = gethostbynamel($domain);
232 $cache[$domain] = array($mx);
233 return $cache[$domain];
236 foreach ($hosts as $i => $host) {
239 $mx->weight = $weight[$i];
240 $mx->a = gethostbynamel("$host.");
243 usort($res, '_sort_mx');
245 $cache[$domain] = $res;
246 return $cache[$domain];
249 $smtp_cache = array();
251 function smtp_cmd($fp, $cmd, $exp = 250)
268 } while ($line[3] == '-');
271 foreach ($smtp_cache as $k => $v) {
273 unset($smtp_cache[$k]);
276 throw new Exception("got $code, expected $exp");
281 function smtp_connect($rcpt)
285 list($local, $domain) = explode('@', $rcpt);
287 if (isset($smtp_cache[$domain])) {
288 return $smtp_cache[$domain];
291 $smarthost = MTrackConfig::get('notify', 'smtp_relay');
293 $domain = $smarthost;
295 $mxs = get_weighted_mx($domain);
297 foreach ($mxs as $ent) {
298 foreach ($ent->a as $addr) {
299 $fp = stream_socket_client("$addr:25", $e, $s);
302 $banner = fgets($fp);
306 } while ($banner[3] == '-');
307 $code = (int)$banner;
312 smtp_cmd($fp, sprintf("HELO %s\r\n", php_uname('n')));
313 $smtp_cache[$domain] = $fp;
321 function send_mail($rcpt, $payload)
326 $reciplist = escapeshellarg($rcpt);
328 echo "would mail: $reciplist\n\n";
329 echo stream_get_contents($payload);
333 echo "Not sending any mail\n";
337 echo "Skip sending mail - no rcpt";
340 if (function_exists('getmxrr') &&
341 MTrackConfig::get('notify', 'use_smtp')) {
342 /* let's do some SMTP */
345 $fp = smtp_connect($rcpt);
347 $local = MTrackConfig::get('notify', 'smtp_from');
349 $local = php_uname('n');
351 smtp_cmd($fp, "MAIL FROM:<$local>\r\n");
352 smtp_cmd($fp, "RCPT TO:<$rcpt>\r\n");
353 smtp_cmd($fp, "DATA\r\n", 354);
355 while ($line = fgets($payload)) {
356 // Session transparency
357 if ($line[0] == '.') {
360 // Canonical line endings
361 $line = preg_replace("/\r?\n/", "\r\n", $line);
367 smtp_cmd($fp, ".\r\n");
370 echo "Using sendmail\n";
371 $pipe = popen("/usr/sbin/sendmail $reciplist", 'w');
372 stream_filter_append($pipe, 'mtrackunix', STREAM_FILTER_WRITE);
373 stream_copy_to_stream($payload, $pipe);
378 function notify_repo($object, $tid, $items, $user, $udata)
385 $code_by_repo = array();
386 foreach ($items as $obj) {
387 if (!($obj instanceof MTrackSCMEvent)) {
388 if (!isset($obj->ent)) {
394 $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
395 $revlist[] = $obj->rev;
396 if ($repo === null) {
400 if (!count($code_by_repo)) {
404 $reponame = $repo->getBrowseRootName();
406 $from = compute_contributor($items);
409 'MIME-Version' => '1.0',
410 'Content-Type' => 'text/plain; charset="UTF-8"',
411 'Content-Transfer-Encoding' => 'quoted-printable',
414 $headers['To'] = make_email($user, $udata);
415 $headers['From'] = make_email($from[0][0], $from[0][1]);
416 if (count($from) > 1) {
419 foreach ($from as $email) {
420 $rep[] = make_email($email[0], $email[1]);
422 $headers['Reply-To'] = join(', ', $rep);
424 $mid = sha1($reponame . join(':', $revlist)) . '@' . php_uname('n');
425 $headers['Message-ID'] = "<$mid>";
427 /* find related project(s) */
429 foreach ($items as $obj) {
430 if (!isset($obj->_related)) continue;
431 foreach ($obj->_related as $rel) {
432 if ($rel[0] == 'project') {
433 $p = get_project($rel[1]);
434 $projects[$p->projid] = $p->shortname;
438 if (count($projects)) {
440 $subj = "[" . join($projects) . "] ";
441 $headers['X-mtrack-project-list'] = join(' ', $projects);
442 foreach ($projects as $pname) {
443 $headers["X-mtrack-project-$pname"] = $pname;
444 $headers['X-mtrack-project'][] = $pname;
449 $subj = sprintf("%scommit %s ", $subj, $reponame);
450 foreach ($revlist as $rev) {
451 if (strlen($subj) > 72) break;
454 $headers['Subject'] = $subj;
459 stream_filter_append($plain, 'mtrackcanonical', STREAM_FILTER_WRITE);
460 foreach ($headers as $name => $value) {
461 if (is_array($value)) {
462 foreach ($value as $v) {
463 fprintf($plain, "%s: %s\n", $name, encode_header($v));
466 fprintf($plain, "%s: %s\n", $name, encode_header($value));
470 fprintf($plain, "\n");
472 add_qp_filter($plain);
474 generate_repo_changes($plain, $code_by_repo, true);
478 send_mail($udata['email'], $plain);
481 function add_qp_filter($stream)
483 stream_filter_append($stream, 'convert.quoted-printable-encode',
484 STREAM_FILTER_WRITE, array(
486 'line-break-chars' => "\r\n",
491 function notify_ticket($object, $tid, $items, $user, $udata)
494 $T = MTrackIssue::loadById($tid);
495 if (!is_object($T)) {
496 echo "Failed to load ticket by id: $tid\n";
500 $from = compute_contributor($items);
501 $audit = get_change_audit($items);
505 $field_changers = array();
506 $old_values = array();
509 foreach ($audit as $CS) {
510 if ($CS->cid == $T->created) {
511 // We use this to set a Message-ID header
514 foreach ($CS->audit as $aud) {
515 // fieldname is of the form: "ticket:id:fieldname"
516 $field = substr($aud->fieldname, strlen($object)+1);
518 if ($field == '@comment') {
519 $comments[] = "Comment by " .
520 $CS->who . ":\n" . $aud->value;
521 } elseif ($field != 'spent') {
522 $field_changers[$field] = $CS->who;
523 if (!isset($old_values[$field])) {
524 $old_values[$field] = $aud->oldvalue;
532 'MIME-Version' => '1.0',
533 'Content-Type' => 'text/plain; charset="UTF-8"',
534 'Content-Transfer-Encoding' => 'quoted-printable',
537 $headers['To'] = make_email($user, $udata);
538 $headers['From'] = make_email($from[0][0], $from[0][1]);
539 if (count($from) > 1) {
542 foreach ($from as $email) {
543 $rep[] = make_email($email[0], $email[1]);
545 $headers['Reply-To'] = join(', ', $rep);
547 $mid = $T->tid . '@' . php_uname('n');
549 $headers['Message-ID'] = "<$mid>";
551 $headers['Message-ID'] = "<$T->updated.$mid>";
552 $headers['In-Reply-To'] = "<$mid>";
553 $headers['References'] = "<$mid>";
555 /* find related project(s) */
557 foreach ($items as $obj) {
558 if (!isset($obj->_related)) continue;
559 foreach ($obj->_related as $rel) {
560 if ($rel[0] == 'project') {
561 $p = get_project($rel[1]);
562 $projects[$p->projid] = $p->shortname;
566 if (count($projects)) {
568 $subj = "[" . join($projects, ' ') . "] ";
570 $headers['X-mtrack-project-list'] = join(' ', $projects);
571 foreach ($projects as $pname) {
572 $headers["X-mtrack-project-$pname"] = $pname;
573 $headers['X-mtrack-project'][] = $pname;
579 $headers['Subject'] = sprintf("%s#%s %s (%s %s)",
580 $subj, $T->nsident, $T->summary, $T->status, $T->classification);
585 stream_filter_append($plain, 'mtrackcanonical', STREAM_FILTER_WRITE);
586 foreach ($headers as $name => $value) {
587 if (is_array($value)) {
588 foreach ($value as $v) {
589 fprintf($plain, "%s: %s\n", $name, encode_header($v));
592 fprintf($plain, "%s: %s\n", $name, encode_header($value));
595 fprintf($plain, "\n");
597 add_qp_filter($plain);
599 fprintf($plain, "%s/Ticket.php/%s\n\n", $ABSWEB, $T->nsident);
601 fprintf($plain, "#%s: %s (%s %s)\n",
602 $T->nsident, $T->summary, $T->status, $T->classification);
604 $owner = strlen($T->owner) ? $T->owner : 'nobody';
605 fprintf($plain, "Responsible: %s (%s / %s)\n",
606 $owner, $T->priority, $T->severity);
608 fprintf($plain, "Milestone: %s\n", join(', ', $T->getMilestones()));
609 fprintf($plain, "Component: %s\n", join(', ', $T->getComponents()));
611 fprintf($plain, "\n");
613 // Display changed fields grouped by the person that last changed them
614 $who_changed = array();
615 foreach ($field_changers as $field => $who) {
616 $who_changed[$who][] = $field;
618 foreach ($who_changed as $who => $fieldlist) {
619 fprintf($plain, "Changes by %s:\n", $who);
621 foreach ($fieldlist as $field) {
622 $old = $old_values[$field];
624 if (!strlen($old) && $field == 'nsident') {
632 foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
633 if (!strlen($id)) continue;
634 $c = get_component($id);
635 $old[$id] = $c->name;
637 $value = $T->getComponents();
638 $field = 'Component';
642 foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
643 if (!strlen($id)) continue;
644 $m = get_milestone($id);
645 $old[$id] = $m->name;
648 $value = $T->getMilestones();
649 $field = 'Milestone';
654 $value = $T->getKeywords();
658 $value = $T->{$field};
660 if (is_array($value)) {
661 $value = join(', ', $value);
663 if (is_array($old)) {
664 $old = join(', ', $old);
666 if ($value == $old) {
669 if ($field == 'description') {
670 $lines = count(explode("\n", $old));
671 $diff = mtrack_diff_strings($old, $value);
674 foreach (explode("\n", $diff) as $line) {
675 if ($line[0] == '-') {
677 } else if ($line[0] == '+') {
681 if (abs($diff_add - $diff_rem) > $lines / 2) {
682 fprintf($plain, "Description changed to:\n%s\n\n", $value);
684 fprintf($plain, "Description changed:\n%s\n\n", $diff);
687 fprintf($plain, "%s %s -> %s\n", $field, $old, $value);
691 foreach ($comments as $comment) {
692 fprintf($plain, "\n%s\n", $comment);
695 $code_by_repo = array();
696 foreach ($items as $obj) {
697 if (!($obj instanceof MTrackSCMEvent)) {
698 if (!isset($obj->ent)) {
703 $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
705 generate_repo_changes($plain, $code_by_repo);
707 fprintf($plain, "\n%s/Ticket.php/%s\n\n", $ABSWEB, $T->nsident);
710 send_mail($udata['email'], $plain);
713 function generate_repo_changes($plain, $code_by_repo, $changelog = false)
718 foreach ($code_by_repo as $reponame => $ents) {
719 fprintf($plain, "\nChanges in %s:\n", $reponame);
721 /* Gather up affected files */
723 foreach ($ents as $obj) {
724 foreach ($obj->files as $file) {
725 if (!isset($files[$file->name])) {
726 $files[$file->name]= array();
728 if (!isset($files[$file->name][$file->status])) {
729 $files[$file->name][$file->status]=1;
732 $files[$file->name][$file->status]++;
737 fprintf($plain, " Affected files:\n");
738 foreach ($files as $filename => $status) {
740 fprintf($plain, " ** More than 20 files were changed\n");
743 fprintf($plain, "%5s %s\n", join('', array_keys($status)), $filename);
746 $too_big = $n > 20 ? true : false;
747 foreach ($ents as $obj) {
748 fprintf($plain, "\n[%s] by %s\n", $obj->rev, $obj->changeby);
749 fprintf($plain, "%schangeset.php/%s/%s\n\n",
750 $ABSWEB, $reponame, $obj->rev);
753 fprintf($plain, "%s\n\n", $obj->changelog);
758 $email_size = get_stream_size($plain);
759 if ($email_size >= $MAX_DIFF) {
763 foreach ($obj->files as $file) {
764 $diff = get_diff($obj, $file);
766 $email_size = get_stream_size($plain);
767 $diff_size = get_stream_size($diff);
769 if ($email_size + $diff_size < $MAX_DIFF) {
770 stream_copy_to_stream($diff, $plain);
771 fwrite($plain, "\n");
779 fprintf($plain, " * Diff exceeds configured limit\n");
784 function get_stream_size($stm)
790 function get_diff(MTrackSCMEvent $ent, $file)
792 $fname = $file->name;
793 if (isset($ent->__diff[$fname])) {
794 $diff = $ent->__diff[$fname];
799 $diff = $ent->repo->diff($file, $ent->rev);
800 stream_copy_to_stream($diff, $tmp);
801 $ent->__diff[$fname] = $tmp;
806 function get_project($pid) {
807 static $projects = array();
808 if (isset($projects[$pid])) {
809 return $projects[$pid];
811 $projects[$pid] = MTrackProject::loadById($pid);
812 return $projects[$pid];
815 function get_component($cid) {
816 static $comps = array();
817 if (isset($comps[$cid])) {
820 $comps[$cid] = MTrackComponent::loadById($cid);
824 function get_milestone($mid) {
825 static $comps = array();
826 if (isset($comps[$mid])) {
829 $comps[$mid] = MTrack_Milestone::loadById($mid);
834 // Now we are done, update the last run time
835 $db->beginTransaction();
836 $db->exec("delete from last_notification");
837 $t = MTrackDB::unixtime($LATEST);
838 echo "updating last run to $t $LATEST\n";
839 $db->exec("insert into last_notification (last_run) values ('$t')");
843 mtrack_cache_maintain();