Added support for bug reporter field.
[be.git] / libbe / bug.py
1 # Copyright (C) 2005 Aaron Bentley and Panometrics, Inc.
2 # <abentley@panoramicfeedback.com>
3 #
4 #    This program is free software; you can redistribute it and/or modify
5 #    it under the terms of the GNU General Public License as published by
6 #    the Free Software Foundation; either version 2 of the License, or
7 #    (at your option) any later version.
8 #
9 #    This program is distributed in the hope that it will be useful,
10 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
11 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 #    GNU General Public License for more details.
13 #
14 #    You should have received a copy of the GNU General Public License
15 #    along with this program; if not, write to the Free Software
16 #    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
17 import os
18 import os.path
19 import errno
20 import time
21 import doctest
22
23 from beuuid import uuid_gen
24 from properties import Property, doc_property, local_property, \
25     defaulting_property, checked_property, cached_property, \
26     primed_property, change_hook_property, settings_property
27 import settings_object
28 import mapfile
29 import comment
30 import utility
31
32
33 ### Define and describe valid bug categories
34 # Use a tuple of (category, description) tuples since we don't have
35 # ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
36
37 # in order of increasing severity
38 severity_level_def = (
39   ("wishlist","A feature that could improve usefullness, but not a bug."),
40   ("minor","The standard bug level."),
41   ("serious","A bug that requires workarounds."),
42   ("critical","A bug that prevents some features from working at all."),
43   ("fatal","A bug that makes the package unusable."))
44
45 # in order of increasing resolution
46 # roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html
47 active_status_def = (
48   ("unconfirmed","A possible bug which lacks independent existance confirmation."),
49   ("open","A working bug that has not been assigned to a developer."),
50   ("assigned","A working bug that has been assigned to a developer."),
51   ("test","The code has been adjusted, but the fix is still being tested."))
52 inactive_status_def = (
53   ("closed", "The bug is no longer relevant."),
54   ("fixed", "The bug should no longer occur."),
55   ("wontfix","It's not a bug, it's a feature."),
56   ("disabled", "?"))
57
58
59 ### Convert the description tuples to more useful formats
60
61 severity_values = tuple([val for val,description in severity_level_def])
62 severity_description = dict(severity_level_def)
63 severity_index = {}
64 for i in range(len(severity_values)):
65     severity_index[severity_values[i]] = i
66
67 active_status_values = tuple(val for val,description in active_status_def)
68 inactive_status_values = tuple(val for val,description in inactive_status_def)
69 status_values = active_status_values + inactive_status_values
70 status_description = dict(active_status_def+inactive_status_def)
71 status_index = {}
72 for i in range(len(status_values)):
73     status_index[status_values[i]] = i
74
75
76 class Bug(settings_object.SavedSettingsObject):
77     """
78     >>> b = Bug()
79     >>> print b.status
80     open
81     >>> print b.severity
82     minor
83
84     There are two formats for time, int and string.  Setting either
85     one will adjust the other appropriately.  The string form is the
86     one stored in the bug's settings file on disk.
87     >>> print type(b.time)
88     <type 'int'>
89     >>> print type(b.time_string)
90     <type 'str'>
91     >>> b.time = 0
92     >>> print b.time_string
93     Thu, 01 Jan 1970 00:00:00 +0000
94     >>> b.time_string="Thu, 01 Jan 1970 00:01:00 +0000"
95     >>> b.time
96     60
97     >>> print b.settings["time"]
98     Thu, 01 Jan 1970 00:01:00 +0000
99     """
100     settings_properties = []
101     required_saved_properties = []
102     _prop_save_settings = settings_object.prop_save_settings
103     _prop_load_settings = settings_object.prop_load_settings
104     def _versioned_property(settings_properties=settings_properties,
105                             required_saved_properties=required_saved_properties,
106                             **kwargs):
107         if "settings_properties" not in kwargs:
108             kwargs["settings_properties"] = settings_properties
109         if "required_saved_properties" not in kwargs:
110             kwargs["required_saved_properties"]=required_saved_properties
111         return settings_object.versioned_property(**kwargs)
112
113     @_versioned_property(name="severity",
114                          doc="A measure of the bug's importance",
115                          default="minor",
116                          allowed=severity_values,
117                          require_save=True)
118     def severity(): return {}
119
120     @_versioned_property(name="status",
121                          doc="The bug's current status",
122                          default="open",
123                          allowed=status_values,
124                          require_save=True)
125     def status(): return {}
126     
127     @property
128     def active(self):
129         return self.status in active_status_values
130
131     @_versioned_property(name="target",
132                          doc="The deadline for fixing this bug")
133     def target(): return {}
134
135     @_versioned_property(name="creator",
136                          doc="The user who entered the bug into the system")
137     def creator(): return {}
138
139     @_versioned_property(name="reporter",
140                          doc="The user who reported the bug")
141     def reporter(): return {}
142
143     @_versioned_property(name="assigned",
144                          doc="The developer in charge of the bug")
145     def assigned(): return {}
146
147     @_versioned_property(name="time",
148                          doc="An RFC 2822 timestamp for bug creation")
149     def time_string(): return {}
150
151     def _get_time(self):
152         if self.time_string == None or self.time_string == settings_object.EMPTY:
153             return None
154         return utility.str_to_time(self.time_string)
155     def _set_time(self, value):
156         self.time_string = utility.time_to_str(value)
157     time = property(fget=_get_time,
158                     fset=_set_time,
159                     doc="An integer version of .time_string")
160
161     @_versioned_property(name="summary",
162                          doc="A one-line bug description")
163     def summary(): return {}
164
165     def _get_comment_root(self, load_full=False):
166         if self.sync_with_disk:
167             return comment.loadComments(self, load_full=load_full)
168         else:
169             return comment.Comment(self, uuid=comment.INVALID_UUID)
170
171     @Property
172     @cached_property(generator=_get_comment_root)
173     @local_property("comment_root")
174     @doc_property(doc="The trunk of the comment tree")
175     def comment_root(): return {}
176
177     def _get_rcs(self):
178         if hasattr(self.bugdir, "rcs"):
179             return self.bugdir.rcs
180
181     @Property
182     @cached_property(generator=_get_rcs)
183     @local_property("rcs")
184     @doc_property(doc="A revision control system instance.")
185     def rcs(): return {}
186
187     def __init__(self, bugdir=None, uuid=None, from_disk=False,
188                  load_comments=False, summary=None):
189         settings_object.SavedSettingsObject.__init__(self)
190         self.bugdir = bugdir
191         self.uuid = uuid
192         if from_disk == True:
193             self.sync_with_disk = True
194         else:
195             self.sync_with_disk = False
196             if uuid == None:
197                 self.uuid = uuid_gen()
198             self.time = int(time.time()) # only save to second precision
199             if self.rcs != None:
200                 self.creator = self.rcs.get_user_id()
201             self.summary = summary
202
203     def __repr__(self):
204         return "Bug(uuid=%r)" % self.uuid
205
206     def _setting_attr_string(self, setting):
207         value = getattr(self, setting)
208         if value == settings_object.EMPTY:
209             return ""
210         else:
211             return str(value)
212
213     def string(self, shortlist=False, show_comments=False):
214         if self.bugdir == None:
215             shortname = self.uuid
216         else:
217             shortname = self.bugdir.bug_shortname(self)
218         if shortlist == False:
219             if self.time_string == "":
220                 timestring = self.time_string
221             else:
222                 htime = utility.handy_time(self.time)
223                 timestring = "%s (%s)" % (htime, self.time_string)
224             info = [("ID", self.uuid),
225                     ("Short name", shortname),
226                     ("Severity", self.severity),
227                     ("Status", self.status),
228                     ("Assigned", self._setting_attr_string("assigned")),
229                     ("Target", self._setting_attr_string("target")),
230                     ("Reporter", self._setting_attr_string("reporter")),
231                     ("Creator", self._setting_attr_string("creator")),
232                     ("Created", timestring)]
233             longest_key_len = max([len(k) for k,v in info])
234             infolines = ["  %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
235             bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
236         else:
237             statuschar = self.status[0]
238             severitychar = self.severity[0]
239             chars = "%c%c" % (statuschar, severitychar)
240             bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
241         
242         if show_comments == True:
243             # take advantage of the string_thread(auto_name_map=True)
244             # SIDE-EFFECT of sorting by comment time.
245             comout = self.comment_root.string_thread(flatten=False,
246                                                      auto_name_map=True,
247                                                      bug_shortname=shortname)
248             output = bugout + '\n' + comout.rstrip('\n')
249         else :
250             output = bugout
251         return output
252
253     def __str__(self):
254         return self.string(shortlist=True)
255
256     def __cmp__(self, other):
257         return cmp_full(self, other)
258
259     def get_path(self, name=None):
260         my_dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
261         if name is None:
262             return my_dir
263         assert name in ["values", "comments"]
264         return os.path.join(my_dir, name)
265
266     def load_settings(self):
267         self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
268         self._setup_saved_settings()
269
270     def load_comments(self, load_full=True):
271         if load_full == True:
272             # Force a complete load of the whole comment tree
273             self.comment_root = self._get_comment_root(load_full=True)
274         else:
275             # Setup for fresh lazy-loading.  Clear _comment_root, so
276             # _get_comment_root returns a fresh version.  Turn of
277             # syncing temporarily so we don't write our blank comment
278             # tree to disk.
279             self.sync_with_disk = False
280             self.comment_root = None
281             self.sync_with_disk = True
282
283     def save_settings(self):
284         assert self.summary != None, "Can't save blank bug"
285         
286         self.rcs.mkdir(self.get_path())
287         path = self.get_path("values")
288         mapfile.map_save(self.rcs, path, self._get_saved_settings())
289         
290     def save(self):
291         self.save_settings()
292
293         if len(self.comment_root) > 0:
294             self.rcs.mkdir(self.get_path("comments"))
295             comment.saveComments(self)
296
297     def remove(self):
298         self.comment_root.remove()
299         path = self.get_path()
300         self.rcs.recursive_remove(path)
301     
302     def comments(self):
303         for comment in self.comment_root.traverse():
304             yield comment
305
306     def new_comment(self, body=None):
307         comm = self.comment_root.new_reply(body=body)
308         return comm
309
310     def comment_from_shortname(self, shortname, *args, **kwargs):
311         return self.comment_root.comment_from_shortname(shortname,
312                                                         *args, **kwargs)
313
314     def comment_from_uuid(self, uuid):
315         return self.comment_root.comment_from_uuid(uuid)
316
317     def comment_shortnames(self, shortname=None):
318         """
319         SIDE-EFFECT : Comment.comment_shortnames will sort the comment
320         tree by comment.time
321         """
322         for id, comment in self.comment_root.comment_shortnames(shortname):
323             yield (id, comment)
324
325
326 # The general rule for bug sorting is that "more important" bugs are
327 # less than "less important" bugs.  This way sorting a list of bugs
328 # will put the most important bugs first in the list.  When relative
329 # importance is unclear, the sorting follows some arbitrary convention
330 # (i.e. dictionary order).
331
332 def cmp_severity(bug_1, bug_2):
333     """
334     Compare the severity levels of two bugs, with more severe bugs
335     comparing as less.
336     >>> bugA = Bug()
337     >>> bugB = Bug()
338     >>> bugA.severity = bugB.severity = "wishlist"
339     >>> cmp_severity(bugA, bugB) == 0
340     True
341     >>> bugB.severity = "minor"
342     >>> cmp_severity(bugA, bugB) > 0
343     True
344     >>> bugA.severity = "critical"
345     >>> cmp_severity(bugA, bugB) < 0
346     True
347     """
348     if not hasattr(bug_2, "severity") :
349         return 1
350     return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
351
352 def cmp_status(bug_1, bug_2):
353     """
354     Compare the status levels of two bugs, with more 'open' bugs
355     comparing as less.
356     >>> bugA = Bug()
357     >>> bugB = Bug()
358     >>> bugA.status = bugB.status = "open"
359     >>> cmp_status(bugA, bugB) == 0
360     True
361     >>> bugB.status = "closed"
362     >>> cmp_status(bugA, bugB) < 0
363     True
364     >>> bugA.status = "fixed"
365     >>> cmp_status(bugA, bugB) > 0
366     True
367     """
368     if not hasattr(bug_2, "status") :
369         return 1
370     val_2 = status_index[bug_2.status]
371     return cmp(status_index[bug_1.status], status_index[bug_2.status])
372
373 def cmp_attr(bug_1, bug_2, attr, invert=False):
374     """
375     Compare a general attribute between two bugs using the conventional
376     comparison rule for that attribute type.  If invert == True, sort
377     *against* that convention.
378     >>> attr="severity"
379     >>> bugA = Bug()
380     >>> bugB = Bug()
381     >>> bugA.severity = "critical"
382     >>> bugB.severity = "wishlist"
383     >>> cmp_attr(bugA, bugB, attr) < 0
384     True
385     >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
386     True
387     >>> bugB.severity = "critical"
388     >>> cmp_attr(bugA, bugB, attr) == 0
389     True
390     """
391     if not hasattr(bug_2, attr) :
392         return 1
393     val_1 = getattr(bug_1, attr)
394     val_2 = getattr(bug_2, attr)
395     if val_1 == settings_object.EMPTY: val_1 = None
396     if val_2 == settings_object.EMPTY: val_2 = None
397     
398     if invert == True :
399         return -cmp(val_1, val_2)
400     else :
401         return cmp(val_1, val_2)
402
403 # alphabetical rankings (a < z)
404 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
405 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
406 # chronological rankings (newer < older)
407 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
408
409 def cmp_full(bug_1, bug_2, cmp_list=(cmp_status,cmp_severity,cmp_assigned,
410                                      cmp_time,cmp_creator)):
411     for comparison in cmp_list :
412         val = comparison(bug_1, bug_2)
413         if val != 0 :
414             return val
415     return 0
416
417 class InvalidValue(ValueError):
418     def __init__(self, name, value):
419         msg = "Cannot assign value %s to %s" % (value, name)
420         Exception.__init__(self, msg)
421         self.name = name
422         self.value = value
423
424 suite = doctest.DocTestSuite()