import
[web.mtrack] / bin / send-notifications.php
1 <?php # vim:ts=2:sw=2:et:
2 /* For licensing and copyright terms, see the file named LICENSE */
3
4 if (function_exists('date_default_timezone_set')) {
5   date_default_timezone_set('UTC');
6 }
7
8 include dirname(__FILE__) . '/../inc/common.php';
9
10 // Force this to be the configure value or something that will guide it
11 // to be set
12 $ABSWEB = MTrackConfig::get('core', 'weburl');
13 if (!strlen($ABSWEB)) {
14   $ABSWEB = "(configure [core] weburl in config.ini)";
15 }
16 $vardir = MTrackConfig::get('core', 'vardir');
17
18 $DEBUG = strlen(getenv('DEBUG_NOTIFY')) ? true : false;
19 $NO_MAIL = strlen(getenv('DEBUG_NOMAIL')) ? true : false;
20
21 $MAX_DIFF = 200 * 1024;
22 $USE_BATCHING = false;
23
24 if (!$DEBUG) {
25   /* only allow one instance to run concurrently */
26   $lockfp = fopen($vardir . '/.notifier.lock', 'w');
27   if (!$lockfp) {
28     exit(1);
29   }
30   if (!flock($lockfp, LOCK_EX|LOCK_NB)) {
31     echo "Another instance is already running\n";
32     exit(1);
33   }
34   /* "leak" $lockfp, so that the lock is held while we continue to run */
35 }
36
37 $db = MTrackDB::get();
38
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()
42     as $row) {
43   $last = $row[0];
44 }
45 $LATEST = strtotime($last);
46 if (getenv('DEBUG_TIME')) {
47   $dtime = strtotime(getenv('DEBUG_TIME'));
48   if ($dtime > 0) {
49     $LATEST = $dtime;
50     $last = MTrackDB::unixtime($LATEST);
51     echo "Using $last as last time (specified via DEBUG_TIME var)\n";
52   }
53 }
54
55 class CanonicalLineEndingFilter extends php_user_filter {
56   function filter($in, $out, &$consumed, $closing)
57   {
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);
62     }
63     return PSFS_PASS_ON;
64   }
65 }
66 class UnixLineEndingFilter extends php_user_filter {
67   function filter($in, $out, &$consumed, $closing)
68   {
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);
73     }
74     return PSFS_PASS_ON;
75   }
76 }
77 stream_filter_register("mtrackcanonical", 'CanonicalLineEndingFilter');
78 stream_filter_register("mtrackunix", 'UnixLineEndingFilter');
79
80 $watched = MTrackWatch::getWatchedItemsAndWatchers($last, 'email');
81 printf("Got %d watchers\n", count($watched));
82
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.)
87  *
88  * For the wiki repo, group by file so that serial edits within the batch
89  * period show up as a single email.
90  */
91
92 foreach ($watched as $user => $objects) {
93   $udata = MTrackAuth::getUserData($user);
94
95   foreach ($objects as $object => $items) {
96     list($otype, $oid) = explode(':', $object, 2);
97
98     $fname = "notify_$otype";
99     if (function_exists($fname)) {
100       call_user_func($fname, $object, $oid, $items, $user, $udata);
101     } else {
102       echo "WARN: no notifier for $otype $oid\n";
103     }
104     foreach ($items as $o) {
105       if ($o instanceof MTrackSCMEvent) {
106         $t = strtotime($o->ctime);
107       } else {
108         $t = strtotime($o->changedate);
109       }
110       if ($t > $LATEST) {
111         $LATEST = $t;
112       }
113     }
114   }
115 }
116
117 function get_change_audit($items)
118 {
119   $cid_list = array();
120   $all_cs = array();
121
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;
128       }
129     }
130   }
131
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) {
136       $cid = $aud->cid;
137       unset($aud->cid);
138       $all_cs[$cid]->audit[] = $aud;
139     }
140   }
141
142   return $all_cs;
143 }
144
145 function compute_contributor($items)
146 {
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]++;
153     }
154   }
155   $count = 0;
156   $major = null;
157   foreach ($contributors as $user => $input) {
158     if ($input > $count) {
159       $major = $user;
160       $count = $input;
161     }
162   }
163   unset($contributors[$major]);
164
165   $res = array();
166   $res[] = array($major, MTrackAuth::getUserData($major));
167   foreach ($contributors as $user => $input) {
168     $res[] = array($user, MTrackAuth::getUserData($user));
169   }
170
171   return $res;
172 }
173
174 function encode_header($string)
175 {
176   $result = array();
177   foreach (preg_split("/\s+/", $string) as $portion) {
178     if (!preg_match("/[\x80-\xff]/", $portion)) {
179       $result[] = $portion;
180       continue;
181     }
182
183     $result[] = '=?UTF-8?B?' . base64_encode($portion) . '?=';
184   }
185   return join(' ', $result);
186 }
187
188 function make_email($uname, $uinfo)
189 {
190   $email = $uinfo['email'];
191   $name = $uinfo['fullname'];
192   if ($name == $email) {
193     return $email;
194   }
195   return encode_header($name) . " <$email>";
196 }
197
198 function _sort_mx($A, $B)
199 {
200   $diff = $A->weight - $B->weight;
201   if ($diff) return $diff;
202   return strncmp($A->host, $B->host);
203 }
204
205 function get_weighted_mx($domain)
206 {
207   static $cache = array();
208
209   if (preg_match("/^\d+\.\d+\.\d+\.\d+$/", $domain)) {
210     /* IP literal */
211     $mx = new stdclass;
212     $mx->host = $domain;
213     $mx->a = array($domain);
214     $cache[$domain] = array($mx);
215     return $cache[$domain];
216   }
217
218   /* ensure that we don't things as local */
219   $domain = rtrim($domain, '.') . '.';
220
221   if (isset($cache[$domain])) {
222     return $cache[$domain];
223   }
224
225   if (!getmxrr($domain, $hosts, $weight)) {
226     // Fallback to A
227     $mx = new stdclass;
228     $mx->host = $domain;
229     $mx->a = gethostbynamel($domain);
230     $cache[$domain] = array($mx);
231     return $cache[$domain];
232   }
233   $res = array();
234   foreach ($hosts as $i => $host) {
235     $mx = new stdclass;
236     $mx->host = $host;
237     $mx->weight = $weight[$i];
238     $mx->a = gethostbynamel("$host.");
239     $res[] = $mx;
240   }
241   usort($res, '_sort_mx');
242
243   $cache[$domain] = $res;
244   return $cache[$domain];
245 }
246
247 $smtp_cache = array();
248
249 function smtp_cmd($fp, $cmd, $exp = 250)
250 {
251   global $smtp_cache;
252   global $DEBUG;
253
254   $res = array();
255
256   if ($DEBUG) {
257     echo "> $cmd";
258   }
259   fwrite($fp, $cmd);
260   do {
261     $line = fgets($fp);
262     $res[] = $res;
263     if ($DEBUG) {
264       echo "< $line";
265     }
266   } while ($line[3] == '-');
267   $code = (int)$line;
268   if ($code != $exp) {
269     foreach ($smtp_cache as $k => $v) {
270       if ($v === $fp) {
271         unset($smtp_cache[$k]);
272       }
273     }
274     throw new Exception("got $code, expected $exp");
275   }
276   return $res;
277 }
278
279 function smtp_connect($rcpt)
280 {
281   global $DEBUG;
282
283   list($local, $domain) = explode('@', $rcpt);
284   global $smtp_cache;
285   if (isset($smtp_cache[$domain])) {
286     return $smtp_cache[$domain];
287   }
288
289   $smarthost = MTrackConfig::get('notify', 'smtp_relay');
290   if ($smarthost) {
291     $domain = $smarthost;
292   }
293   $mxs = get_weighted_mx($domain);
294
295   foreach ($mxs as $ent) {
296     foreach ($ent->a as $addr) {
297       $fp = stream_socket_client("$addr:25", $e, $s);
298       if ($fp) {
299         do {
300           $banner = fgets($fp);
301           if ($DEBUG) {
302             echo "< $banner";
303           }
304         } while ($banner[3] == '-');
305         $code = (int)$banner;
306         if ($code != 220) {
307           fclose($fp);
308           continue;
309         }
310         smtp_cmd($fp, sprintf("EHLO %s\r\n", php_uname('n')));
311         $smtp_cache[$domain] = $fp;
312         return $fp;
313       }
314     }
315   }
316   return false;
317 }
318
319 function send_mail($rcpt, $payload)
320 {
321   global $DEBUG;
322   global $NO_MAIL;
323
324   $reciplist = escapeshellarg($rcpt);
325   if ($DEBUG) {
326     echo "would mail: $reciplist\n\n";
327     echo stream_get_contents($payload);
328     rewind($payload);
329   }
330   if ($NO_MAIL) {
331     echo "Not sending any mail\n";
332     return;
333   }
334   if (function_exists('getmxrr') &&
335       MTrackConfig::get('notify', 'use_smtp')) {
336     /* let's do some SMTP */
337     echo "Using SMTP\n";
338
339     $fp = smtp_connect($rcpt);
340     if ($fp) {
341       $local = MTrackConfig::get('notify', 'smtp_from');
342       if (!$local) {
343         $local = php_uname('n');
344       }
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);
348
349       while ($line = fgets($payload)) {
350         // Session transparency
351         if ($line[0] == '.') {
352           $line = '.' . $line;
353         }
354         // Canonical line endings
355         $line = preg_replace("/\r?\n/", "\r\n", $line);
356         if ($DEBUG) {
357           echo "> $line";
358         }
359         fwrite($fp, $line);
360       }
361       smtp_cmd($fp, ".\r\n");
362     }
363   } else {
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);
368     pclose($pipe);
369   }
370 }
371
372 function notify_repo($object, $tid, $items, $user, $udata)
373 {
374   global $ABSWEB;
375
376   $revlist = array();
377   $repo = null;
378
379   $code_by_repo = array();
380   foreach ($items as $obj) {
381     if (!($obj instanceof MTrackSCMEvent)) {
382       if (!isset($obj->ent)) {
383         continue;
384       }
385       $obj = $obj->ent;
386     }
387
388     $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
389     $revlist[] = $obj->rev;
390     if ($repo === null) {
391       $repo = $obj->repo;
392     }
393   }
394   if (!count($code_by_repo)) {
395     return;
396   }
397
398   $reponame = $repo->getBrowseRootName();
399
400   $from = compute_contributor($items);
401
402   $headers = array(
403     'MIME-Version' => '1.0',
404     'Content-Type' => 'text/plain; charset="UTF-8"',
405     'Content-Transfer-Encoding' => 'quoted-printable',
406   );
407
408   $headers['To'] = make_email($user, $udata);
409   $headers['From'] = make_email($from[0][0], $from[0][1]);
410   if (count($from) > 1) {
411     $rep = array();
412     array_shift($from);
413     foreach ($from as $email) {
414       $rep[] = make_email($email[0], $email[1]);
415     }
416     $headers['Reply-To'] = join(', ', $rep);
417   }
418   $mid = sha1($reponame . join(':', $revlist)) . '@' . php_uname('n');
419   $headers['Message-ID'] = "<$mid>";
420
421   /* find related project(s) */
422   $projects = array();
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;
429       }
430     }
431   }
432   if (count($projects)) {
433     natsort($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;
439     }
440   } else {
441     $subj = '';
442   }
443   $subj = sprintf("%scommit %s ", $subj, $reponame);
444   foreach ($revlist as $rev) {
445     if (strlen($subj) > 72) break;
446     $subj .= " [$rev]";
447   }
448   $headers['Subject'] = $subj;
449
450   global $ABSWEB;
451
452   $plain = tmpfile();
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));
458       }
459     } else {
460       fprintf($plain, "%s: %s\n", $name, encode_header($value));
461     }
462   }
463
464   fprintf($plain, "\n");
465   fflush($plain);
466   add_qp_filter($plain);
467
468   generate_repo_changes($plain, $code_by_repo, true);
469
470   rewind($plain);
471
472   send_mail($udata['email'], $plain);
473 }
474
475 function add_qp_filter($stream)
476 {
477   stream_filter_append($stream, 'convert.quoted-printable-encode',
478     STREAM_FILTER_WRITE, array(
479       'line-length' => 74,
480       'line-break-chars' => "\r\n",
481     )
482   );
483 }
484
485 function notify_ticket($object, $tid, $items, $user, $udata)
486 {
487   global $MAX_DIFF;
488   $T = MTrackIssue::loadById($tid);
489   if (!is_object($T)) {
490     echo "Failed to load ticket by id: $tid\n";
491     return;
492   }
493
494   $from = compute_contributor($items);
495   $audit = get_change_audit($items);
496
497   $comments = array();
498   $fields = array();
499   $field_changers = array();
500   $old_values = array();
501   $is_initial = false;
502
503   foreach ($audit as $CS) {
504     if ($CS->cid == $T->created) {
505       // We use this to set a Message-ID header
506       $is_initial = true;
507     }
508     foreach ($CS->audit as $aud) {
509       // fieldname is of the form: "ticket:id:fieldname"
510       $field = substr($aud->fieldname, strlen($object)+1);
511
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;
519         }
520       }
521     }
522   }
523
524
525   $headers = array(
526     'MIME-Version' => '1.0',
527     'Content-Type' => 'text/plain; charset="UTF-8"',
528     'Content-Transfer-Encoding' => 'quoted-printable',
529   );
530
531   $headers['To'] = make_email($user, $udata);
532   $headers['From'] = make_email($from[0][0], $from[0][1]);
533   if (count($from) > 1) {
534     $rep = array();
535     array_shift($from);
536     foreach ($from as $email) {
537       $rep[] = make_email($email[0], $email[1]);
538     }
539     $headers['Reply-To'] = join(', ', $rep);
540   }
541   $mid = $T->tid . '@' . php_uname('n');
542   if ($is_initial) {
543     $headers['Message-ID'] = "<$mid>";
544   } else {
545     $headers['Message-ID'] = "<$T->updated.$mid>";
546     $headers['In-Reply-To'] = "<$mid>";
547     $headers['References'] = "<$mid>";
548   }
549   /* find related project(s) */
550   $projects = array();
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;
557       }
558     }
559   }
560   if (count($projects)) {
561     natsort($projects);
562     $subj = "[" . join($projects, ' ') . "] ";
563
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;
568     }
569   } else {
570     $subj = '';
571   }
572
573   $headers['Subject'] = sprintf("%s#%s %s (%s %s)",
574     $subj, $T->nsident, $T->summary, $T->status, $T->classification);
575
576   global $ABSWEB;
577
578   $plain = tmpfile();
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));
584       }
585     } else {
586       fprintf($plain, "%s: %s\n", $name, encode_header($value));
587     }
588   }
589   fprintf($plain, "\n");
590   fflush($plain);
591   add_qp_filter($plain);
592
593   fprintf($plain, "%sticket.php/%s\n\n", $ABSWEB, $T->nsident);
594
595   fprintf($plain, "#%s: %s (%s %s)\n",
596     $T->nsident, $T->summary, $T->status, $T->classification);
597
598   $owner = strlen($T->owner) ? $T->owner : 'nobody';
599   fprintf($plain, "Responsible: %s (%s / %s)\n",
600     $owner, $T->priority, $T->severity);
601
602   fprintf($plain, "Milestone: %s\n", join(', ', $T->getMilestones()));
603   fprintf($plain, "Component: %s\n", join(', ', $T->getComponents()));
604
605   fprintf($plain, "\n");
606
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;
611   }
612   foreach ($who_changed as $who => $fieldlist) {
613     fprintf($plain, "Changes by %s:\n", $who);
614
615     foreach ($fieldlist as $field) {
616       $old = $old_values[$field];
617
618       if (!strlen($old) && $field == 'nsident') {
619         continue;
620       }
621
622       $value = null;
623       switch ($field) {
624         case '@components':
625           $old = array();
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;
630           }
631           $value = $T->getComponents();
632           $field = 'Component';
633           break;
634         case '@milestones':
635           $old = array();
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;
640           }
641           $value = array();
642           $value = $T->getMilestones();
643           $field = 'Milestone';
644           break;
645         case '@keywords':
646           $old = array();
647           $field = 'Keywords';
648           $value = $T->getKeywords();
649           break;
650         default:
651           $old = null;
652           $value = $T->{$field};
653       }
654       if (is_array($value)) {
655         $value = join(', ', $value);
656       }
657       if (is_array($old)) {
658         $old = join(', ', $old);
659       }
660       if ($value == $old) {
661         continue;
662       }
663       if ($field == 'description') {
664         $lines = count(explode("\n", $old));
665         $diff = mtrack_diff_strings($old, $value);
666         $diff_add = 0;
667         $diff_rem = 0;
668         foreach (explode("\n", $diff) as $line) {
669           if ($line[0] == '-') {
670             $diff_rem++;
671           } else if ($line[0] == '+') {
672             $diff_add++;
673           }
674         }
675         if (abs($diff_add - $diff_rem) > $lines / 2) {
676           fprintf($plain, "Description changed to:\n%s\n\n", $value);
677         } else {
678           fprintf($plain, "Description changed:\n%s\n\n", $diff);
679         }
680       } else {
681         fprintf($plain, "%s %s -> %s\n", $field, $old, $value);
682       }
683     }
684   }
685   foreach ($comments as $comment) {
686     fprintf($plain, "\n%s\n", $comment);
687   }
688
689   $code_by_repo = array();
690   foreach ($items as $obj) {
691     if (!($obj instanceof MTrackSCMEvent)) {
692       if (!isset($obj->ent)) {
693         continue;
694       }
695       $obj = $obj->ent;
696     }
697     $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
698   }
699   generate_repo_changes($plain, $code_by_repo);
700
701   fprintf($plain, "\n%sticket.php/%s\n\n", $ABSWEB, $T->nsident);
702   rewind($plain);
703
704   send_mail($udata['email'], $plain);
705 }
706
707 function generate_repo_changes($plain, $code_by_repo, $changelog = false)
708 {
709   global $MAX_DIFF;
710   global $ABSWEB;
711
712   foreach ($code_by_repo as $reponame => $ents) {
713     fprintf($plain, "\nChanges in %s:\n", $reponame);
714
715     /* Gather up affected files */
716     $files = array();
717     foreach ($ents as $obj) {
718       foreach ($obj->files as $file) {
719         $files[$file->name][$file->status]++;
720       }
721     }
722     ksort($files);
723     $n = 0;
724     fprintf($plain, "  Affected files:\n");
725     foreach ($files as $filename => $status) {
726       if ($n++ > 20) {
727         fprintf($plain, "  ** More than 20 files were changed\n");
728         break;
729       }
730       fprintf($plain, "%5s %s\n", join('', array_keys($status)), $filename);
731     }
732
733     $too_big = false;
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);
738
739       if ($changelog) {
740         fprintf($plain, "%s\n\n", $obj->changelog);
741       }
742
743       $email_size = get_stream_size($plain);
744       if ($email_size >= $MAX_DIFF) {
745         $too_big = true;
746         continue;
747       }
748       foreach ($obj->files as $file) {
749         $diff = get_diff($obj, $file);
750
751         $email_size = get_stream_size($plain);
752         $diff_size = get_stream_size($diff);
753
754         if ($email_size + $diff_size < $MAX_DIFF) {
755           stream_copy_to_stream($diff, $plain);
756           fwrite($plain, "\n");
757         } else {
758           $too_big = true;
759         }
760       }
761
762     }
763     if ($too_big) {
764       fprintf($plain, "  * Diff exceeds configured limit\n");
765     }
766   }
767 }
768
769 function get_stream_size($stm)
770 {
771   $st = fstat($stm);
772   return $st['size'];
773 }
774
775 function get_diff(MTrackSCMEvent $ent, $file)
776 {
777   $fname = $file->name;
778   if (isset($ent->__diff[$fname])) {
779     $diff = $ent->__diff[$fname];
780     rewind($diff);
781     return $diff;
782   }
783   $tmp = tmpfile();
784   $diff = $ent->repo->diff($file, $ent->rev);
785   stream_copy_to_stream($diff, $tmp);
786   $ent->__diff[$fname] = $tmp;
787   rewind($tmp);
788   return $tmp;
789 }
790
791 function get_project($pid) {
792   static $projects = array();
793   if (isset($projects[$pid])) {
794     return $projects[$pid];
795   }
796   $projects[$pid] = MTrackProject::loadById($pid);
797   return $projects[$pid];
798 }
799
800 function get_component($cid) {
801   static $comps = array();
802   if (isset($comps[$cid])) {
803     return $comps[$cid];
804   }
805   $comps[$cid] = MTrackComponent::loadById($cid);
806   return $comps[$cid];
807 }
808
809 function get_milestone($mid) {
810   static $comps = array();
811   if (isset($comps[$mid])) {
812     return $comps[$mid];
813   }
814   $comps[$mid] = MTrackMilestone::loadById($mid);
815   return $comps[$mid];
816 }
817
818 if (!$DEBUG) {
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')");
825   $db->commit();
826 }
827
828 mtrack_cache_maintain();
829