1 <?php # vim:ts=2:sw=2:et:
2 /* For licensing and copyright terms, see the file named LICENSE */
4 if (function_exists('date_default_timezone_set')) {
5 date_default_timezone_set('UTC');
8 include dirname(__FILE__) . '/../inc/common.php';
10 // Force this to be the configure value or something that will guide it
12 $ABSWEB = MTrackConfig::get('core', 'weburl');
13 if (!strlen($ABSWEB)) {
14 $ABSWEB = "(configure [core] weburl in config.ini)";
16 $vardir = MTrackConfig::get('core', 'vardir');
18 $DEBUG = strlen(getenv('DEBUG_NOTIFY')) ? true : false;
19 $NO_MAIL = strlen(getenv('DEBUG_NOMAIL')) ? true : false;
21 $MAX_DIFF = 200 * 1024;
22 $USE_BATCHING = false;
25 /* only allow one instance to run concurrently */
26 $lockfp = fopen($vardir . '/.notifier.lock', 'w');
30 if (!flock($lockfp, LOCK_EX|LOCK_NB)) {
31 echo "Another instance is already running\n";
34 /* "leak" $lockfp, so that the lock is held while we continue to run */
37 $db = MTrackDB::get();
39 // default to the last 10 minutes, but prefer the last recorded run time
40 $last = MTrackDB::unixtime(time() - 600);
41 foreach (MTrackDB::q('select last_run from last_notification')->fetchAll()
45 $LATEST = strtotime($last);
46 if (getenv('DEBUG_TIME')) {
47 $dtime = strtotime(getenv('DEBUG_TIME'));
50 $last = MTrackDB::unixtime($LATEST);
51 echo "Using $last as last time (specified via DEBUG_TIME var)\n";
55 class CanonicalLineEndingFilter extends php_user_filter {
56 function filter($in, $out, &$consumed, $closing)
58 while ($bucket = stream_bucket_make_writeable($in)) {
59 $bucket->data = preg_replace("/\r?\n/", "\r\n", $bucket->data);
60 $consumed += $bucket->datalen;
61 stream_bucket_append($out, $bucket);
66 class UnixLineEndingFilter extends php_user_filter {
67 function filter($in, $out, &$consumed, $closing)
69 while ($bucket = stream_bucket_make_writeable($in)) {
70 $bucket->data = preg_replace("/\r?\n/", "\n", $bucket->data);
71 $consumed += $bucket->datalen;
72 stream_bucket_append($out, $bucket);
77 stream_filter_register("mtrackcanonical", 'CanonicalLineEndingFilter');
78 stream_filter_register("mtrackunix", 'UnixLineEndingFilter');
80 $watched = MTrackWatch::getWatchedItemsAndWatchers($last, 'email');
81 printf("Got %d watchers\n", count($watched));
83 /* For each watcher, compute the changes.
84 * Group changes by ticket, sending one email per ticket.
85 * Group tickets into batch updates if the only fields that changed are
86 * bulk update style (milestone, assignment etc.)
88 * For the wiki repo, group by file so that serial edits within the batch
89 * period show up as a single email.
92 foreach ($watched as $user => $objects) {
93 $udata = MTrackAuth::getUserData($user);
95 foreach ($objects as $object => $items) {
96 list($otype, $oid) = explode(':', $object, 2);
98 $fname = "notify_$otype";
99 if (function_exists($fname)) {
100 call_user_func($fname, $object, $oid, $items, $user, $udata);
102 echo "WARN: no notifier for $otype $oid\n";
104 foreach ($items as $o) {
105 if ($o instanceof MTrackSCMEvent) {
106 $t = strtotime($o->ctime);
108 $t = strtotime($o->changedate);
117 function get_change_audit($items)
122 foreach ($items as $obj) {
123 if (!($obj instanceof MTrackSCMEvent)) {
124 $all_cs[$obj->cid] = $obj;
125 if (!isset($obj->audit)) {
126 $obj->audit = array();
127 $cid_list[] = $obj->cid;
132 if (count($cid_list)) {
133 $cid_list = join(',', $cid_list);
134 foreach (MTrackDB::q("select * from change_audit where cid in ($cid_list)")
135 ->fetchAll(PDO::FETCH_OBJ) as $aud) {
138 $all_cs[$cid]->audit[] = $aud;
145 function compute_contributor($items)
147 $contributors = array();
148 foreach ($items as $obj) {
149 if (isset($obj->who)) {
150 $contributors[$obj->who]++;
151 } elseif (isset($obj->changeby)) {
152 $contributors[$obj->changeby]++;
157 foreach ($contributors as $user => $input) {
158 if ($input > $count) {
163 unset($contributors[$major]);
166 $res[] = array($major, MTrackAuth::getUserData($major));
167 foreach ($contributors as $user => $input) {
168 $res[] = array($user, MTrackAuth::getUserData($user));
174 function encode_header($string)
177 foreach (preg_split("/\s+/", $string) as $portion) {
178 if (!preg_match("/[\x80-\xff]/", $portion)) {
179 $result[] = $portion;
183 $result[] = '=?UTF-8?B?' . base64_encode($portion) . '?=';
185 return join(' ', $result);
188 function make_email($uname, $uinfo)
190 $email = $uinfo['email'];
191 $name = $uinfo['fullname'];
192 if ($name == $email) {
195 return encode_header($name) . " <$email>";
198 function _sort_mx($A, $B)
200 $diff = $A->weight - $B->weight;
201 if ($diff) return $diff;
202 return strncmp($A->host, $B->host);
205 function get_weighted_mx($domain)
207 static $cache = array();
209 if (preg_match("/^\d+\.\d+\.\d+\.\d+$/", $domain)) {
213 $mx->a = array($domain);
214 $cache[$domain] = array($mx);
215 return $cache[$domain];
218 /* ensure that we don't things as local */
219 $domain = rtrim($domain, '.') . '.';
221 if (isset($cache[$domain])) {
222 return $cache[$domain];
225 if (!getmxrr($domain, $hosts, $weight)) {
229 $mx->a = gethostbynamel($domain);
230 $cache[$domain] = array($mx);
231 return $cache[$domain];
234 foreach ($hosts as $i => $host) {
237 $mx->weight = $weight[$i];
238 $mx->a = gethostbynamel("$host.");
241 usort($res, '_sort_mx');
243 $cache[$domain] = $res;
244 return $cache[$domain];
247 $smtp_cache = array();
249 function smtp_cmd($fp, $cmd, $exp = 250)
266 } while ($line[3] == '-');
269 foreach ($smtp_cache as $k => $v) {
271 unset($smtp_cache[$k]);
274 throw new Exception("got $code, expected $exp");
279 function smtp_connect($rcpt)
283 list($local, $domain) = explode('@', $rcpt);
285 if (isset($smtp_cache[$domain])) {
286 return $smtp_cache[$domain];
289 $smarthost = MTrackConfig::get('notify', 'smtp_relay');
291 $domain = $smarthost;
293 $mxs = get_weighted_mx($domain);
295 foreach ($mxs as $ent) {
296 foreach ($ent->a as $addr) {
297 $fp = stream_socket_client("$addr:25", $e, $s);
300 $banner = fgets($fp);
304 } while ($banner[3] == '-');
305 $code = (int)$banner;
310 smtp_cmd($fp, sprintf("EHLO %s\r\n", php_uname('n')));
311 $smtp_cache[$domain] = $fp;
319 function send_mail($rcpt, $payload)
324 $reciplist = escapeshellarg($rcpt);
326 echo "would mail: $reciplist\n\n";
327 echo stream_get_contents($payload);
331 echo "Not sending any mail\n";
334 if (function_exists('getmxrr') &&
335 MTrackConfig::get('notify', 'use_smtp')) {
336 /* let's do some SMTP */
339 $fp = smtp_connect($rcpt);
341 $local = MTrackConfig::get('notify', 'smtp_from');
343 $local = php_uname('n');
345 smtp_cmd($fp, "MAIL FROM:<$local>\r\n");
346 smtp_cmd($fp, "RCPT TO:<$rcpt>\r\n");
347 smtp_cmd($fp, "DATA\r\n", 354);
349 while ($line = fgets($payload)) {
350 // Session transparency
351 if ($line[0] == '.') {
354 // Canonical line endings
355 $line = preg_replace("/\r?\n/", "\r\n", $line);
361 smtp_cmd($fp, ".\r\n");
364 echo "Using sendmail\n";
365 $pipe = popen("/usr/sbin/sendmail $reciplist", 'w');
366 stream_filter_append($pipe, 'mtrackunix', STREAM_FILTER_WRITE);
367 stream_copy_to_stream($payload, $pipe);
372 function notify_repo($object, $tid, $items, $user, $udata)
379 $code_by_repo = array();
380 foreach ($items as $obj) {
381 if (!($obj instanceof MTrackSCMEvent)) {
382 if (!isset($obj->ent)) {
388 $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
389 $revlist[] = $obj->rev;
390 if ($repo === null) {
394 if (!count($code_by_repo)) {
398 $reponame = $repo->getBrowseRootName();
400 $from = compute_contributor($items);
403 'MIME-Version' => '1.0',
404 'Content-Type' => 'text/plain; charset="UTF-8"',
405 'Content-Transfer-Encoding' => 'quoted-printable',
408 $headers['To'] = make_email($user, $udata);
409 $headers['From'] = make_email($from[0][0], $from[0][1]);
410 if (count($from) > 1) {
413 foreach ($from as $email) {
414 $rep[] = make_email($email[0], $email[1]);
416 $headers['Reply-To'] = join(', ', $rep);
418 $mid = sha1($reponame . join(':', $revlist)) . '@' . php_uname('n');
419 $headers['Message-ID'] = "<$mid>";
421 /* find related project(s) */
423 foreach ($items as $obj) {
424 if (!isset($obj->_related)) continue;
425 foreach ($obj->_related as $rel) {
426 if ($rel[0] == 'project') {
427 $p = get_project($rel[1]);
428 $projects[$p->projid] = $p->shortname;
432 if (count($projects)) {
434 $subj = "[" . join($projects) . "] ";
435 $headers['X-mtrack-project-list'] = join(' ', $projects);
436 foreach ($projects as $pname) {
437 $headers["X-mtrack-project-$pname"] = $pname;
438 $headers['X-mtrack-project'][] = $pname;
443 $subj = sprintf("%scommit %s ", $subj, $reponame);
444 foreach ($revlist as $rev) {
445 if (strlen($subj) > 72) break;
448 $headers['Subject'] = $subj;
453 stream_filter_append($plain, 'mtrackcanonical', STREAM_FILTER_WRITE);
454 foreach ($headers as $name => $value) {
455 if (is_array($value)) {
456 foreach ($value as $v) {
457 fprintf($plain, "%s: %s\n", $name, encode_header($v));
460 fprintf($plain, "%s: %s\n", $name, encode_header($value));
464 fprintf($plain, "\n");
466 add_qp_filter($plain);
468 generate_repo_changes($plain, $code_by_repo, true);
472 send_mail($udata['email'], $plain);
475 function add_qp_filter($stream)
477 stream_filter_append($stream, 'convert.quoted-printable-encode',
478 STREAM_FILTER_WRITE, array(
480 'line-break-chars' => "\r\n",
485 function notify_ticket($object, $tid, $items, $user, $udata)
488 $T = MTrackIssue::loadById($tid);
489 if (!is_object($T)) {
490 echo "Failed to load ticket by id: $tid\n";
494 $from = compute_contributor($items);
495 $audit = get_change_audit($items);
499 $field_changers = array();
500 $old_values = array();
503 foreach ($audit as $CS) {
504 if ($CS->cid == $T->created) {
505 // We use this to set a Message-ID header
508 foreach ($CS->audit as $aud) {
509 // fieldname is of the form: "ticket:id:fieldname"
510 $field = substr($aud->fieldname, strlen($object)+1);
512 if ($field == '@comment') {
513 $comments[] = "Comment by " .
514 $CS->who . ":\n" . $aud->value;
515 } elseif ($field != 'spent') {
516 $field_changers[$field] = $CS->who;
517 if (!isset($old_values[$field])) {
518 $old_values[$field] = $aud->oldvalue;
526 'MIME-Version' => '1.0',
527 'Content-Type' => 'text/plain; charset="UTF-8"',
528 'Content-Transfer-Encoding' => 'quoted-printable',
531 $headers['To'] = make_email($user, $udata);
532 $headers['From'] = make_email($from[0][0], $from[0][1]);
533 if (count($from) > 1) {
536 foreach ($from as $email) {
537 $rep[] = make_email($email[0], $email[1]);
539 $headers['Reply-To'] = join(', ', $rep);
541 $mid = $T->tid . '@' . php_uname('n');
543 $headers['Message-ID'] = "<$mid>";
545 $headers['Message-ID'] = "<$T->updated.$mid>";
546 $headers['In-Reply-To'] = "<$mid>";
547 $headers['References'] = "<$mid>";
549 /* find related project(s) */
551 foreach ($items as $obj) {
552 if (!isset($obj->_related)) continue;
553 foreach ($obj->_related as $rel) {
554 if ($rel[0] == 'project') {
555 $p = get_project($rel[1]);
556 $projects[$p->projid] = $p->shortname;
560 if (count($projects)) {
562 $subj = "[" . join($projects, ' ') . "] ";
564 $headers['X-mtrack-project-list'] = join(' ', $projects);
565 foreach ($projects as $pname) {
566 $headers["X-mtrack-project-$pname"] = $pname;
567 $headers['X-mtrack-project'][] = $pname;
573 $headers['Subject'] = sprintf("%s#%s %s (%s %s)",
574 $subj, $T->nsident, $T->summary, $T->status, $T->classification);
579 stream_filter_append($plain, 'mtrackcanonical', STREAM_FILTER_WRITE);
580 foreach ($headers as $name => $value) {
581 if (is_array($value)) {
582 foreach ($value as $v) {
583 fprintf($plain, "%s: %s\n", $name, encode_header($v));
586 fprintf($plain, "%s: %s\n", $name, encode_header($value));
589 fprintf($plain, "\n");
591 add_qp_filter($plain);
593 fprintf($plain, "%sticket.php/%s\n\n", $ABSWEB, $T->nsident);
595 fprintf($plain, "#%s: %s (%s %s)\n",
596 $T->nsident, $T->summary, $T->status, $T->classification);
598 $owner = strlen($T->owner) ? $T->owner : 'nobody';
599 fprintf($plain, "Responsible: %s (%s / %s)\n",
600 $owner, $T->priority, $T->severity);
602 fprintf($plain, "Milestone: %s\n", join(', ', $T->getMilestones()));
603 fprintf($plain, "Component: %s\n", join(', ', $T->getComponents()));
605 fprintf($plain, "\n");
607 // Display changed fields grouped by the person that last changed them
608 $who_changed = array();
609 foreach ($field_changers as $field => $who) {
610 $who_changed[$who][] = $field;
612 foreach ($who_changed as $who => $fieldlist) {
613 fprintf($plain, "Changes by %s:\n", $who);
615 foreach ($fieldlist as $field) {
616 $old = $old_values[$field];
618 if (!strlen($old) && $field == 'nsident') {
626 foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
627 if (!strlen($id)) continue;
628 $c = get_component($id);
629 $old[$id] = $c->name;
631 $value = $T->getComponents();
632 $field = 'Component';
636 foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
637 if (!strlen($id)) continue;
638 $m = get_milestone($id);
639 $old[$id] = $m->name;
642 $value = $T->getMilestones();
643 $field = 'Milestone';
648 $value = $T->getKeywords();
652 $value = $T->{$field};
654 if (is_array($value)) {
655 $value = join(', ', $value);
657 if (is_array($old)) {
658 $old = join(', ', $old);
660 if ($value == $old) {
663 if ($field == 'description') {
664 $lines = count(explode("\n", $old));
665 $diff = mtrack_diff_strings($old, $value);
668 foreach (explode("\n", $diff) as $line) {
669 if ($line[0] == '-') {
671 } else if ($line[0] == '+') {
675 if (abs($diff_add - $diff_rem) > $lines / 2) {
676 fprintf($plain, "Description changed to:\n%s\n\n", $value);
678 fprintf($plain, "Description changed:\n%s\n\n", $diff);
681 fprintf($plain, "%s %s -> %s\n", $field, $old, $value);
685 foreach ($comments as $comment) {
686 fprintf($plain, "\n%s\n", $comment);
689 $code_by_repo = array();
690 foreach ($items as $obj) {
691 if (!($obj instanceof MTrackSCMEvent)) {
692 if (!isset($obj->ent)) {
697 $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
699 generate_repo_changes($plain, $code_by_repo);
701 fprintf($plain, "\n%sticket.php/%s\n\n", $ABSWEB, $T->nsident);
704 send_mail($udata['email'], $plain);
707 function generate_repo_changes($plain, $code_by_repo, $changelog = false)
712 foreach ($code_by_repo as $reponame => $ents) {
713 fprintf($plain, "\nChanges in %s:\n", $reponame);
715 /* Gather up affected files */
717 foreach ($ents as $obj) {
718 foreach ($obj->files as $file) {
719 $files[$file->name][$file->status]++;
724 fprintf($plain, " Affected files:\n");
725 foreach ($files as $filename => $status) {
727 fprintf($plain, " ** More than 20 files were changed\n");
730 fprintf($plain, "%5s %s\n", join('', array_keys($status)), $filename);
734 foreach ($ents as $obj) {
735 fprintf($plain, "\n[%s] by %s\n", $obj->rev, $obj->changeby);
736 fprintf($plain, "%schangeset.php/%s/%s\n\n",
737 $ABSWEB, $reponame, $obj->rev);
740 fprintf($plain, "%s\n\n", $obj->changelog);
743 $email_size = get_stream_size($plain);
744 if ($email_size >= $MAX_DIFF) {
748 foreach ($obj->files as $file) {
749 $diff = get_diff($obj, $file);
751 $email_size = get_stream_size($plain);
752 $diff_size = get_stream_size($diff);
754 if ($email_size + $diff_size < $MAX_DIFF) {
755 stream_copy_to_stream($diff, $plain);
756 fwrite($plain, "\n");
764 fprintf($plain, " * Diff exceeds configured limit\n");
769 function get_stream_size($stm)
775 function get_diff(MTrackSCMEvent $ent, $file)
777 $fname = $file->name;
778 if (isset($ent->__diff[$fname])) {
779 $diff = $ent->__diff[$fname];
784 $diff = $ent->repo->diff($file, $ent->rev);
785 stream_copy_to_stream($diff, $tmp);
786 $ent->__diff[$fname] = $tmp;
791 function get_project($pid) {
792 static $projects = array();
793 if (isset($projects[$pid])) {
794 return $projects[$pid];
796 $projects[$pid] = MTrackProject::loadById($pid);
797 return $projects[$pid];
800 function get_component($cid) {
801 static $comps = array();
802 if (isset($comps[$cid])) {
805 $comps[$cid] = MTrackComponent::loadById($cid);
809 function get_milestone($mid) {
810 static $comps = array();
811 if (isset($comps[$mid])) {
814 $comps[$mid] = MTrackMilestone::loadById($mid);
819 // Now we are done, update the last run time
820 $db->beginTransaction();
821 $db->exec("delete from last_notification");
822 $t = MTrackDB::unixtime($LATEST);
823 echo "updating last run to $t $LATEST\n";
824 $db->exec("insert into last_notification (last_run) values ('$t')");
828 mtrack_cache_maintain();