PageRenderTime 10ms CodeModel.GetById 6ms app.highlight 95ms RepoModel.GetById 1ms app.codeStats 0ms

/bin/send-notifications.php

https://bitbucket.org/pmjones/mtrack
PHP | 592 lines | 496 code | 53 blank | 43 comment | 79 complexity | 82c8c8fab25e44242aa383829e4d274b MD5 | raw file
  1<?php # vim:ts=2:sw=2:et:
  2/* For licensing and copyright terms, see the file named LICENSE */
  3include dirname(__FILE__) . '/../inc/common.php';
  4
  5$DEBUG = strlen(getenv('DEBUG_NOTIFY')) ? true : false;
  6
  7$MAX_DIFF = 200 * 1024;
  8$USE_BATCHING = false;
  9
 10if (!$DEBUG) {
 11  /* only allow one instance to run concurrently */
 12  $lockfp = fopen(dirname(__FILE__) . '/../var/.notifier.lock', 'w');
 13  if (!$lockfp) {
 14    exit(1);
 15  }
 16  if (!flock($lockfp, LOCK_EX|LOCK_NB)) {
 17    echo "Another instance is already running\n";
 18    exit(1);
 19  }
 20  /* "leak" $lockfp, so that the lock is held while we continue to run */
 21}
 22
 23$db = MTrackDB::get();
 24
 25// default to the last 10 minutes, but prefer the last recorded run time
 26$last = MTrackDB::unixtime(time() - 600);
 27foreach (MTrackDB::q('select last_run from last_notification')->fetchAll()
 28    as $row) {
 29  $last = $row[0];
 30}
 31$LATEST = strtotime($last);
 32if ($DEBUG) {
 33  $dtime = strtotime(getenv('DEBUG_TIME'));
 34  if ($dtime > 0) {
 35    $LATEST = $dtime;
 36    $last = MTrackDB::unixtime($LATEST);
 37    echo "Using $last as last time (specified via DEBUG_TIME var)\n";
 38  }
 39}
 40
 41$by_object = array();
 42
 43// Gather pertinent change information en-masse
 44$changes = MTrackDB::q("
 45  select * from changes where changedate > ? order by changedate asc", $last)
 46  ->fetchAll(PDO::FETCH_OBJ);
 47$cids = array();
 48$cs_by_cid = array();
 49foreach ($changes as $CS) {
 50  $cids[] = $CS->cid;
 51  $cs_by_cid[$CS->cid] = $CS;
 52
 53  $t = strtotime($CS->changedate);
 54  if ($t > $LATEST) {
 55    $LATEST = $t;
 56  }
 57
 58  list($object, $id) = explode(':', $CS->object, 3);
 59  switch ($object) {
 60    case 'ticket':
 61    case 'wiki':
 62    case 'milestone':
 63      $by_object[$object][$id][] = $CS->cid;
 64      break;
 65  }
 66}
 67$cidlist = join(',', $cids);
 68$change_audit = array();
 69$effort_audit = array();
 70if (count($cids)) {
 71  foreach (MTrackDB::q("select * from change_audit where cid in ($cidlist)")
 72      ->fetchAll(PDO::FETCH_ASSOC) as $citem) {
 73    $change_audit[$citem['cid']][] = $citem;
 74  }
 75  foreach (MTrackDB::q(
 76        "select * from effort where cid in ($cidlist)")
 77      ->fetchAll(PDO::FETCH_ASSOC) as $eff) {
 78    $effort_audit[$eff['cid']][] = $eff;
 79  }
 80}
 81
 82$comp_by_id = array();
 83foreach (MTrackDB::q('select c.compid, c.name, p.name from components c left join components_by_project cbp on (c.compid = cbp.compid) left join projects p on (cbp.projid = p.projid) where deleted <> 1 order by c.name')
 84    ->fetchAll(PDO::FETCH_NUM) as $row) {
 85  if (strlen($row[2])) {
 86    $row[1] .= " ($row[2])";
 87  }
 88  $comp_by_id[$row[0]] = $row[1];
 89}
 90
 91$milestone_by_id = array();
 92foreach (MTrackDB::q('select  mid, name from milestones')->fetchAll() as $m)
 93{
 94  $milestone_by_id[$m[0]] = $m[1];
 95}
 96$proj_by_comp = array();
 97foreach (MTrackDB::q('select projid, compid from components_by_project')
 98    ->fetchAll() as $r) {
 99  $proj_by_comp[$r['compid']] = $r['projid'];
100}
101$proj_by_id = array();
102foreach (MTrackDB::q('select projid from projects')->fetchAll() as $r) {
103  $proj_by_id[$r[0]] = MTrackProject::loadById($r[0]);
104}
105
106function get_user_info($name)
107{
108  $name = mtrack_canon_username($name);
109  static $info = array();
110  if (isset($info[$name])) {
111    return $info[$name];
112  }
113  foreach (MTrackDB::q('select * from userinfo where userid = ?', $name)
114      ->fetchAll(PDO::FETCH_ASSOC) as $data) {
115    $info[$name] = $data;
116    return $data;
117  }
118  // fake it
119  $data = array(
120    'userid' => $name,
121    'email' => $name . '@' . MTrackConfig::get('core', 'default_email_domain'),
122    'fullname' => $name,
123  );
124  $info[$name] = $data;
125  return $data;
126}
127
128
129// Need to assess changesets from the various repos to catch changes
130// that don't reference tickets
131
132// TODO: For mercurial and presumably other DVCS, we need to track changegroups
133// independently, because the timestamp of the push is not stored in the repo.
134// This means that it is possible to sit on a commit for a couple of days
135// before pushing it, and this script will not see it because the log date in
136// the repo will always be far in the past even though the push just happened.
137
138$repo_changes_by_ticket = array();
139$repos_by_name = array();
140
141foreach (MTrackDB::q('select shortname from repos order by shortname')
142      ->fetchAll(PDO::FETCH_COLUMN, 0) as $reponame) {
143  $repo = MTrackRepo::loadByName($reponame);
144  $repos_by_name[$reponame] = $repo;
145  $checker = new MTrackCommitChecker($repo);
146
147  foreach ($repo->history(null, $last) as $e) {
148    // the SCM may give us information on an item older than the date
149    // we requested, so we need to filter here too
150    $t = strtotime($e->ctime);
151    if ($t <= strtotime($last)) {
152echo "[$e->rev] exclude $e->ctime $last\n";
153      continue;
154    }
155echo "[$e->rev] include $e->ctime $last\n";
156    if ($t > $LATEST) {
157      $LATEST = $t;
158echo "  update latest to $t\n";
159    }
160
161    $pid = $repo->projectFromPath($e->files);
162    if ($pid > 1) {
163      $proj = $proj_by_id[$pid];
164      $e->changelog = $proj->adjust_links($e->changelog, true);
165    }
166    $actions = $checker->parseCommitMessage($e->changelog);
167    $tickets = array();
168    foreach ($actions as $act) {
169      $tkt = $act[1];
170      $tickets[$tkt] = $tkt;
171      $repo_changes_by_ticket[$tkt][$reponame][$e->rev] = $e->rev;
172    }
173    if (count($tickets) == 0) {
174      // This changeset is not represented by a change in a ticket
175      $by_object['changeset'][$reponame][$e->rev] = $e;
176    }
177  }
178}
179
180$batch_fields = array(
181  'owner' => true,
182  'status' => true,
183  'priority' => true,
184  '@milestones' => true,
185);
186
187function major_contributor($list)
188{
189  $major = null;
190  $count = 0;
191  foreach ($list as $user => $input) {
192    if ($input > $count) {
193      $major = $user;
194      $count = $input;
195    }
196  }
197  return $major;
198}
199
200if (isset($by_object['ticket'])) {
201  // If tickets were subject to roadmap/pri change only, say as part of
202  // a mass update, then we recognize those and send out a batch notification
203  // separately
204
205  $batch_tkt = array();
206  $tkt_by_tid = array();
207
208  foreach ($by_object['ticket'] as $tid => $cslist) {
209    $T = MTrackIssue::loadById($tid);
210
211    $fields = array();
212    $oid = "ticket:$tid";
213    $is_batch = true;
214    $old_values = array();
215    $comments = array();
216    $field_changers = array();
217    $contributors = array();
218    $projects = array(); // defines who gets notified
219
220    foreach ($cslist as $cid) {
221      $contributors[$cs_by_cid[$cid]->who]++;
222      if (isset($change_audit[$cid])) foreach ($change_audit[$cid] as $C) {
223        // fieldname is of the form: "ticket:id:fieldname"
224        $field = substr($C['fieldname'], strlen($oid)+1);
225        if (!isset($batch_fields[$field])) {
226          $is_batch = false;
227        }
228        if ($field == '@comment') {
229          $comments[] = "Comment by " . 
230            $cs_by_cid[$cid]->who . ":\n" . $C['value'];
231        } elseif ($field != 'spent') {
232          $field_changers[$field] = $cs_by_cid[$cid]->who;
233          if (!isset($old_values[$field])) {
234            $old_values[$field] = $C['oldvalue'];
235          }
236        }
237        $contributors[$cs_by_cid[$cid]->who]++;
238      }
239    }
240    if ($is_batch && $USE_BATCHING) {
241      $batch_tkt[$tid] = $cslist;
242      $tkt_by_tid[$tid] = $T;
243      continue;
244    }
245    /* changes were not suitable for batching, so now we represent them
246     * as history on that ticket */
247    $plain = '';
248    $plain .= MTrackConfig::get('core', 'weburl')
249            . "ticket.php/$T->nsident\n\n";
250    $plain .=  "#$T->nsident: $T->summary ($T->status $T->classification)\n";
251    $owner = strlen($T->owner) == 0 ? 'nobody' : $T->owner;
252    $plain .= "Responsible: $owner ($T->priority / $T->severity)\n";
253    $plain .= "Milestone: " . 
254                join(', ', $T->getMilestones()) . "\n";
255    $plain .= "Component: " . 
256                join(', ', $T->getComponents()) . "\n";
257    $plain .= "\n";
258
259    foreach ($T->getComponents() as $compid => $comp) {
260      $projects[$proj_by_comp[$compid]]++;
261    }
262
263    // Display changed fields grouped by the person that last changed them
264    $who_changed = array();
265    foreach ($field_changers as $field => $who) {
266      $who_changed[$who][] = $field;
267    }
268
269    foreach ($who_changed as $who => $fieldlist) {
270      $plain .= "Changes by $who:\n";
271      foreach ($fieldlist as $field) {
272        $old = $old_values[$field];
273
274        if (!strlen($old) && $field == 'nsident') {
275          continue;
276        }
277
278        $value = null;
279        switch ($field) {
280          case '@components':
281            $old = array();
282            foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
283              $old[] = $comp_by_id[$id];
284              $projects[$proj_by_comp[$id]]++;
285            }
286            $value = array();
287            foreach ($T->getComponents() as $id => $compname) {
288              $value[$id] = $comp_by_id[$id];
289            }
290            $field = 'Component';
291            break;
292          case '@milestones':
293            $old = array();
294            foreach (preg_split("/\s*,\s*/", $old_values[$field]) as $id) {
295              $old[] = $milestone_by_id[$id];
296            }
297            $value = $T->getMilestones();
298            $field = 'Milestone';
299            break;
300          case '@keywords':
301            $field = 'Keywords';
302            $value = $T->getKeywords();
303            break;
304          default:
305            $value = $T->$field;
306        }
307        if (is_array($value)) {
308          $value = join(', ', $value);
309        }
310        if (is_array($old)) {
311          $old = join(', ', $old);
312        }
313        if ($value == $old) {
314          continue;
315        }
316        if ($field == 'description') {
317          $lines = count(explode("\n", $old));
318          $diff = mtrack_diff_strings($old, $value);
319          $diff_add = 0;
320          $diff_rem = 0;
321          foreach (explode("\n", $diff) as $line) {
322            if ($line[0] == '-') {
323              $diff_rem++;
324            } else if ($line[0] == '+') {
325              $diff_add++;
326            }
327          }
328          if (abs($diff_add - $diff_rem) > $lines / 2) {
329            $plain .= "Description changed to:\n" . $value . "\n\n";
330          } else {
331            $plain .= "Description changed:\n" . $diff . "\n\n";
332          }
333        } else {
334          $plain .= "$field $old -> $value\n";
335        }
336      }
337    }
338    foreach ($comments as $comment) {
339// parse the description to see if it matches the "(In
340// [changeset:repo,id], [changeset:repo,id]...)" form.  This is needed because
341// there is a possibility that we will opt to exclude a changeset at the top of
342// this script based on date.  We want to pluck out all the changesets that are
343// referenced in such a way and add them to the $repo_changes_by_ticket array
344// for this ticket
345// FIXME: need to handle the multiple changeset case for DVCS like hg.
346      if (preg_match_all(
347          "/\(In \[changeset:(([^,]+),([a-zA-Z0-9]+))\]\)/sm",
348          $comment, $CSM)) {
349        // $CSM[2] => repo
350        // $CSM[3] => changeset
351        foreach ($CSM[2] as $csm => $csm_repo) {
352          $csm_rev = $CSM[3][$csm];
353          if (!isset($repo_changes_by_ticket[$T->nsident]) ||
354              !in_array($csm_rev,
355                $repo_changes_by_ticket[$T->nsident][$csm_repo])) {
356            $repo_changes_by_ticket[$T->nsident][$csm_repo][] = $csm_rev;
357          }
358        }
359      }
360      $plain .= "\n" . $comment . "\n";
361    }
362
363    /* bundle up the changesets along with the ticket changes */
364    if (isset($repo_changes_by_ticket[$T->nsident])) {
365      foreach ($repo_changes_by_ticket[$T->nsident] as $reponame => $revlist) {
366        $plain .= "\nChanges in $reponame:\n";
367
368        $repo = $repos_by_name[$reponame];
369        /* TODO: 'compress' a run of changes so that we show the net change */
370
371        // all affected files in this repo for this notification
372        $files = array();
373        $ent_by_rev = array();
374
375        foreach ($revlist as $rev) {
376          list($ent) = $repo->history(null, 1, 'rev', $rev);
377          $ent_by_rev[$rev] = $ent;
378          $projects[$repo->projectFromPath($ent->files)]++;
379          foreach ($ent->files as $file) {
380            $files[$file] = "$file->status $file->name";
381          }
382        }
383
384        $plain .= "  Affected files:\n   " . join("\n   ", $files) . "\n";
385
386        $too_big = false;
387        foreach ($ent_by_rev as $ent) {
388          $plain .= "\n[$ent->rev] by $ent->changeby\n";
389          $plain .= MTrackConfig::get('core', 'weburl')
390            . "changeset.php/$reponame/$ent->rev\n\n";
391
392          if (strlen($plain) < $MAX_DIFF) {
393            foreach ($ent->files as $file) {
394              $diff = stream_get_contents($repo->diff($file, $ent->rev));
395              if (strlen($plain) + strlen($diff) < $MAX_DIFF) {
396                $plain .= $diff . "\n";
397              } else {
398                $too_big = true;
399              }
400            }
401          } else {
402            $too_big = true;
403          }
404        }
405        if ($too_big) {
406          $plain .= "  ** Diff exceeds configured limit\n";
407        }
408      }
409    }
410    $plain .= "\n" . MTrackConfig::get('core', 'weburl')
411            . "ticket.php/$T->nsident\n";
412
413    $to = preg_split("/(\s+|\s*[,;]\s*)/", $T->cc);
414    $fromuser = major_contributor($contributors);
415    $U = get_user_info($fromuser);
416    $from = "From: <$U[email]> \"$U[fullname]\"\n";
417    $to[$U['email']] = $U['email'];
418    $repto = array("<$U[email]> \"$U[fullname]\"");
419    foreach ($contributors as $c => $count) {
420      if ($c !== $fromuser) {
421        $U = get_user_info($c);
422        if (strlen($U['email'])) {
423          $repto[] = "<$U[email]> \"$U[fullname]\"";
424          $to[$U['email']] = $U['email'];
425        }
426      }
427    }
428    if (count($repto) > 1) {
429      $repto = "Reply-To: " . join(", ", $repto) . "\n";
430    } else {
431      $repto = '';
432    }
433    $pnames = array();
434    if (count($projects) == 0) {
435      $projects[1] = 1; // force in a default project
436    }
437    foreach ($projects as $pid => $count) {
438      $P = $proj_by_id[$pid];
439      if (is_object($P)) {
440        $pnames[$P->shortname] = $P->shortname;
441        $to[$P->notifyemail] = $P->notifyemail;
442      }
443    }
444    natsort($pnames);
445
446    // compute base message-id
447    $mid = $T->tid . '@' . php_uname('n');
448    // if one of the changesets we saw in this run matches the created cid
449    // then we are the initial message
450    $is_initial = false;
451    foreach ($by_object['ticket'][$T->tid] as $cid) {
452      if ($cid == $T->created) {
453        $is_initial = true;
454      }
455    }
456    if ($is_initial) {
457      $mid = "Message-ID: <$mid>\n";
458    } else {
459      $mid = "Message-ID: <$T->updated.$mid>\n" .
460             "In-Reply-To: <$mid>\n" .
461             "References: <$mid>\n";
462    }
463
464    $phdrs = "X-mtrack-project-list: " . join(' ', $pnames) . "\n";
465    foreach ($pnames as $p) {
466      $phdrs .= "X-mtrack-project-$p: $p\n";
467      $phdrs .= "X-mtrack-project: $p\n";
468    }
469
470    foreach ($to as $_ => $recip) {
471      if (!strlen($recip)) {
472        unset($to[$_]);
473      }
474    }
475
476    $mail = $from . $repto . $mid .
477      "To: " . join(', ', $to) . "\n" .
478      'Subject: =?UTF-8?B?' . base64_encode('[' . join(' ', $pnames) . "] #$T->nsident $T->summary ($T->status $T->classification)") . '?=' . "\n" .
479      "MIME-Version: 1.0\n" .
480      "Content-Type: text/plain; charset=utf-8\n" .
481      "X-mtrack-ticket: $T->nsident\n" .
482      $phdrs .
483      "\n" .
484      $plain
485      ;
486
487    $reciplist = array();
488    foreach ($to as $recip) {
489      $reciplist[] = escapeshellarg($recip);
490    }
491    $reciplist = join(' ', $reciplist);
492    if ($DEBUG) {
493      echo "would mail: $reciplist\n$mail\n";
494    } else {
495      $pipe = popen("/usr/sbin/sendmail $reciplist", 'w');
496      fwrite($pipe, $mail);
497      pclose($pipe);
498    }
499  }
500
501  if (count($batch_tkt) > 0) { // FIXME: implement batched ticket overview
502    $plain = '';
503    echo "\nBatched ticket notifications\n";
504    foreach ($batch_tkt as $tid => $cslist) {
505      $T = $tkt_by_tid[$tid];
506      echo "  $T->nsident";
507    }
508    echo "\n";
509  }
510}
511
512if (isset($by_object['changeset'])) 
513foreach ($by_object['changeset'] as $reponame => $revlist) {
514  $repo = $repos_by_name[$reponame];
515  foreach ($revlist as $rev => $ent) {
516    $plain = "Changes in $reponame [$ent->rev] by $ent->changeby:\n";
517    $plain .= MTrackConfig::get('core', 'weburl')
518      . "changeset.php/$reponame/$ent->rev\n\n";
519    $plain .= "\n" . $ent->changelog . "\n\n";
520    $flist = array();
521    foreach ($ent->files as $file) {
522      $flist[] = "$file->status $file->name";
523    }
524    $plain .= "  Affected files:\n   " . join("\n   ", $flist) . "\n\n";
525
526    if (strlen($plain) < $MAX_DIFF) {
527      foreach ($ent->files as $file) {
528        $diff = stream_get_contents($repo->diff($file, $ent->rev));
529        if (strlen($plain) + strlen($diff) < $MAX_DIFF) {
530          $plain .= $diff . "\n";
531        } else {
532          $too_big = true;
533        }
534      }
535    } else {
536      $too_big = true;
537    }
538    if ($too_big) {
539      $plain .= "  ** Diff exceeds configured limit\n";
540    }
541
542    $U = get_user_info($ent->changeby);
543    $from = "From: <$U[email]> \"$U[fullname]\"\n";
544    $pid = $repo->projectFromPath($ent->files);
545    if ($pid === null) $pid = 1;
546    $P = $proj_by_id[$pid];
547
548    $mail = $from . 
549      "To: $P->notifyemail\n" .
550      'Subject: =?UTF-8?B?' . base64_encode("[$P->shortname] $reponame commit [$ent->rev]") . '?=' . "\n" .
551      "MIME-Version: 1.0\n" .
552      "Content-Type: text/plain; charset=utf-8\n" .
553      "\n" .
554      $plain
555      ;
556
557    if ($DEBUG) {
558      echo "Would mail: $P->notifyemail\n";
559      echo $mail . "\n";
560    } else {
561      $pipe = popen("/usr/sbin/sendmail " .
562                escapeshellarg($P->notifyemail), 'w');
563      fwrite($pipe, $mail);
564      pclose($pipe);
565    }
566  }
567}
568
569if (isset($by_object['wiki'])) {
570  foreach ($by_object['wiki'] as $pagename => $cslist) {
571    //echo "Wiki $pagename\n";
572    // TODO: implement wiki page change notification
573  }
574}
575
576if (isset($by_object['milestone'])) {
577  foreach ($by_object['milestone'] as $mname => $cslist) {
578    //echo "Milestone $mname\n";
579    // TODO: implement milestone change notification
580  }
581}
582
583if (!$DEBUG) {
584  // Now we are done, update the last run time
585  $last_change = end($changes);
586  $db->beginTransaction();
587  $db->exec("delete from last_notification");
588  $t = MTrackDB::unixtime($LATEST);
589  echo "updating last run to $t $LATEST\n";
590  $db->exec("insert into last_notification (last_run) values ('$t')");
591  $db->commit();
592}