final move of files
[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 function _sort_mx($A, $B)
201 {
202   $diff = $A->weight - $B->weight;
203   if ($diff) return $diff;
204   return strncmp($A->host, $B->host);
205 }
206
207 function get_weighted_mx($domain)
208 {
209   static $cache = array();
210
211   if (preg_match("/^\d+\.\d+\.\d+\.\d+$/", $domain)) {
212     /* IP literal */
213     $mx = new stdclass;
214     $mx->host = $domain;
215     $mx->a = array($domain);
216     $cache[$domain] = array($mx);
217     return $cache[$domain];
218   }
219
220   /* ensure that we don't things as local */
221   $domain = rtrim($domain, '.') . '.';
222
223   if (isset($cache[$domain])) {
224     return $cache[$domain];
225   }
226
227   if (!getmxrr($domain, $hosts, $weight)) {
228     // Fallback to A
229     $mx = new stdclass;
230     $mx->host = $domain;
231     $mx->a = gethostbynamel($domain);
232     $cache[$domain] = array($mx);
233     return $cache[$domain];
234   }
235   $res = array();
236   foreach ($hosts as $i => $host) {
237     $mx = new stdclass;
238     $mx->host = $host;
239     $mx->weight = $weight[$i];
240     $mx->a = gethostbynamel("$host.");
241     $res[] = $mx;
242   }
243   usort($res, '_sort_mx');
244
245   $cache[$domain] = $res;
246   return $cache[$domain];
247 }
248
249 $smtp_cache = array();
250
251 function smtp_cmd($fp, $cmd, $exp = 250)
252 {
253   global $smtp_cache;
254   global $DEBUG;
255
256   $res = array();
257
258   if ($DEBUG) {
259     echo "> $cmd";
260   }
261   fwrite($fp, $cmd);
262   do {
263     $line = fgets($fp);
264     $res[] = $res;
265     if ($DEBUG) {
266       echo "< $line";
267     }
268   } while ($line[3] == '-');
269   $code = (int)$line;
270   if ($code != $exp) {
271     foreach ($smtp_cache as $k => $v) {
272       if ($v === $fp) {
273         unset($smtp_cache[$k]);
274       }
275     }
276     throw new Exception("got $code, expected $exp");
277   }
278   return $res;
279 }
280
281 function smtp_connect($rcpt)
282 {
283   global $DEBUG;
284
285   list($local, $domain) = explode('@', $rcpt);
286   global $smtp_cache;
287   if (isset($smtp_cache[$domain])) {
288     return $smtp_cache[$domain];
289   }
290
291   $smarthost = MTrackConfig::get('notify', 'smtp_relay');
292   if ($smarthost) {
293     $domain = $smarthost;
294   }
295   $mxs = get_weighted_mx($domain);
296
297   foreach ($mxs as $ent) {
298     foreach ($ent->a as $addr) {
299       $fp = stream_socket_client("$addr:25", $e, $s);
300       if ($fp) {
301         do {
302           $banner = fgets($fp);
303           if ($DEBUG) {
304             echo "< $banner";
305           }
306         } while ($banner[3] == '-');
307         $code = (int)$banner;
308         if ($code != 220) {
309           fclose($fp);
310           continue;
311         }
312         smtp_cmd($fp, sprintf("HELO %s\r\n", php_uname('n')));
313         $smtp_cache[$domain] = $fp;
314         return $fp;
315       }
316     }
317   }
318   return false;
319 }
320
321 function send_mail($rcpt, $payload)
322 {
323   global $DEBUG;
324   global $NO_MAIL;
325
326   $reciplist = escapeshellarg($rcpt);
327   if ($DEBUG) {
328     echo "would mail: $reciplist\n\n";
329     echo stream_get_contents($payload);
330     rewind($payload);
331   }
332   if ($NO_MAIL) {
333     echo "Not sending any mail\n";
334     return;
335   }
336   if (empty($rcpt)) {
337     echo "Skip sending mail - no rcpt";
338         return;
339 }
340   if (function_exists('getmxrr') &&
341       MTrackConfig::get('notify', 'use_smtp')) {
342     /* let's do some SMTP */
343     echo "Using SMTP\n";
344
345     $fp = smtp_connect($rcpt);
346     if ($fp) {
347       $local = MTrackConfig::get('notify', 'smtp_from');
348       if (!$local) {
349         $local = php_uname('n');
350       }
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);
354
355       while ($line = fgets($payload)) {
356         // Session transparency
357         if ($line[0] == '.') {
358           $line = '.' . $line;
359         }
360         // Canonical line endings
361         $line = preg_replace("/\r?\n/", "\r\n", $line);
362         if ($DEBUG) {
363           echo "> $line";
364         }
365         fwrite($fp, $line);
366       }
367       smtp_cmd($fp, ".\r\n");
368     }
369   } else {
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);
374     pclose($pipe);
375   }
376 }
377
378 function notify_repo($object, $tid, $items, $user, $udata)
379 {
380   global $ABSWEB;
381
382   $revlist = array();
383   $repo = null;
384
385   $code_by_repo = array();
386   foreach ($items as $obj) {
387     if (!($obj instanceof MTrackSCMEvent)) {
388       if (!isset($obj->ent)) {
389         continue;
390       }
391       $obj = $obj->ent;
392     }
393
394     $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
395     $revlist[] = $obj->rev;
396     if ($repo === null) {
397       $repo = $obj->repo;
398     }
399   }
400   if (!count($code_by_repo)) {
401     return;
402   }
403
404   $reponame = $repo->getBrowseRootName();
405
406   $from = compute_contributor($items);
407
408   $headers = array(
409     'MIME-Version' => '1.0',
410     'Content-Type' => 'text/plain; charset="UTF-8"',
411     'Content-Transfer-Encoding' => 'quoted-printable',
412   );
413
414   $headers['To'] = make_email($user, $udata);
415   $headers['From'] = make_email($from[0][0], $from[0][1]);
416   if (count($from) > 1) {
417     $rep = array();
418     array_shift($from);
419     foreach ($from as $email) {
420       $rep[] = make_email($email[0], $email[1]);
421     }
422     $headers['Reply-To'] = join(', ', $rep);
423   }
424   $mid = sha1($reponame . join(':', $revlist)) . '@' . php_uname('n');
425   $headers['Message-ID'] = "<$mid>";
426
427   /* find related project(s) */
428   $projects = array();
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;
435       }
436     }
437   }
438   if (count($projects)) {
439     natsort($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;
445     }
446   } else {
447     $subj = '';
448   }
449   $subj = sprintf("%scommit %s ", $subj, $reponame);
450   foreach ($revlist as $rev) {
451     if (strlen($subj) > 72) break;
452     $subj .= " [$rev]";
453   }
454   $headers['Subject'] = $subj;
455
456   global $ABSWEB;
457
458   $plain = tmpfile();
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));
464       }
465     } else {
466       fprintf($plain, "%s: %s\n", $name, encode_header($value));
467     }
468   }
469
470   fprintf($plain, "\n");
471   fflush($plain);
472   add_qp_filter($plain);
473
474   generate_repo_changes($plain, $code_by_repo, true);
475
476   rewind($plain);
477
478   send_mail($udata['email'], $plain);
479 }
480
481 function add_qp_filter($stream)
482 {
483   stream_filter_append($stream, 'convert.quoted-printable-encode',
484     STREAM_FILTER_WRITE, array(
485       'line-length' => 74,
486       'line-break-chars' => "\r\n",
487     )
488   );
489 }
490
491 function notify_ticket($object, $tid, $items, $user, $udata)
492 {
493   global $MAX_DIFF;
494   $T = MTrackIssue::loadById($tid);
495   if (!is_object($T)) {
496     echo "Failed to load ticket by id: $tid\n";
497     return;
498   }
499
500   $from = compute_contributor($items);
501   $audit = get_change_audit($items);
502
503   $comments = array();
504   $fields = array();
505   $field_changers = array();
506   $old_values = array();
507   $is_initial = false;
508
509   foreach ($audit as $CS) {
510     if ($CS->cid == $T->created) {
511       // We use this to set a Message-ID header
512       $is_initial = true;
513     }
514     foreach ($CS->audit as $aud) {
515       // fieldname is of the form: "ticket:id:fieldname"
516       $field = substr($aud->fieldname, strlen($object)+1);
517
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;
525         }
526       }
527     }
528   }
529
530
531   $headers = array(
532     'MIME-Version' => '1.0',
533     'Content-Type' => 'text/plain; charset="UTF-8"',
534     'Content-Transfer-Encoding' => 'quoted-printable',
535   );
536
537   $headers['To'] = make_email($user, $udata);
538   $headers['From'] = make_email($from[0][0], $from[0][1]);
539   if (count($from) > 1) {
540     $rep = array();
541     array_shift($from);
542     foreach ($from as $email) {
543       $rep[] = make_email($email[0], $email[1]);
544     }
545     $headers['Reply-To'] = join(', ', $rep);
546   }
547   $mid = $T->tid . '@' . php_uname('n');
548   if ($is_initial) {
549     $headers['Message-ID'] = "<$mid>";
550   } else {
551     $headers['Message-ID'] = "<$T->updated.$mid>";
552     $headers['In-Reply-To'] = "<$mid>";
553     $headers['References'] = "<$mid>";
554   }
555   /* find related project(s) */
556   $projects = array();
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;
563       }
564     }
565   }
566   if (count($projects)) {
567     natsort($projects);
568     $subj = "[" . join($projects, ' ') . "] ";
569
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;
574     }
575   } else {
576     $subj = '';
577   }
578
579   $headers['Subject'] = sprintf("%s#%s %s (%s %s)",
580     $subj, $T->nsident, $T->summary, $T->status, $T->classification);
581
582   global $ABSWEB;
583
584   $plain = tmpfile();
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));
590       }
591     } else {
592       fprintf($plain, "%s: %s\n", $name, encode_header($value));
593     }
594   }
595   fprintf($plain, "\n");
596   fflush($plain);
597   add_qp_filter($plain);
598
599   fprintf($plain, "%s/Ticket.php/%s\n\n", $ABSWEB, $T->nsident);
600
601   fprintf($plain, "#%s: %s (%s %s)\n",
602     $T->nsident, $T->summary, $T->status, $T->classification);
603
604   $owner = strlen($T->owner) ? $T->owner : 'nobody';
605   fprintf($plain, "Responsible: %s (%s / %s)\n",
606     $owner, $T->priority, $T->severity);
607
608   fprintf($plain, "Milestone: %s\n", join(', ', $T->getMilestones()));
609   fprintf($plain, "Component: %s\n", join(', ', $T->getComponents()));
610
611   fprintf($plain, "\n");
612
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;
617   }
618   foreach ($who_changed as $who => $fieldlist) {
619     fprintf($plain, "Changes by %s:\n", $who);
620
621     foreach ($fieldlist as $field) {
622       $old = $old_values[$field];
623
624       if (!strlen($old) && $field == 'nsident') {
625         continue;
626       }
627
628       $value = null;
629       switch ($field) {
630         case '@components':
631           $old = array();
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;
636           }
637           $value = $T->getComponents();
638           $field = 'Component';
639           break;
640         case '@milestones':
641           $old = array();
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;
646           }
647           $value = array();
648           $value = $T->getMilestones();
649           $field = 'Milestone';
650           break;
651         case '@keywords':
652           $old = array();
653           $field = 'Keywords';
654           $value = $T->getKeywords();
655           break;
656         default:
657           $old = null;
658           $value = $T->{$field};
659       }
660       if (is_array($value)) {
661         $value = join(', ', $value);
662       }
663       if (is_array($old)) {
664         $old = join(', ', $old);
665       }
666       if ($value == $old) {
667         continue;
668       }
669       if ($field == 'description') {
670         $lines = count(explode("\n", $old));
671         $diff = mtrack_diff_strings($old, $value);
672         $diff_add = 0;
673         $diff_rem = 0;
674         foreach (explode("\n", $diff) as $line) {
675           if ($line[0] == '-') {
676             $diff_rem++;
677           } else if ($line[0] == '+') {
678             $diff_add++;
679           }
680         }
681         if (abs($diff_add - $diff_rem) > $lines / 2) {
682           fprintf($plain, "Description changed to:\n%s\n\n", $value);
683         } else {
684           fprintf($plain, "Description changed:\n%s\n\n", $diff);
685         }
686       } else {
687         fprintf($plain, "%s %s -> %s\n", $field, $old, $value);
688       }
689     }
690   }
691   foreach ($comments as $comment) {
692     fprintf($plain, "\n%s\n", $comment);
693   }
694
695   $code_by_repo = array();
696   foreach ($items as $obj) {
697     if (!($obj instanceof MTrackSCMEvent)) {
698       if (!isset($obj->ent)) {
699         continue;
700       }
701       $obj = $obj->ent;
702     }
703     $code_by_repo[$obj->repo->getBrowseRootName()][] = $obj;
704   }
705   generate_repo_changes($plain, $code_by_repo);
706
707   fprintf($plain, "\n%s/Ticket.php/%s\n\n", $ABSWEB, $T->nsident);
708   rewind($plain);
709
710   send_mail($udata['email'], $plain);
711 }
712
713 function generate_repo_changes($plain, $code_by_repo, $changelog = false)
714 {
715   global $MAX_DIFF;
716   global $ABSWEB;
717
718   foreach ($code_by_repo as $reponame => $ents) {
719     fprintf($plain, "\nChanges in %s:\n", $reponame);
720
721     /* Gather up affected files */
722     $files = array();
723     foreach ($ents as $obj) {
724       foreach ($obj->files as $file) {
725         if (!isset($files[$file->name])) {
726             $files[$file->name]= array();
727         }
728         if (!isset($files[$file->name][$file->status])) {
729             $files[$file->name][$file->status]=1;
730             continue;
731         }
732         $files[$file->name][$file->status]++;
733       }
734     }
735     ksort($files);
736     $n = 0;
737     fprintf($plain, "  Affected files:\n");
738     foreach ($files as $filename => $status) {
739       if ($n++ > 20) {
740         fprintf($plain, "  ** More than 20 files were changed\n");
741         break;
742       }
743       fprintf($plain, "%5s %s\n", join('', array_keys($status)), $filename);
744     }
745
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);
751
752       if ($changelog) {
753         fprintf($plain, "%s\n\n", $obj->changelog);
754       }
755       if ($too_big) {
756            continue;
757       }
758       $email_size = get_stream_size($plain);
759       if ($email_size >= $MAX_DIFF) {
760         $too_big = true;
761         continue;
762       }
763       foreach ($obj->files as $file) {
764         $diff = get_diff($obj, $file);
765
766         $email_size = get_stream_size($plain);
767         $diff_size = get_stream_size($diff);
768
769         if ($email_size + $diff_size < $MAX_DIFF) {
770           stream_copy_to_stream($diff, $plain);
771           fwrite($plain, "\n");
772         } else {
773           $too_big = true;
774         }
775       }
776
777     }
778     if ($too_big) {
779       fprintf($plain, "  * Diff exceeds configured limit\n");
780     }
781   }
782 }
783
784 function get_stream_size($stm)
785 {
786   $st = fstat($stm);
787   return $st['size'];
788 }
789
790 function get_diff(MTrackSCMEvent $ent, $file)
791 {
792   $fname = $file->name;
793   if (isset($ent->__diff[$fname])) {
794     $diff = $ent->__diff[$fname];
795     rewind($diff);
796     return $diff;
797   }
798   $tmp = tmpfile();
799   $diff = $ent->repo->diff($file, $ent->rev);
800   stream_copy_to_stream($diff, $tmp);
801   $ent->__diff[$fname] = $tmp;
802   rewind($tmp);
803   return $tmp;
804 }
805
806 function get_project($pid) {
807   static $projects = array();
808   if (isset($projects[$pid])) {
809     return $projects[$pid];
810   }
811   $projects[$pid] = MTrackProject::loadById($pid);
812   return $projects[$pid];
813 }
814
815 function get_component($cid) {
816   static $comps = array();
817   if (isset($comps[$cid])) {
818     return $comps[$cid];
819   }
820   $comps[$cid] = MTrackComponent::loadById($cid);
821   return $comps[$cid];
822 }
823
824 function get_milestone($mid) {
825   static $comps = array();
826   if (isset($comps[$mid])) {
827     return $comps[$mid];
828   }
829   $comps[$mid] = MTrack_Milestone::loadById($mid);
830   return $comps[$mid];
831 }
832
833 if (!$DEBUG) {
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')");
840   $db->commit();
841 }
842
843 mtrack_cache_maintain();
844