final move of files
[web.mtrack] / MTrack / Issue.php
1 <?php
2 require_once 'MTrack/Interface/IssueListener.php';
3 require_once 'MTrack/DB.php';
4 require_once 'MTrack/Config.php';
5 require_once 'MTrack/Component.php';
6 require_once 'MTrack/Wiki.php';
7 require_once 'MTrack/Changeset.php';
8 require_once 'MTrack/SearchDB.php';
9 require_once 'MTrack/Milestone.php';
10 require_once 'MTrack/Keyword.php';
11
12
13 class MTrackIssue {
14   public $tid = null;
15   public $nsident = null;
16   public $summary = null;
17   public $description = null;
18   public $created = null;
19   public $updated = null;
20   public $owner = null;
21   public $priority = null;
22   public $severity = null;
23   public $classification = null;
24   public $resolution = null;
25   public $status = null;
26   public $estimated = null;
27   public $spent = null;
28   public $changelog = null;
29   public $cc = null;
30   protected $components = null;
31   protected $origcomponents = null;
32   protected $milestones = null;
33   protected $origmilestones = null;
34   protected $comments_to_add = array();
35   protected $keywords = null;
36   protected $origkeywords = null;
37   protected $effort = array();
38
39   static $_listeners = array();
40   static function loadById($id) {
41     try {
42       return new MTrackIssue($id);
43     } catch (Exception $e) {
44     }
45     return null;
46   }
47   static function loadByNSIdent($id) // really the integer id.
48   {
49     static $cache = array();
50     if (!isset($cache[$id])) {
51       $ids = MTrackDB::q('select tid from tickets where nsident = ?', $id)
52             ->fetchAll(PDO::FETCH_COLUMN, 0);
53       if (count($ids) == 1) {
54         $cache[$id] = $ids[0];
55       } else {
56         return null;
57       }
58     }
59     return new MTrackIssue($cache[$id]);
60   }
61   static function registerListener(IMTrackIssueListener $l) // used by CustomFields
62   {
63     self::$_listeners[] = $l;
64   }
65   static function index_issue($object)
66   {
67     list($ignore, $ident) = explode(':', $object, 2);
68     $i = MTrackIssue::loadById($ident);
69     if (!$i) return;
70     echo "Ticket #$i->nsident\n";
71
72     $CS = MTrackChangeset::get($i->updated);
73     $CSC = MTrackChangeset::get($i->created);
74
75     $kw = join(' ', array_values($i->getKeywords()));
76     $idx = array(
77             'summary' => $i->summary,
78             'description' => $i->description,
79             'changelog' => $i->changelog,
80             'keyword' => $kw,
81             'stored:date' => $CS->when,
82             'who' => $CS->who,
83             'creator' => $CSC->who,
84             'stored:created' => $CSC->when,
85             'owner' => $i->owner
86             );
87     $i->augmentIndexerFields($idx);
88     MTrackSearchDB::add("ticket:$i->tid", $idx, true);
89
90     foreach (MTrackDB::q('select value, changedate, who from
91         change_audit left join changes using (cid) where fieldname = ?',
92         "ticket:$ident:@comment") as $row) {
93       list($text, $when, $who) = $row;
94       $start = time();
95       $id = sha1($text);
96       $elapsed = time() - $start;
97       if ($elapsed > 4) {
98         echo "  - comment $who $when took $elapsed to hash\n";
99       }
100       $start = time();
101       if (strlen($text) > 8192) {
102         // A huge paste into a ticket
103         $text = substr($text, 0, 8192);
104       }
105       MTrackSearchDB::add("ticket:$ident:comment:$id", array(
106         'description' => $text,
107         'stored:date' => $when,
108         'who' => $who,
109       ), true);
110
111       $elapsed = time() - $start;
112       if ($elapsed > 4) {
113         echo "  - comment $who $when took $elapsed to index\n";
114       }
115     }
116   }
117
118   //methods..
119   
120     function __construct($tid = null) {
121         if ($tid === null) {
122             $this->components = array();
123             $this->origcomponents = array();
124             $this->milestones = array();
125             $this->origmilestones = array();
126             $this->keywords = array();
127             $this->origkeywords = array();
128             $this->status = 'new';
129
130             foreach (array('classification', 'severity', 'priority') as $f) {
131                 $this->$f = MTrackConfig::get('ticket', "default.$f");
132             }
133         } else {
134             $data =  MTrackDB::q('select * from tickets where tid = ?',
135                               $tid)->fetchAll();
136             
137             $row = null;
138             if (isset($data[0])) {
139                 $row = $data[0];
140             } 
141             
142             if (!is_array($row)) {
143                 throw new Exception("no such issue $tid");
144             }
145             
146             foreach ($row as $k => $v) {
147                 $this->$k = $v;
148             }
149         }
150     }
151
152   function applyPOSTData($data) {
153     foreach (self::$_listeners as $l) {
154       $l->applyPOSTData($this, $data);
155     }
156   }
157
158   function augmentFormFields(&$FIELDSET) {
159     foreach (self::$_listeners as $l) {
160       $l->augmentFormFields($this, $FIELDSET);
161     }
162   }
163   function augmentIndexerFields(&$idx) {
164     foreach (self::$_listeners as $l) {
165       $l->augmentIndexerFields($this, $idx);
166     }
167   }
168   function augmentSaveParams(&$params) {
169     foreach (self::$_listeners as $l) {
170       $l->augmentSaveParams($this, $params);
171     }
172   }
173
174   function checkVeto()
175   {
176     $args = func_get_args();
177     $method = array_shift($args);
178     $veto = array();
179
180     foreach (self::$_listeners as $l) {
181       $v = call_user_func_array(array($l, $method), $args);
182       if ($v !== true) {
183         $veto[] = $v;
184       }
185     }
186     if (count($veto)) {
187       $reasons = array();
188       foreach ($veto as $r) {
189         if (is_array($r)) {
190           foreach ($r as $m) {
191             $reasons[] = $m;
192           }
193         } else {
194           $reasons[] = $r;
195         }
196       }
197       require_once 'Exception/Veto.php';
198       throw new MTrackVetoException($reasons);
199     }
200   }
201
202   function save(MTrackChangeset $CS)
203   {
204     $db = MTrackDB::get();
205     $reindex = false;
206
207     if ($this->tid === null) {
208       $this->created = $CS->cid;
209       $oldrow = array();
210       $reindex = true;
211     } else {
212       list($oldrow) = MTrackDB::q('select * from tickets where tid = ?',
213                         $this->tid)->fetchAll();
214     }
215
216     $this->checkVeto('vetoSave', $this, $oldrow);
217
218     $this->updated = $CS->cid;
219
220     $params = array(
221       'summary' => $this->summary,
222       'description' => $this->description,
223       'created' => $this->created,
224       'updated' => $this->updated,
225       'owner' => $this->owner,
226       'changelog' => $this->changelog,
227       'priority' => $this->priority,
228       'severity' => $this->severity,
229       'classification' => $this->classification,
230       'resolution' => $this->resolution,
231       'status' => $this->status,
232       'estimated' => (float)$this->estimated,
233       'spent' => (float)$this->spent,
234       'nsident' => $this->nsident,
235       'cc' => $this->cc,
236     );
237
238     $this->augmentSaveParams($params);
239
240     if ($this->tid === null) {
241       $sql = 'insert into tickets ';
242       $keys = array();
243       $values = array();
244       
245       require_once 'UUID.php';
246       $new_tid = new OmniTI_Util_UUID;
247       $new_tid = $new_tid->toRFC4122String(false);
248
249       $keys[] = "tid";
250       $values[] = "'$new_tid'";
251
252       foreach ($params as $key => $value) {
253         $keys[] = $key;
254         $values[] = ":$key";
255       }
256
257       $sql .= "(" . join(', ', $keys) . ") values (" .
258               join(', ', $values) . ")";
259     } else {
260       $sql = 'update tickets set ';
261       $values = array();
262       foreach ($params as $key => $value) {
263         $values[] = "$key = :$key";
264       }
265       $sql .= join(', ', $values) . " where tid = :tid";
266
267       $params['tid'] = $this->tid;
268     }
269
270     $q = $db->prepare($sql);
271     $q->execute($params);
272
273     if ($this->tid === null) {
274       $this->tid = $new_tid;
275       $created = true;
276     } else {
277       $created = false;
278     }
279
280     foreach ($params as $key => $value) {
281       if ($key == 'created' || $key == 'updated' || $key == 'tid') {
282         continue;
283       }
284       if ($key == 'changelog' || $key == 'description' || $key == 'summary') {
285         if (!isset($oldrow[$key]) || $oldrow[$key] != $value) {
286           $reindex = true;
287         }
288       }
289       if (!isset($oldrow[$key])) {
290         $oldrow[$key] = null;
291       }
292       $CS->add("ticket:$this->tid:$key", $oldrow[$key], $value);
293     }
294
295     $this->compute_diff($CS, 'components', 'ticket_components', 'compid',
296         $this->components, $this->origcomponents);
297     $this->compute_diff($CS, 'keywords', 'ticket_keywords', 'kid',
298         $this->keywords, $this->origkeywords);
299     $this->compute_diff($CS, 'milestones', 'ticket_milestones', 'mid',
300         $this->milestones, $this->origmilestones);
301
302     foreach ($this->comments_to_add as $text) {
303       $CS->add("ticket:$this->tid:@comment", null, $text);
304     }
305
306     foreach ($this->effort as $effort) {
307       MTrackDB::q('insert into effort (tid, cid, expended, remaining)
308         values (?, ?, ?, ?)',
309         $this->tid, $CS->cid, $effort[0], $effort[1]);
310     }
311     $this->effort = array();
312   }
313  
314   function getComponents()
315   {
316     if ($this->components !== null) {
317         return $this->components ;
318     }
319     $q = MTrackDB::q('
320             select tc.compid, name 
321                 from ticket_components tc 
322                     left join components using (compid) 
323                     where tid = ?', $this->tid);
324   
325     $this->origcomponents = array();
326     foreach ($q->fetchAll() as $row) {
327         $this->origcomponents[$row[0]] = $row[1];
328     }
329     $this->components = $this->origcomponents;
330     
331     return $this->components;
332   }
333
334   function assocComponent($comp)
335   {
336     $comp = $this->resolveComponent($comp);
337     $this->getComponents();
338     $this->checkVeto('vetoComponent', $this, $comp, true);
339     $this->components[$comp->compid] = $comp->name;
340   }
341
342   function dissocComponent($comp)
343   {
344     $comp = $this->resolveComponent($comp);
345     $this->getComponents();
346     $this->checkVeto('vetoComponent', $this, $comp, false);
347     unset($this->components[$comp->compid]);
348   }
349
350   function getMilestones()
351   {
352     if ($this->milestones === null) {
353       $comps = MTrackDB::q('select tc.mid, name from ticket_milestones tc left join milestones using (mid) where tid = ? order by duedate, name', $this->tid)->fetchAll();
354       $this->origmilestones = array();
355       foreach ($comps as $row) {
356         $this->origmilestones[$row[0]] = $row[1];
357       }
358       $this->milestones = $this->origmilestones;
359     }
360     return $this->milestones;
361   }
362
363   
364   function assocMilestone($M)
365   {
366     $ms = $this->resolveMilestone($M);
367     if ($ms === null) {
368       throw new Exception("unable to resolve milestone $M");
369     }
370     $this->getMilestones();
371     $this->checkVeto('vetoMilestone', $this, $ms, true);
372     $this->milestones[$ms->mid] = $ms->name;
373   }
374
375   function dissocMilestone($M)
376   {
377     $ms = $this->resolveMilestone($M);
378     if ($ms === null) {
379       throw new Exception("unable to resolve milestone $M");
380     }
381     $this->getMilestones();
382     $this->checkVeto('vetoMilestone', $this, $ms, false);
383     unset($this->milestones[$ms->mid]);
384   }
385
386   function addComment($comment)
387   {
388     $comment = trim($comment);
389     if (strlen($comment)) {
390       $this->checkVeto('vetoComment', $this, $comment);
391       $this->comments_to_add[] = $comment;
392     }
393   }
394
395
396   function assocKeyword($kw)
397   {
398     $kw = $this->resolveKeyword($kw);
399     $this->getKeywords();
400     $this->checkVeto('vetoKeyword', $this, $kw, true);
401     $this->keywords[$kw->kid] = $kw->keyword;
402   }
403
404   function dissocKeyword($kw)
405   {
406     $kw = $this->resolveKeyword($kw);
407     $this->getKeywords();
408     $this->checkVeto('vetoKeyword', $this, $kw, false);
409     unset($this->keywords[$kw->kid]);
410   }
411
412   function getKeywords()
413   {
414     if ($this->keywords === null) {
415       $comps = MTrackDB::q('select tc.kid, keyword from ticket_keywords tc left join keywords using (kid) where tid = ?', $this->tid)->fetchAll();
416       $this->origkeywords = array();
417       foreach ($comps as $row) {
418         $this->origkeywords[$row[0]] = $row[1];
419       }
420       $this->keywords = $this->origkeywords;
421     }
422     return $this->keywords;
423   }
424
425   function addEffort($amount, $revised = null)
426   {
427     $diff = null;
428     if ($revised !== null) {
429       $diff = $revised - $this->estimated;
430       $this->estimated = $revised;
431     }
432     $this->effort[] = array($amount, $diff);
433     $this->spent += $amount;
434   }
435
436   function close()
437   {
438     $this->status = 'closed';
439     $this->addEffort(0, 0);
440   }
441
442   function isOpen()
443   {
444     switch ($this->status) {
445       case 'closed':
446         return false;
447       default:
448         return true;
449     }
450   }
451
452   function reOpen()
453   {
454     $this->status = 'reopened';
455     $this->resolution = null;
456   }
457
458   
459     function toArray() 
460     {
461         $ret = get_object_vars($this);
462        // echo '<PRE>'; print_r($ret);exit;
463         return $ret;
464     }
465   
466   
467   /// rendering tricks
468   
469   
470     function updateWho($link)
471     {
472         return  $link->username(
473                 $this->updated ? 
474                     MTrackChangeset::get($this->updated)->who :
475                     MTrackAuth::whoami()
476         );
477         
478     }
479     function updateWhen($link) 
480     {
481         return  $link->date( $this->updated ? 
482                 MTrackChangeset::get($this->updated)->when :
483                 MTrackDB::unixtime(time()) 
484         );
485     }
486     function createdWho($link)
487     {
488         return  $link->username(
489                 $this->created ? 
490                     MTrackChangeset::get($this->created)->who :
491                     MTrackAuth::whoami()
492         );
493         
494     }
495     function createdWhen($link) 
496     {
497         return  $link->date( $this->created ? 
498                 MTrackChangeset::get($this->created)->when :
499                 MTrackDB::unixtime(time()) 
500         );
501     }
502   
503     function keywordsToHtml() 
504     {
505         $value = array();
506         foreach ($this->getKeywords() as $kw) {
507             $value[] = mtrack_keyword($kw);
508         }
509         return  join(' ', $value);
510     }
511     
512
513     
514     function componentsToHtml() 
515     {
516         $res = array();
517         $value = join(',',array_keys($this->getComponents()));
518         
519         if (!strlen($value)) {
520             return '';
521         }
522         $q = MTrackDB::q(
523               "select name, deleted from components where compid in ($value)");
524         foreach ($q->fetchAll() as $row) {
525             
526             $c = ($row['deleted'] ? '<del>' : '') .
527                 htmlentities($row['name'], ENT_QUOTES, 'utf-8') . 
528                 ($row['deleted'] ? '</del>' : '');
529             $res[] = $c;
530         }
531         
532         return join(", ", $res);
533        
534     }
535     
536     function milestonesToHtml($msurl) 
537     {
538         if (empty($this->milestone_url)) {
539             die("requires issue->milestone_url to be set.");
540         }
541         $res = array();
542         $value = join(',',array_keys($this->getMilestones()));
543         
544         if (!strlen($value)) {
545             return '';
546         }
547         foreach (MTrackDB::q(
548           "select name, completed, deleted from milestones where mid in ($value)")
549           ->fetchAll() as $row) {
550               
551             $row['deleted'] =   strlen($row['completed']) ? 1 : 0;
552                 
553             $c = "<span class='milestone" . 
554                 ($row['deleted'] ?  " completed" : '') . 
555                 "'><a href=\"{$this->milestone_url}" . urlencode($row['name']) . '">' .
556                 htmlentities($row['name'], ENT_QUOTES, 'utf-8') .
557                 "</a></span>";
558             $res[] = $c;
559         }
560         return join(", ", $res);
561     }
562     function descriptionToHtml()
563     {
564         return MTrack_Wiki::format_to_html($this->description);
565     }
566    /* function attachmentsToHtml()
567     {
568         require_once 'Attachment.php';
569         return MTrackAttachment::renderList("ticket:{$this->tid}");
570     }
571     function attachmentsDeleteToHtml() {
572         require_once 'Attachment.php';
573         return MTrackAttachment::renderDeleteList("ticket:{$this->tid}");
574     }
575  */
576     function toIdString() {
577         return "ticket:{$this->tid}";
578             
579     }
580     
581     // watcher related.
582     function watcherButton()
583     {
584         return MTrackWatch::renderWatchUI('ticket', $this->tid);
585     }
586     
587     function watchType() 
588     {
589         return 'ticket';
590     }
591     function watchId()
592     {
593         return $this->tid;
594     }
595     
596     function watchers()
597     {
598         return MTrackWatch::objectWatchers($this);
599     }
600     
601     
602     private function resolveMilestone($ms)
603     {
604         if ($ms instanceof MTrack_Milestone) {
605           return $ms;
606         }
607         if (ctype_digit($ms)) {
608           return MTrack_Milestone::loadById($ms);
609         }
610         return MTrack_Milestone::loadByName($ms);
611     }
612
613   private function resolveKeyword($kw)
614   {
615     if ($kw instanceof MTrackKeyword) {
616       return $kw;
617     }
618     $k = MTrackKeyword::loadByWord($kw);
619     if ($k === null) {
620       if (ctype_digit($kw)) {
621         return MTrackKeyword::loadById($kw);
622       }
623       throw new Exception("unknown keyword $kw");
624     }
625     return $k;
626   }
627   private function compute_diff(MTrackChangeset $CS, $label,
628         $tablename, $keyname, $current, $orig) {
629     if (!is_array($current)) {
630       $current = array();
631     }
632     if (!is_array($orig)) {
633       $orig = array();
634     }
635     $added = array_keys(array_diff_key($current, $orig));
636     $removed = array_keys(array_diff_key($orig, $current));
637
638     $db = MTrackDB::get();
639     $ADD = $db->prepare(
640       "insert into $tablename (tid, $keyname) values (?, ?)");
641     $DEL = $db->prepare(
642       "delete from $tablename where tid = ? AND $keyname = ?");
643     foreach ($added as $key) {
644       $ADD->execute(array($this->tid, $key));
645     }
646     foreach ($removed as $key) {
647       $DEL->execute(array($this->tid, $key));
648     }
649     if (count($added) + count($removed)) {
650       $old = join(',', array_keys($orig));
651       $new = join(',', array_keys($current));
652       $CS->add(
653         "ticket:$this->tid:@$label", $old, $new);
654     }
655   }
656   private function resolveComponent($comp)
657   {
658     if ($comp instanceof MTrackComponent) {
659       return $comp;
660     }
661     if (ctype_digit($comp)) {
662       return MTrackComponent::loadById($comp);
663     }
664     return MTrackComponent::loadByName($comp);
665   }
666 }