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>";
204 function notify_repo($object, $tid, $items, $user, $udata)
211 $code_by_repo = array();
212 foreach ($items as $obj) {
213 if (!($obj instanceof MTrackSCMEvent)) {
214 if (!isset($obj->ent)) {
220 $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
221 $revlist[] = $obj->rev;
222 if ($repo === null) {
226 if (!count($code_by_repo)) {
230 $reponame = $repo->getBrowseRootName();
232 $from = compute_contributor($items);
235 'MIME-Version' => '1.0',
236 'Content-Type' => 'text/plain; charset="UTF-8"',
237 'Content-Transfer-Encoding' => 'quoted-printable',
240 $headers['To'] = make_email($user, $udata);
241 $headers['From'] = make_email($from[0][0], $from[0][1]);
242 if (count($from) > 1) {
245 foreach ($from as $email) {
246 $rep[] = make_email($email[0], $email[1]);
248 $headers['Reply-To'] = join(', ', $rep);
250 $mid = sha1($reponame . join(':', $revlist)) . '@' . php_uname('n');
251 $headers['Message-ID'] = "<$mid>";
253 /* find related project(s) */
255 foreach ($items as $obj) {
256 if (!isset($obj->_related)) continue;
257 foreach ($obj->_related as $rel) {
258 if ($rel[0] == 'project') {
259 $p = get_project($rel[1]);
260 $projects[$p->projid] = $p->shortname;
264 if (count($projects)) {
266 $subj = "[" . join($projects) . "] ";
267 $headers['X-mtrack-project-list'] = join(' ', $projects);
268 foreach ($projects as $pname) {
269 $headers["X-mtrack-project-$pname"] = $pname;
270 $headers['X-mtrack-project'][] = $pname;
275 $subj = sprintf("%scommit %s ", $subj, $reponame);
276 foreach ($revlist as $rev) {
277 if (strlen($subj) > 72) break;
280 $headers['Subject'] = $subj;
285 stream_filter_append($plain, 'mtrackcanonical', STREAM_FILTER_WRITE);
286 foreach ($headers as $name => $value) {
287 if (is_array($value)) {
288 foreach ($value as $v) {
289 fprintf($plain, "%s: %s\n", $name, encode_header($v));
292 fprintf($plain, "%s: %s\n", $name, encode_header($value));
296 fprintf($plain, "\n");
298 add_qp_filter($plain);
300 generate_repo_changes($plain, $code_by_repo, true);
304 send_mail($udata['email'], $plain);
307 function add_qp_filter($stream)
309 stream_filter_append($stream, 'convert.quoted-printable-encode',
310 STREAM_FILTER_WRITE, array(
312 'line-break-chars' => "\r\n",
317 function notify_ticket($object, $tid, $items, $user, $udata)
320 $T = MTrackIssue::loadById($tid);
321 if (!is_object($T)) {
322 echo "Failed to load ticket by id: $tid\n";
326 $from = compute_contributor($items);
327 $audit = get_change_audit($items);
331 $field_changers = array();
332 $old_values = array();
335 foreach ($audit as $CS) {
336 if ($CS->cid == $T->created) {
337 // We use this to set a Message-ID header
340 foreach ($CS->audit as $aud) {
341 // fieldname is of the form: "ticket:id:fieldname"
342 $field = substr($aud->fieldname, strlen($object)+1);
344 if ($field == '@comment') {
345 $comments[] = "Comment by " .
346 $CS->who . ":\n" . $aud->value;
347 } elseif ($field != 'spent') {
348 $field_changers[$field] = $CS->who;
349 if (!isset($old_values[$field])) {
350 $old_values[$field] = $aud->oldvalue;
358 'MIME-Version' => '1.0',
359 'Content-Type' => 'text/plain; charset="UTF-8"',
360 'Content-Transfer-Encoding' => 'quoted-printable',
363 $headers['To'] = make_email($user, $udata);
364 $headers['From'] = make_email($from[0][0], $from[0][1]);
365 if (count($from) > 1) {
368 foreach ($from as $email) {
369 $rep[] = make_email($email[0], $email[1]);
371 $headers['Reply-To'] = join(', ', $rep);
373 $mid = $T->tid . '@' . php_uname('n');
375 $headers['Message-ID'] = "<$mid>";
377 $headers['Message-ID'] = "<$T->updated.$mid>";
378 $headers['In-Reply-To'] = "<$mid>";
379 $headers['References'] = "<$mid>";
381 /* find related project(s) */
383 foreach ($items as $obj) {
384 if (!isset($obj->_related)) continue;
385 foreach ($obj->_related as $rel) {
386 if ($rel[0] == 'project') {
387 $p = get_project($rel[1]);
388 $projects[$p->projid] = $p->shortname;
392 if (count($projects)) {
394 $subj = "[" . join($projects, ' ') . "] ";
396 $headers['X-mtrack-project-list'] = join(' ', $projects);
397 foreach ($projects as $pname) {
398 $headers["X-mtrack-project-$pname"] = $pname;
399 $headers['X-mtrack-project'][] = $pname;
405 $headers['Subject'] = sprintf("%s#%s %s (%s %s)",
406 $subj, $T->nsident, $T->summary, $T->status, $T->classification);
411 stream_filter_append($plain, 'mtrackcanonical', STREAM_FILTER_WRITE);
412 foreach ($headers as $name => $value) {
413 if (is_array($value)) {
414 foreach ($value as $v) {
415 fprintf($plain, "%s: %s\n", $name, encode_header($v));
418 fprintf($plain, "%s: %s\n", $name, encode_header($value));
421 fprintf($plain, "\n");
423 add_qp_filter($plain);
425 fprintf($plain, "%s/Ticket.php/%s\n\n", $ABSWEB, $T->nsident);
427 fprintf($plain, "#%s: %s (%s %s)\n",
428 $T->nsident, $T->summary, $T->status, $T->classification);
430 $owner = strlen($T->owner) ? $T->owner : 'nobody';
431 fprintf($plain, "Responsible: %s (%s / %s)\n",
432 $owner, $T->priority, $T->severity);
434 fprintf($plain, "Milestone: %s\n", join(', ', $T->getMilestones()));
435 fprintf($plain, "Component: %s\n", join(', ', $T->getComponents()));
437 fprintf($plain, "\n");
439 // Display changed fields grouped by the person that last changed them
440 $who_changed = array();
441 foreach ($field_changers as $field => $who) {
442 $who_changed[$who][] = $field;
444 foreach ($who_changed as $who => $fieldlist) {
445 fprintf($plain, "Changes by %s:\n", $who);
447 foreach ($fieldlist as $field) {
448 $old = $old_values[$field];
450 if (!strlen($old) && $field == 'nsident') {
458 foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
459 if (!strlen($id)) continue;
460 $c = get_component($id);
461 $old[$id] = $c->name;
463 $value = $T->getComponents();
464 $field = 'Component';
468 foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
469 if (!strlen($id)) continue;
470 $m = get_milestone($id);
471 $old[$id] = $m->name;
474 $value = $T->getMilestones();
475 $field = 'Milestone';
480 $value = $T->getKeywords();
484 $value = $T->{$field};
486 if (is_array($value)) {
487 $value = join(', ', $value);
489 if (is_array($old)) {
490 $old = join(', ', $old);
492 if ($value == $old) {
495 if ($field == 'description') {
496 $lines = count(explode("\n", $old));
497 $diff = mtrack_diff_strings($old, $value);
500 foreach (explode("\n", $diff) as $line) {
501 if ($line[0] == '-') {
503 } else if ($line[0] == '+') {
507 if (abs($diff_add - $diff_rem) > $lines / 2) {
508 fprintf($plain, "Description changed to:\n%s\n\n", $value);
510 fprintf($plain, "Description changed:\n%s\n\n", $diff);
513 fprintf($plain, "%s %s -> %s\n", $field, $old, $value);
517 foreach ($comments as $comment) {
518 fprintf($plain, "\n%s\n", $comment);
521 $code_by_repo = array();
522 foreach ($items as $obj) {
523 if (!($obj instanceof MTrackSCMEvent)) {
524 if (!isset($obj->ent)) {
529 $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
531 generate_repo_changes($plain, $code_by_repo);
533 fprintf($plain, "\n%s/Ticket.php/%s\n\n", $ABSWEB, $T->nsident);
536 send_mail($udata['email'], $plain);
539 function generate_repo_changes($plain, $code_by_repo, $changelog = false)
544 foreach ($code_by_repo as $reponame => $ents) {
545 fprintf($plain, "\nChanges in %s:\n", $reponame);
547 /* Gather up affected files */
549 foreach ($ents as $obj) {
550 foreach ($obj->files as $file) {
551 if (!isset($files[$file->name])) {
552 $files[$file->name]= array();
554 if (!isset($files[$file->name][$file->status])) {
555 $files[$file->name][$file->status]=1;
558 $files[$file->name][$file->status]++;
563 fprintf($plain, " Affected files:\n");
564 foreach ($files as $filename => $status) {
566 fprintf($plain, " ** More than 20 files were changed\n");
569 fprintf($plain, "%5s %s\n", join('', array_keys($status)), $filename);
572 $too_big = $n > 20 ? true : false;
573 foreach ($ents as $obj) {
574 fprintf($plain, "\n[%s] by %s\n", $obj->rev, $obj->changeby);
575 fprintf($plain, "%schangeset.php/%s/%s\n\n",
576 $ABSWEB, $reponame, $obj->rev);
579 fprintf($plain, "%s\n\n", $obj->changelog);
584 $email_size = get_stream_size($plain);
585 if ($email_size >= $MAX_DIFF) {
589 foreach ($obj->files as $file) {
590 $diff = get_diff($obj, $file);
592 $email_size = get_stream_size($plain);
593 $diff_size = get_stream_size($diff);
595 if ($email_size + $diff_size < $MAX_DIFF) {
596 stream_copy_to_stream($diff, $plain);
597 fwrite($plain, "\n");
605 fprintf($plain, " * Diff exceeds configured limit\n");
610 function get_stream_size($stm)
616 function get_diff(MTrackSCMEvent $ent, $file)
618 $fname = $file->name;
619 if (isset($ent->__diff[$fname])) {
620 $diff = $ent->__diff[$fname];
625 $diff = $ent->repo->diff($file, $ent->rev);
626 stream_copy_to_stream($diff, $tmp);
627 $ent->__diff[$fname] = $tmp;
632 function get_project($pid) {
633 static $projects = array();
634 if (isset($projects[$pid])) {
635 return $projects[$pid];
637 $projects[$pid] = MTrackProject::loadById($pid);
638 return $projects[$pid];
641 function get_component($cid) {
642 static $comps = array();
643 if (isset($comps[$cid])) {
646 $comps[$cid] = MTrackComponent::loadById($cid);
650 function get_milestone($mid) {
651 static $comps = array();
652 if (isset($comps[$mid])) {
655 $comps[$mid] = MTrack_Milestone::loadById($mid);
660 // Now we are done, update the last run time
661 $db->beginTransaction();
662 $db->exec("delete from last_notification");
663 $t = MTrackDB::unixtime($LATEST);
664 echo "updating last run to $t $LATEST\n";
665 $db->exec("insert into last_notification (last_run) values ('$t')");
669 mtrack_cache_maintain();