php8
[web.mtrack] / MTrackWeb / Cron / send-notifications.php
1 <?php # vim:ts=2:sw=2:et:
2 /* For licensing and copyright terms, see the file named LICENSE */
3
4 die("make a class of me");
5
6 if (function_exists('date_default_timezone_set')) {
7   date_default_timezone_set('UTC');
8 }
9
10 include dirname(__FILE__) . '/../inc/common.php';
11
12 // Force this to be the configure value or something that will guide it
13 // to be set
14 $ABSWEB = MTrackConfig::get('core', 'weburl');
15 if (!strlen($ABSWEB)) {
16   $ABSWEB = "(configure [core] weburl in config.ini)";
17 }
18 $vardir = MTrackConfig::get('core', 'vardir');
19
20 $DEBUG = strlen(getenv('DEBUG_NOTIFY')) ? true : false;
21 $NO_MAIL = strlen(getenv('DEBUG_NOMAIL')) ? true : false;
22
23 $MAX_DIFF = 200 * 1024;
24 $USE_BATCHING = false;
25
26 if (!$DEBUG) {
27   /* only allow one instance to run concurrently */
28   $lockfp = fopen($vardir . '/.notifier.lock', 'w');
29   if (!$lockfp) {
30     exit(1);
31   }
32   if (!flock($lockfp, LOCK_EX|LOCK_NB)) {
33     echo "Another instance is already running\n";
34     exit(1);
35   }
36   /* "leak" $lockfp, so that the lock is held while we continue to run */
37 }
38
39 $db = MTrackDB::get();
40
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()
44     as $row) {
45   $last = $row[0];
46 }
47 $LATEST = strtotime($last);
48 if (getenv('DEBUG_TIME')) {
49   $dtime = strtotime(getenv('DEBUG_TIME'));
50   if ($dtime > 0) {
51     $LATEST = $dtime;
52     $last = MTrackDB::unixtime($LATEST);
53     echo "Using $last as last time (specified via DEBUG_TIME var)\n";
54   }
55 }
56
57 class CanonicalLineEndingFilter extends php_user_filter {
58   function filter($in, $out, &$consumed, $closing)
59   {
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);
64     }
65     return PSFS_PASS_ON;
66   }
67 }
68 class UnixLineEndingFilter extends php_user_filter {
69   function filter($in, $out, &$consumed, $closing)
70   {
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);
75     }
76     return PSFS_PASS_ON;
77   }
78 }
79 stream_filter_register("mtrackcanonical", 'CanonicalLineEndingFilter');
80 stream_filter_register("mtrackunix", 'UnixLineEndingFilter');
81
82 $watched = MTrackWatch::getWatchedItemsAndWatchers($last, 'email');
83 printf("Got %d watchers\n", count($watched));
84
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.)
89  *
90  * For the wiki repo, group by file so that serial edits within the batch
91  * period show up as a single email.
92  */
93
94 foreach ($watched as $user => $objects) {
95   $udata = MTrackAuth::getUserData($user);
96
97   foreach ($objects as $object => $items) {
98     list($otype, $oid) = explode(':', $object, 2);
99
100     $fname = "notify_$otype";
101     if (function_exists($fname)) {
102       call_user_func($fname, $object, $oid, $items, $user, $udata);
103     } else {
104       echo "WARN: no notifier for $otype $oid\n";
105     }
106     foreach ($items as $o) {
107       if ($o instanceof MTrackSCMEvent) {
108         $t = strtotime($o->ctime);
109       } else {
110         $t = strtotime($o->changedate);
111       }
112       if ($t > $LATEST) {
113         $LATEST = $t;
114       }
115     }
116   }
117 }
118
119 function get_change_audit($items)
120 {
121   $cid_list = array();
122   $all_cs = array();
123
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;
130       }
131     }
132   }
133
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) {
138       $cid = $aud->cid;
139       unset($aud->cid);
140       $all_cs[$cid]->audit[] = $aud;
141     }
142   }
143
144   return $all_cs;
145 }
146
147 function compute_contributor($items)
148 {
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]++;
155     }
156   }
157   $count = 0;
158   $major = null;
159   foreach ($contributors as $user => $input) {
160     if ($input > $count) {
161       $major = $user;
162       $count = $input;
163     }
164   }
165   unset($contributors[$major]);
166
167   $res = array();
168   $res[] = array($major, MTrackAuth::getUserData($major));
169   foreach ($contributors as $user => $input) {
170     $res[] = array($user, MTrackAuth::getUserData($user));
171   }
172
173   return $res;
174 }
175
176 function encode_header($string)
177 {
178   $result = array();
179   foreach (preg_split("/\s+/", $string) as $portion) {
180     if (!preg_match("/[\x80-\xff]/", $portion)) {
181       $result[] = $portion;
182       continue;
183     }
184
185     $result[] = '=?UTF-8?B?' . base64_encode($portion) . '?=';
186   }
187   return join(' ', $result);
188 }
189
190 function make_email($uname, $uinfo)
191 {
192   $email = $uinfo['email'];
193   $name = $uinfo['fullname'];
194   if ($name == $email) {
195     return $email;
196   }
197   return encode_header($name) . " <$email>";
198 }
199  
200  
201  
202  
203  
204 function notify_repo($object, $tid, $items, $user, $udata)
205 {
206   global $ABSWEB;
207
208   $revlist = array();
209   $repo = null;
210
211   $code_by_repo = array();
212   foreach ($items as $obj) {
213     if (!($obj instanceof MTrackSCMEvent)) {
214       if (!isset($obj->ent)) {
215         continue;
216       }
217       $obj = $obj->ent;
218     }
219
220     $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
221     $revlist[] = $obj->rev;
222     if ($repo === null) {
223       $repo = $obj->repo;
224     }
225   }
226   if (!count($code_by_repo)) {
227     return;
228   }
229
230   $reponame = $repo->getBrowseRootName();
231
232   $from = compute_contributor($items);
233
234   $headers = array(
235     'MIME-Version' => '1.0',
236     'Content-Type' => 'text/plain; charset="UTF-8"',
237     'Content-Transfer-Encoding' => 'quoted-printable',
238   );
239
240   $headers['To'] = make_email($user, $udata);
241   $headers['From'] = make_email($from[0][0], $from[0][1]);
242   if (count($from) > 1) {
243     $rep = array();
244     array_shift($from);
245     foreach ($from as $email) {
246       $rep[] = make_email($email[0], $email[1]);
247     }
248     $headers['Reply-To'] = join(', ', $rep);
249   }
250   $mid = sha1($reponame . join(':', $revlist)) . '@' . php_uname('n');
251   $headers['Message-ID'] = "<$mid>";
252
253   /* find related project(s) */
254   $projects = array();
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;
261       }
262     }
263   }
264   if (count($projects)) {
265     natsort($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;
271     }
272   } else {
273     $subj = '';
274   }
275   $subj = sprintf("%scommit %s ", $subj, $reponame);
276   foreach ($revlist as $rev) {
277     if (strlen($subj) > 72) break;
278     $subj .= " [$rev]";
279   }
280   $headers['Subject'] = $subj;
281
282   global $ABSWEB;
283
284   $plain = tmpfile();
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));
290       }
291     } else {
292       fprintf($plain, "%s: %s\n", $name, encode_header($value));
293     }
294   }
295
296   fprintf($plain, "\n");
297   fflush($plain);
298   add_qp_filter($plain);
299
300   generate_repo_changes($plain, $code_by_repo, true);
301
302   rewind($plain);
303
304   send_mail($udata['email'], $plain);
305 }
306
307 function add_qp_filter($stream)
308 {
309   stream_filter_append($stream, 'convert.quoted-printable-encode',
310     STREAM_FILTER_WRITE, array(
311       'line-length' => 74,
312       'line-break-chars' => "\r\n",
313     )
314   );
315 }
316
317 function notify_ticket($object, $tid, $items, $user, $udata)
318 {
319   global $MAX_DIFF;
320   $T = MTrackIssue::loadById($tid);
321   if (!is_object($T)) {
322     echo "Failed to load ticket by id: $tid\n";
323     return;
324   }
325
326   $from = compute_contributor($items);
327   $audit = get_change_audit($items);
328
329   $comments = array();
330   $fields = array();
331   $field_changers = array();
332   $old_values = array();
333   $is_initial = false;
334
335   foreach ($audit as $CS) {
336     if ($CS->cid == $T->created) {
337       // We use this to set a Message-ID header
338       $is_initial = true;
339     }
340     foreach ($CS->audit as $aud) {
341       // fieldname is of the form: "ticket:id:fieldname"
342       $field = substr($aud->fieldname, strlen($object)+1);
343
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;
351         }
352       }
353     }
354   }
355
356
357   $headers = array(
358     'MIME-Version' => '1.0',
359     'Content-Type' => 'text/plain; charset="UTF-8"',
360     'Content-Transfer-Encoding' => 'quoted-printable',
361   );
362
363   $headers['To'] = make_email($user, $udata);
364   $headers['From'] = make_email($from[0][0], $from[0][1]);
365   if (count($from) > 1) {
366     $rep = array();
367     array_shift($from);
368     foreach ($from as $email) {
369       $rep[] = make_email($email[0], $email[1]);
370     }
371     $headers['Reply-To'] = join(', ', $rep);
372   }
373   $mid = $T->tid . '@' . php_uname('n');
374   if ($is_initial) {
375     $headers['Message-ID'] = "<$mid>";
376   } else {
377     $headers['Message-ID'] = "<$T->updated.$mid>";
378     $headers['In-Reply-To'] = "<$mid>";
379     $headers['References'] = "<$mid>";
380   }
381   /* find related project(s) */
382   $projects = array();
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;
389       }
390     }
391   }
392   if (count($projects)) {
393     natsort($projects);
394     $subj = "[" . join($projects, ' ') . "] ";
395
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;
400     }
401   } else {
402     $subj = '';
403   }
404
405   $headers['Subject'] = sprintf("%s#%s %s (%s %s)",
406     $subj, $T->nsident, $T->summary, $T->status, $T->classification);
407
408   global $ABSWEB;
409
410   $plain = tmpfile();
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));
416       }
417     } else {
418       fprintf($plain, "%s: %s\n", $name, encode_header($value));
419     }
420   }
421   fprintf($plain, "\n");
422   fflush($plain);
423   add_qp_filter($plain);
424
425   fprintf($plain, "%s/Ticket.php/%s\n\n", $ABSWEB, $T->nsident);
426
427   fprintf($plain, "#%s: %s (%s %s)\n",
428     $T->nsident, $T->summary, $T->status, $T->classification);
429
430   $owner = strlen($T->owner) ? $T->owner : 'nobody';
431   fprintf($plain, "Responsible: %s (%s / %s)\n",
432     $owner, $T->priority, $T->severity);
433
434   fprintf($plain, "Milestone: %s\n", join(', ', $T->getMilestones()));
435   fprintf($plain, "Component: %s\n", join(', ', $T->getComponents()));
436
437   fprintf($plain, "\n");
438
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;
443   }
444   foreach ($who_changed as $who => $fieldlist) {
445     fprintf($plain, "Changes by %s:\n", $who);
446
447     foreach ($fieldlist as $field) {
448       $old = $old_values[$field];
449
450       if (!strlen($old) && $field == 'nsident') {
451         continue;
452       }
453
454       $value = null;
455       switch ($field) {
456         case '@components':
457           $old = array();
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;
462           }
463           $value = $T->getComponents();
464           $field = 'Component';
465           break;
466         case '@milestones':
467           $old = array();
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;
472           }
473           $value = array();
474           $value = $T->getMilestones();
475           $field = 'Milestone';
476           break;
477         case '@keywords':
478           $old = array();
479           $field = 'Keywords';
480           $value = $T->getKeywords();
481           break;
482         default:
483           $old = null;
484           $value = $T->{$field};
485       }
486       if (is_array($value)) {
487         $value = join(', ', $value);
488       }
489       if (is_array($old)) {
490         $old = join(', ', $old);
491       }
492       if ($value == $old) {
493         continue;
494       }
495       if ($field == 'description') {
496         $lines = count(explode("\n", $old));
497         $diff = mtrack_diff_strings($old, $value);
498         $diff_add = 0;
499         $diff_rem = 0;
500         foreach (explode("\n", $diff) as $line) {
501           if ($line[0] == '-') {
502             $diff_rem++;
503           } else if ($line[0] == '+') {
504             $diff_add++;
505           }
506         }
507         if (abs($diff_add - $diff_rem) > $lines / 2) {
508           fprintf($plain, "Description changed to:\n%s\n\n", $value);
509         } else {
510           fprintf($plain, "Description changed:\n%s\n\n", $diff);
511         }
512       } else {
513         fprintf($plain, "%s %s -> %s\n", $field, $old, $value);
514       }
515     }
516   }
517   foreach ($comments as $comment) {
518     fprintf($plain, "\n%s\n", $comment);
519   }
520
521   $code_by_repo = array();
522   foreach ($items as $obj) {
523     if (!($obj instanceof MTrackSCMEvent)) {
524       if (!isset($obj->ent)) {
525         continue;
526       }
527       $obj = $obj->ent;
528     }
529     $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
530   }
531   generate_repo_changes($plain, $code_by_repo);
532
533   fprintf($plain, "\n%s/Ticket.php/%s\n\n", $ABSWEB, $T->nsident);
534   rewind($plain);
535
536   send_mail($udata['email'], $plain);
537 }
538
539 function generate_repo_changes($plain, $code_by_repo, $changelog = false)
540 {
541   global $MAX_DIFF;
542   global $ABSWEB;
543
544   foreach ($code_by_repo as $reponame => $ents) {
545     fprintf($plain, "\nChanges in %s:\n", $reponame);
546
547     /* Gather up affected files */
548     $files = array();
549     foreach ($ents as $obj) {
550       foreach ($obj->files as $file) {
551         if (!isset($files[$file->name])) {
552             $files[$file->name]= array();
553         }
554         if (!isset($files[$file->name][$file->status])) {
555             $files[$file->name][$file->status]=1;
556             continue;
557         }
558         $files[$file->name][$file->status]++;
559       }
560     }
561     ksort($files);
562     $n = 0;
563     fprintf($plain, "  Affected files:\n");
564     foreach ($files as $filename => $status) {
565       if ($n++ > 20) {
566         fprintf($plain, "  ** More than 20 files were changed\n");
567         break;
568       }
569       fprintf($plain, "%5s %s\n", join('', array_keys($status)), $filename);
570     }
571
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);
577
578       if ($changelog) {
579         fprintf($plain, "%s\n\n", $obj->changelog);
580       }
581       if ($too_big) {
582            continue;
583       }
584       $email_size = get_stream_size($plain);
585       if ($email_size >= $MAX_DIFF) {
586         $too_big = true;
587         continue;
588       }
589       foreach ($obj->files as $file) {
590         $diff = get_diff($obj, $file);
591
592         $email_size = get_stream_size($plain);
593         $diff_size = get_stream_size($diff);
594
595         if ($email_size + $diff_size < $MAX_DIFF) {
596           stream_copy_to_stream($diff, $plain);
597           fwrite($plain, "\n");
598         } else {
599           $too_big = true;
600         }
601       }
602
603     }
604     if ($too_big) {
605       fprintf($plain, "  * Diff exceeds configured limit\n");
606     }
607   }
608 }
609
610 function get_stream_size($stm)
611 {
612   $st = fstat($stm);
613   return $st['size'];
614 }
615
616 function get_diff(MTrackSCMEvent $ent, $file)
617 {
618   $fname = $file->name;
619   if (isset($ent->__diff[$fname])) {
620     $diff = $ent->__diff[$fname];
621     rewind($diff);
622     return $diff;
623   }
624   $tmp = tmpfile();
625   $diff = $ent->repo->diff($file, $ent->rev);
626   stream_copy_to_stream($diff, $tmp);
627   $ent->__diff[$fname] = $tmp;
628   rewind($tmp);
629   return $tmp;
630 }
631
632 function get_project($pid) {
633   static $projects = array();
634   if (isset($projects[$pid])) {
635     return $projects[$pid];
636   }
637   $projects[$pid] = MTrackProject::loadById($pid);
638   return $projects[$pid];
639 }
640
641 function get_component($cid) {
642   static $comps = array();
643   if (isset($comps[$cid])) {
644     return $comps[$cid];
645   }
646   $comps[$cid] = MTrackComponent::loadById($cid);
647   return $comps[$cid];
648 }
649
650 function get_milestone($mid) {
651   static $comps = array();
652   if (isset($comps[$mid])) {
653     return $comps[$mid];
654   }
655   $comps[$mid] = MTrack_Milestone::loadById($mid);
656   return $comps[$mid];
657 }
658
659 if (!$DEBUG) {
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')");
666   $db->commit();
667 }
668
669 mtrack_cache_maintain();
670