import
[web.mtrack] / inc / issue.php
1 <?php # vim:ts=2:sw=2:et:
2 /* For licensing and copyright terms, see the file named LICENSE */
3
4 class MTrackEnumeration {
5   public $tablename;
6   protected $fieldname;
7   protected $fieldvalue;
8
9   public $name = null;
10   public $value = null;
11   public $deleted = null;
12   public $new = true;
13
14   function enumerate($all = false) {
15     $res = array();
16     if ($all) {
17       foreach (MTrackDB::q(sprintf("select %s, %s, deleted from %s order by %s",
18             $this->fieldname, $this->fieldvalue, $this->tablename,
19             $this->fieldvalue))
20             ->fetchAll(PDO::FETCH_NUM)
21           as $row) {
22         $res[$row[0]] = array(
23           'name' => $row[0],
24           'value' => $row[1],
25           'deleted' => $row[2] == '1' ? true : false
26         );
27       }
28     } else {
29       foreach (MTrackDB::q(sprintf("select %s from %s where deleted != '1'",
30               $this->fieldname, $this->tablename))->fetchAll(PDO::FETCH_NUM)
31           as $row) {
32         $res[$row[0]] = $row[0];
33       }
34     }
35     return $res;
36   }
37
38   function __construct($name = null) {
39     if ($name !== null) {
40       list($row) = MTrackDB::q(sprintf(
41           "select %s, deleted from %s where %s = ?",
42           $this->fieldvalue, $this->tablename, $this->fieldname),
43           $name)
44           ->fetchAll();
45       if (isset($row[0])) {
46         $this->name = $name;
47         $this->value = $row[0];
48         $this->deleted = $row[1];
49         $this->new = false;
50         return;
51       }
52       throw new Exception("unable to find $this->tablename with name = $name");
53     }
54
55     $this->deleted = false;
56   }
57
58   function save(MTrackChangeset $CS) {
59     if ($this->new) {
60       MTrackDB::q(sprintf('insert into %s (%s, %s, deleted) values (?, ?, ?)',
61         $this->tablename, $this->fieldname, $this->fieldvalue),
62             $this->name, $this->value, (int)$this->deleted);
63       $old = null;
64     } else {
65       list($row) = MTrackDB::q(
66         sprintf('select %s, deleted from %s where %s = ?',
67           $this->fieldname, $this->tablename, $this->fieldvalue),
68           $this->name)->fetchAll();
69       $old = $row[0];
70       MTrackDB::q(sprintf('update %s set %s = ?, deleted = ? where %s = ?',
71         $this->tablename, $this->fieldvalue, $this->fieldname),
72             $this->value, (int)$this->deleted, $this->name);
73     }
74     $CS->add($this->tablename . ":" . $this->name . ":" . $this->fieldvalue,
75       $old, $this->value);
76
77   }
78 }
79
80 class MTrackTicketState extends MTrackEnumeration {
81   public $tablename = 'ticketstates';
82   protected $fieldname = 'statename';
83   protected $fieldvalue = 'ordinal';
84
85   static function loadByName($name) {
86     return new MTrackTicketState($name);
87   }
88 }
89
90
91 class MTrackPriority extends MTrackEnumeration {
92   public $tablename = 'priorities';
93   protected $fieldname = 'priorityname';
94   protected $fieldvalue = 'value';
95
96   static function loadByName($name) {
97     return new MTrackPriority($name);
98   }
99 }
100
101 class MTrackSeverity extends MTrackEnumeration {
102   public $tablename = 'severities';
103   protected $fieldname = 'sevname';
104   protected $fieldvalue = 'ordinal';
105
106   static function loadByName($name) {
107     return new MTrackSeverity($name);
108   }
109 }
110
111 class MTrackResolution extends MTrackEnumeration {
112   public $tablename = 'resolutions';
113   protected $fieldname = 'resname';
114   protected $fieldvalue = 'ordinal';
115
116   static function loadByName($name) {
117     return new MTrackResolution($name);
118   }
119 }
120
121 class MTrackClassification extends MTrackEnumeration {
122   public $tablename = 'classifications';
123   protected $fieldname = 'classname';
124   protected $fieldvalue = 'ordinal';
125
126   static function loadByName($name) {
127     return new MTrackClassification($name);
128   }
129 }
130
131 class MTrackComponent {
132   public $compid = null;
133   public $name = null;
134   public $deleted = null;
135   protected $projects = null;
136   protected $origprojects = null;
137
138   static function loadById($id) {
139     return new MTrackComponent($id);
140   }
141
142   static function loadByName($name) {
143     $rows = MTrackDB::q('select compid from components where name = ?',
144       $name)->fetchAll(PDO::FETCH_COLUMN, 0);
145     if (isset($rows[0])) {
146       return self::loadById($rows[0]);
147     }
148     return null;
149   }
150
151   function __construct($id = null) {
152     if ($id !== null) {
153       list($row) = MTrackDB::q(
154                     'select name, deleted from components where compid = ?',
155                     $id)->fetchAll();
156       if (isset($row[0])) {
157         $this->compid = $id;
158         $this->name = $row[0];
159         $this->deleted = $row[1];
160         return;
161       }
162       throw new Exception("unable to find component with id = $id");
163     }
164     $this->deleted = false;
165   }
166
167   function getProjects() {
168     if ($this->origprojects === null) {
169       $this->origprojects = array();
170       foreach (MTrackDB::q('select projid from components_by_project where compid = ? order by projid', $this->compid) as $row) {
171         $this->origprojects[] = $row[0];
172       }
173       $this->projects = $this->origprojects;
174     }
175     return $this->projects;
176   }
177
178   function setProjects($projlist) {
179     $this->projects = $projlist;
180   }
181
182   function save(MTrackChangeset $CS) {
183     if ($this->compid) {
184       list($row) = MTrackDB::q(
185                     'select name, deleted from components where compid = ?',
186                     $id)->fetchAll();
187       $old = $row;
188       MTrackDB::q(
189           'update components set name = ?, deleted = ? where compid = ?',
190           $this->name, (int)$this->deleted, $this->compid);
191     } else {
192       MTrackDB::q('insert into components (name, deleted) values (?, ?)',
193         $this->name, (int)$this->deleted);
194       $this->compid = MTrackDB::lastInsertId('components', 'compid');
195       $old = null;
196     }
197     $CS->add("component:" . $this->compid . ":name", $old['name'], $this->name);
198     $CS->add("component:" . $this->compid . ":deleted", $old['deleted'], $this->deleted);
199     if ($this->projects !== $this->origprojects) {
200       $old = is_array($this->origprojects) ?
201               join(",", $this->origprojects) : '';
202       $new = is_array($this->projects) ?
203               join(",", $this->projects) : '';
204       MTrackDB::q('delete from components_by_project where compid = ?',
205           $this->compid);
206       if (is_array($this->projects)) {
207         foreach ($this->projects as $pid) {
208           MTrackDB::q(
209             'insert into components_by_project (compid, projid) values (?, ?)',
210             $this->compid, $pid);
211         }
212       }
213       $CS->add("component:$this->compid:projects", $old, $new);
214     }
215   }
216 }
217
218 class MTrackProject {
219   public $projid = null;
220   public $ordinal = 5;
221   public $name = null;
222   public $shortname = null;
223   public $notifyemail = null;
224
225   static function loadById($id) {
226     return new MTrackProject($id);
227   }
228
229   static function loadByName($name) {
230     list($row) = MTrackDB::q('select projid from projects where shortname = ?',
231       $name)->fetchAll();
232     if (isset($row[0])) {
233       return self::loadById($row[0]);
234     }
235     return null;
236   }
237
238   function __construct($id = null) {
239     if ($id !== null) {
240       list($row) = MTrackDB::q(
241                     'select * from projects where projid = ?',
242                     $id)->fetchAll();
243       if (isset($row[0])) {
244         $this->projid = $row['projid'];
245         $this->ordinal = $row['ordinal'];
246         $this->name = $row['name'];
247         $this->shortname = $row['shortname'];
248         $this->notifyemail = $row['notifyemail'];
249         return;
250       }
251       throw new Exception("unable to find project with id = $id");
252     }
253   }
254
255   function save(MTrackChangeset $CS) {
256     if ($this->projid) {
257       list($row) = MTrackDB::q(
258                     'select * from projects where projid = ?',
259                     $this->projid)->fetchAll();
260       $old = $row;
261       MTrackDB::q(
262           'update projects set ordinal = ?, name = ?, shortname = ?,
263             notifyemail = ? where projid = ?',
264           $this->ordinal, $this->name, $this->shortname,
265           $this->notifyemail, $this->projid);
266     } else {
267       MTrackDB::q('insert into projects (ordinal, name,
268           shortname, notifyemail) values (?, ?, ?, ?)',
269         $this->ordinal, $this->name, $this->shortname,
270         $this->notifyemail);
271       $this->projid = MTrackDB::lastInsertId('projects', 'projid');
272       $old = null;
273     }
274     $CS->add("project:" . $this->projid . ":name", $old['name'], $this->name);
275     $CS->add("project:" . $this->projid . ":ordinal", $old['ordinal'], $this->ordinal);
276     $CS->add("project:" . $this->projid . ":shortname", $old['shortname'], $this->shortname);
277     $CS->add("project:" . $this->projid . ":notifyemail", $old['notifyemail'], $this->notifyemail);
278   }
279
280   function _adjust_ticket_link($M) {
281     $tktlimit = MTrackConfig::get('trac_import', "max_ticket:$this->shortname");
282     if ($M[1] <= $tktlimit) {
283       return "#$this->shortname$M[1]";
284     }
285     return $M[0];
286   }
287
288   function adjust_links($reason, $use_ticket_prefix)
289   {
290     if (!$use_ticket_prefix) {
291       return $reason;
292     }
293
294     $tktlimit = MTrackConfig::get('trac_import', "max_ticket:$this->shortname");
295     if ($tktlimit !== null) {
296       $reason = preg_replace_callback('/#(\d+)/',
297         array($this, '_adjust_ticket_link'), $reason);
298     } else {
299 //      don't do this if the number is outside the valid ranges
300 //      may need to be clever about this during trac imports
301 //      $reason = preg_replace('/#(\d+)/', "#$this->shortname\$1", $reason);
302     }
303 // FIXME: this and the above need to be more intelligent
304     $reason = preg_replace('/\[(\d+)\]/', "[$this->shortname\$1]", $reason);
305     return $reason;
306   }
307 }
308
309 /* The listener protocol is to return true if all is good,
310  * or to return either a string or an array of strings that
311  * detail why a change is not allowed to proceed */
312 interface IMTrackIssueListener {
313   function vetoMilestone(MTrackIssue $issue,
314             MTrackMilestone $ms, $assoc = true);
315   function vetoKeyword(MTrackIssue $issue,
316             MTrackKeyword $kw, $assoc = true);
317   function vetoComponent(MTrackIssue $issue,
318             MTrackComponent $comp, $assoc = true);
319   function vetoProject(MTrackIssue $issue,
320             MTrackProject $proj, $assoc = true);
321   function vetoComment(MTrackIssue $issue, $comment);
322   function vetoSave(MTrackIssue $issue, $oldFields);
323
324   function augmentFormFields(MTrackIssue $issue, &$fieldset);
325   function applyPOSTData(MTrackIssue $issue, $data);
326   function augmentSaveParams(MTrackIssue $issue, &$params);
327   function augmentIndexerFields(MTrackIssue $issue, &$idx);
328 }
329
330 class MTrackVetoException extends Exception {
331   public $reasons;
332
333   function __construct($reasons) {
334     $this->reasons = $reasons;
335     parent::__construct(join("\n", $reasons));
336   }
337 }
338
339 class MTrackIssue {
340   public $tid = null;
341   public $nsident = null;
342   public $summary = null;
343   public $description = null;
344   public $created = null;
345   public $updated = null;
346   public $owner = null;
347   public $priority = null;
348   public $severity = null;
349   public $classification = null;
350   public $resolution = null;
351   public $status = null;
352   public $estimated = null;
353   public $spent = null;
354   public $changelog = null;
355   public $cc = null;
356   protected $components = null;
357   protected $origcomponents = null;
358   protected $milestones = null;
359   protected $origmilestones = null;
360   protected $comments_to_add = array();
361   protected $keywords = null;
362   protected $origkeywords = null;
363   protected $effort = array();
364
365   static $_listeners = array();
366
367   static function loadById($id) {
368     try {
369       return new MTrackIssue($id);
370     } catch (Exception $e) {
371     }
372     return null;
373   }
374
375   static function loadByNSIdent($id) {
376     static $cache = array();
377     if (!isset($cache[$id])) {
378       $ids = MTrackDB::q('select tid from tickets where nsident = ?', $id)
379             ->fetchAll(PDO::FETCH_COLUMN, 0);
380       if (count($ids) == 1) {
381         $cache[$id] = $ids[0];
382       } else {
383         return null;
384       }
385     }
386     return new MTrackIssue($cache[$id]);
387   }
388
389   static function registerListener(IMTrackIssueListener $l)
390   {
391     self::$_listeners[] = $l;
392   }
393
394   function __construct($tid = null) {
395     if ($tid === null) {
396       $this->components = array();
397       $this->origcomponents = array();
398       $this->milestones = array();
399       $this->origmilestones = array();
400       $this->keywords = array();
401       $this->origkeywords = array();
402       $this->status = 'new';
403
404       foreach (array('classification', 'severity', 'priority') as $f) {
405         $this->$f = MTrackConfig::get('ticket', "default.$f");
406       }
407     } else {
408       $data =  MTrackDB::q('select * from tickets where tid = ?',
409                         $tid)->fetchAll();
410       if (isset($data[0])) {
411         $row = $data[0];
412       } else {
413         $row = null;
414       }
415       if (!is_array($row)) {
416         throw new Exception("no such issue $tid");
417       }
418       foreach ($row as $k => $v) {
419         $this->$k = $v;
420       }
421     }
422   }
423
424   function applyPOSTData($data) {
425     foreach (self::$_listeners as $l) {
426       $l->applyPOSTData($this, $data);
427     }
428   }
429
430   function augmentFormFields(&$FIELDSET) {
431     foreach (self::$_listeners as $l) {
432       $l->augmentFormFields($this, $FIELDSET);
433     }
434   }
435   function augmentIndexerFields(&$idx) {
436     foreach (self::$_listeners as $l) {
437       $l->augmentIndexerFields($this, $idx);
438     }
439   }
440   function augmentSaveParams(&$params) {
441     foreach (self::$_listeners as $l) {
442       $l->augmentSaveParams($this, $params);
443     }
444   }
445
446   function checkVeto()
447   {
448     $args = func_get_args();
449     $method = array_shift($args);
450     $veto = array();
451
452     foreach (self::$_listeners as $l) {
453       $v = call_user_func_array(array($l, $method), $args);
454       if ($v !== true) {
455         $veto[] = $v;
456       }
457     }
458     if (count($veto)) {
459       $reasons = array();
460       foreach ($veto as $r) {
461         if (is_array($r)) {
462           foreach ($r as $m) {
463             $reasons[] = $m;
464           }
465         } else {
466           $reasons[] = $r;
467         }
468       }
469       throw new MTrackVetoException($reasons);
470     }
471   }
472
473   function save(MTrackChangeset $CS)
474   {
475     $db = MTrackDB::get();
476     $reindex = false;
477
478     if ($this->tid === null) {
479       $this->created = $CS->cid;
480       $oldrow = array();
481       $reindex = true;
482     } else {
483       list($oldrow) = MTrackDB::q('select * from tickets where tid = ?',
484                         $this->tid)->fetchAll();
485     }
486
487     $this->checkVeto('vetoSave', $this, $oldrow);
488
489     $this->updated = $CS->cid;
490
491     $params = array(
492       'summary' => $this->summary,
493       'description' => $this->description,
494       'created' => $this->created,
495       'updated' => $this->updated,
496       'owner' => $this->owner,
497       'changelog' => $this->changelog,
498       'priority' => $this->priority,
499       'severity' => $this->severity,
500       'classification' => $this->classification,
501       'resolution' => $this->resolution,
502       'status' => $this->status,
503       'estimated' => (float)$this->estimated,
504       'spent' => (float)$this->spent,
505       'nsident' => $this->nsident,
506       'cc' => $this->cc,
507     );
508
509     $this->augmentSaveParams($params);
510
511     if ($this->tid === null) {
512       $sql = 'insert into tickets ';
513       $keys = array();
514       $values = array();
515
516       $new_tid = new OmniTI_Util_UUID;
517       $new_tid = $new_tid->toRFC4122String(false);
518
519       $keys[] = "tid";
520       $values[] = "'$new_tid'";
521
522       foreach ($params as $key => $value) {
523         $keys[] = $key;
524         $values[] = ":$key";
525       }
526
527       $sql .= "(" . join(', ', $keys) . ") values (" .
528               join(', ', $values) . ")";
529     } else {
530       $sql = 'update tickets set ';
531       $values = array();
532       foreach ($params as $key => $value) {
533         $values[] = "$key = :$key";
534       }
535       $sql .= join(', ', $values) . " where tid = :tid";
536
537       $params['tid'] = $this->tid;
538     }
539
540     $q = $db->prepare($sql);
541     $q->execute($params);
542
543     if ($this->tid === null) {
544       $this->tid = $new_tid;
545       $created = true;
546     } else {
547       $created = false;
548     }
549
550     foreach ($params as $key => $value) {
551       if ($key == 'created' || $key == 'updated' || $key == 'tid') {
552         continue;
553       }
554       if ($key == 'changelog' || $key == 'description' || $key == 'summary') {
555         if (!isset($oldrow[$key]) || $oldrow[$key] != $value) {
556           $reindex = true;
557         }
558       }
559       if (!isset($oldrow[$key])) {
560         $oldrow[$key] = null;
561       }
562       $CS->add("ticket:$this->tid:$key", $oldrow[$key], $value);
563     }
564
565     $this->compute_diff($CS, 'components', 'ticket_components', 'compid',
566         $this->components, $this->origcomponents);
567     $this->compute_diff($CS, 'keywords', 'ticket_keywords', 'kid',
568         $this->keywords, $this->origkeywords);
569     $this->compute_diff($CS, 'milestones', 'ticket_milestones', 'mid',
570         $this->milestones, $this->origmilestones);
571
572     foreach ($this->comments_to_add as $text) {
573       $CS->add("ticket:$this->tid:@comment", null, $text);
574     }
575
576     foreach ($this->effort as $effort) {
577       MTrackDB::q('insert into effort (tid, cid, expended, remaining)
578         values (?, ?, ?, ?)',
579         $this->tid, $CS->cid, $effort[0], $effort[1]);
580     }
581     $this->effort = array();
582   }
583
584   static function index_issue($object)
585   {
586     list($ignore, $ident) = explode(':', $object, 2);
587     $i = MTrackIssue::loadById($ident);
588     if (!$i) return;
589     echo "Ticket #$i->nsident\n";
590
591     $CS = MTrackChangeset::get($i->updated);
592     $CSC = MTrackChangeset::get($i->created);
593
594     $kw = join(' ', array_values($i->getKeywords()));
595     $idx = array(
596             'summary' => $i->summary,
597             'description' => $i->description,
598             'changelog' => $i->changelog,
599             'keyword' => $kw,
600             'stored:date' => $CS->when,
601             'who' => $CS->who,
602             'creator' => $CSC->who,
603             'stored:created' => $CSC->when,
604             'owner' => $i->owner
605             );
606     $i->augmentIndexerFields($idx);
607     MTrackSearchDB::add("ticket:$i->tid", $idx, true);
608
609     foreach (MTrackDB::q('select value, changedate, who from
610         change_audit left join changes using (cid) where fieldname = ?',
611         "ticket:$ident:@comment") as $row) {
612       list($text, $when, $who) = $row;
613       $start = time();
614       $id = sha1($text);
615       $elapsed = time() - $start;
616       if ($elapsed > 4) {
617         echo "  - comment $who $when took $elapsed to hash\n";
618       }
619       $start = time();
620       if (strlen($text) > 8192) {
621         // A huge paste into a ticket
622         $text = substr($text, 0, 8192);
623       }
624       MTrackSearchDB::add("ticket:$ident:comment:$id", array(
625         'description' => $text,
626         'stored:date' => $when,
627         'who' => $who,
628       ), true);
629
630       $elapsed = time() - $start;
631       if ($elapsed > 4) {
632         echo "  - comment $who $when took $elapsed to index\n";
633       }
634     }
635   }
636
637   private function compute_diff(MTrackChangeset $CS, $label,
638         $tablename, $keyname, $current, $orig) {
639     if (!is_array($current)) {
640       $current = array();
641     }
642     if (!is_array($orig)) {
643       $orig = array();
644     }
645     $added = array_keys(array_diff_key($current, $orig));
646     $removed = array_keys(array_diff_key($orig, $current));
647
648     $db = MTrackDB::get();
649     $ADD = $db->prepare(
650       "insert into $tablename (tid, $keyname) values (?, ?)");
651     $DEL = $db->prepare(
652       "delete from $tablename where tid = ? AND $keyname = ?");
653     foreach ($added as $key) {
654       $ADD->execute(array($this->tid, $key));
655     }
656     foreach ($removed as $key) {
657       $DEL->execute(array($this->tid, $key));
658     }
659     if (count($added) + count($removed)) {
660       $old = join(',', array_keys($orig));
661       $new = join(',', array_keys($current));
662       $CS->add(
663         "ticket:$this->tid:@$label", $old, $new);
664     }
665   }
666   function getComponents()
667   {
668     if ($this->components === null) {
669       $comps = MTrackDB::q('select tc.compid, name from ticket_components tc left join components using (compid) where tid = ?', $this->tid)->fetchAll();
670       $this->origcomponents = array();
671       foreach ($comps as $row) {
672         $this->origcomponents[$row[0]] = $row[1];
673       }
674       $this->components = $this->origcomponents;
675     }
676     return $this->components;
677   }
678
679   private function resolveComponent($comp)
680   {
681     if ($comp instanceof MTrackComponent) {
682       return $comp;
683     }
684     if (ctype_digit($comp)) {
685       return MTrackComponent::loadById($comp);
686     }
687     return MTrackComponent::loadByName($comp);
688   }
689
690   function assocComponent($comp)
691   {
692     $comp = $this->resolveComponent($comp);
693     $this->getComponents();
694     $this->checkVeto('vetoComponent', $this, $comp, true);
695     $this->components[$comp->compid] = $comp->name;
696   }
697
698   function dissocComponent($comp)
699   {
700     $comp = $this->resolveComponent($comp);
701     $this->getComponents();
702     $this->checkVeto('vetoComponent', $this, $comp, false);
703     unset($this->components[$comp->compid]);
704   }
705
706   function getMilestones()
707   {
708     if ($this->milestones === null) {
709       $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();
710       $this->origmilestones = array();
711       foreach ($comps as $row) {
712         $this->origmilestones[$row[0]] = $row[1];
713       }
714       $this->milestones = $this->origmilestones;
715     }
716     return $this->milestones;
717   }
718
719   private function resolveMilestone($ms)
720   {
721     if ($ms instanceof MTrackMilestone) {
722       return $ms;
723     }
724     if (ctype_digit($ms)) {
725       return MTrackMilestone::loadById($ms);
726     }
727     return MTrackMilestone::loadByName($ms);
728   }
729
730   function assocMilestone($M)
731   {
732     $ms = $this->resolveMilestone($M);
733     if ($ms === null) {
734       throw new Exception("unable to resolve milestone $M");
735     }
736     $this->getMilestones();
737     $this->checkVeto('vetoMilestone', $this, $ms, true);
738     $this->milestones[$ms->mid] = $ms->name;
739   }
740
741   function dissocMilestone($M)
742   {
743     $ms = $this->resolveMilestone($M);
744     if ($ms === null) {
745       throw new Exception("unable to resolve milestone $M");
746     }
747     $this->getMilestones();
748     $this->checkVeto('vetoMilestone', $this, $ms, false);
749     unset($this->milestones[$ms->mid]);
750   }
751
752   function addComment($comment)
753   {
754     $comment = trim($comment);
755     if (strlen($comment)) {
756       $this->checkVeto('vetoComment', $this, $comment);
757       $this->comments_to_add[] = $comment;
758     }
759   }
760
761   private function resolveKeyword($kw)
762   {
763     if ($kw instanceof MTrackKeyword) {
764       return $kw;
765     }
766     $k = MTrackKeyword::loadByWord($kw);
767     if ($k === null) {
768       if (ctype_digit($kw)) {
769         return MTrackKeyword::loadById($kw);
770       }
771       throw new Exception("unknown keyword $kw");
772     }
773     return $k;
774   }
775
776   function assocKeyword($kw)
777   {
778     $kw = $this->resolveKeyword($kw);
779     $this->getKeywords();
780     $this->checkVeto('vetoKeyword', $this, $kw, true);
781     $this->keywords[$kw->kid] = $kw->keyword;
782   }
783
784   function dissocKeyword($kw)
785   {
786     $kw = $this->resolveKeyword($kw);
787     $this->getKeywords();
788     $this->checkVeto('vetoKeyword', $this, $kw, false);
789     unset($this->keywords[$kw->kid]);
790   }
791
792   function getKeywords()
793   {
794     if ($this->keywords === null) {
795       $comps = MTrackDB::q('select tc.kid, keyword from ticket_keywords tc left join keywords using (kid) where tid = ?', $this->tid)->fetchAll();
796       $this->origkeywords = array();
797       foreach ($comps as $row) {
798         $this->origkeywords[$row[0]] = $row[1];
799       }
800       $this->keywords = $this->origkeywords;
801     }
802     return $this->keywords;
803   }
804
805   function addEffort($amount, $revised = null)
806   {
807     $diff = null;
808     if ($revised !== null) {
809       $diff = $revised - $this->estimated;
810       $this->estimated = $revised;
811     }
812     $this->effort[] = array($amount, $diff);
813     $this->spent += $amount;
814   }
815
816   function close()
817   {
818     $this->status = 'closed';
819     $this->addEffort(0, 0);
820   }
821
822   function isOpen()
823   {
824     switch ($this->status) {
825       case 'closed':
826         return false;
827       default:
828         return true;
829     }
830   }
831
832   function reOpen()
833   {
834     $this->status = 'reopened';
835     $this->resolution = null;
836   }
837
838 }
839
840 MTrackSearchDB::register_indexer('ticket', array('MTrackIssue', 'index_issue'));
841 MTrackACL::registerAncestry('enum', 'Enumerations');
842 MTrackACL::registerAncestry("component", 'Components');
843 MTrackACL::registerAncestry("project", 'Projects');
844 MTrackACL::registerAncestry("ticket", "Tickets");
845 MTrackWatch::registerEventTypes('ticket', array(
846   'ticket' => 'Tickets'
847 ));