bug severity verification now works with per-tree severities.
[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.  (name, description) pairs
38 severity_def = (
39   ("wishlist","A feature that could improve usefulness, 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 = ()
62 severity_description = {}
63 severity_index = {}
64 def load_severities(severity_def):
65     global severity_values
66     global severity_description
67     global severity_index
68     severity_values = tuple([val for val,description in severity_def])
69     severity_description = dict(severity_def)
70     severity_index = {}
71     for i,severity in enumerate(severity_values):
72         severity_index[severity] = i
73 load_severities(severity_def)
74
75 active_status_values = tuple(val for val,description in active_status_def)
76 inactive_status_values = tuple(val for val,description in inactive_status_def)
77 status_values = active_status_values + inactive_status_values
78 status_description = dict(active_status_def+inactive_status_def)
79 status_index = {}
80 for i in range(len(status_values)):
81     status_index[status_values[i]] = i
82
83
84 class Bug(settings_object.SavedSettingsObject):
85     """
86     >>> b = Bug()
87     >>> print b.status
88     open
89     >>> print b.severity
90     minor
91
92     There are two formats for time, int and string.  Setting either
93     one will adjust the other appropriately.  The string form is the
94     one stored in the bug's settings file on disk.
95     >>> print type(b.time)
96     <type 'int'>
97     >>> print type(b.time_string)
98     <type 'str'>
99     >>> b.time = 0
100     >>> print b.time_string
101     Thu, 01 Jan 1970 00:00:00 +0000
102     >>> b.time_string="Thu, 01 Jan 1970 00:01:00 +0000"
103     >>> b.time
104     60
105     >>> print b.settings["time"]
106     Thu, 01 Jan 1970 00:01:00 +0000
107     """
108     settings_properties = []
109     required_saved_properties = []
110     _prop_save_settings = settings_object.prop_save_settings
111     _prop_load_settings = settings_object.prop_load_settings
112     def _versioned_property(settings_properties=settings_properties,
113                             required_saved_properties=required_saved_properties,
114                             **kwargs):
115         if "settings_properties" not in kwargs:
116             kwargs["settings_properties"] = settings_properties
117         if "required_saved_properties" not in kwargs:
118             kwargs["required_saved_properties"]=required_saved_properties
119         return settings_object.versioned_property(**kwargs)
120
121     @_versioned_property(name="severity",
122                          doc="A measure of the bug's importance",
123                          default="minor",
124                          check_fn=lambda s: s in severity_values,
125                          require_save=True)
126     def severity(): return {}
127
128     @_versioned_property(name="status",
129                          doc="The bug's current status",
130                          default="open",
131                          allowed=status_values,
132                          require_save=True)
133     def status(): return {}
134     
135     @property
136     def active(self):
137         return self.status in active_status_values
138
139     @_versioned_property(name="target",
140                          doc="The deadline for fixing this bug")
141     def target(): return {}
142
143     @_versioned_property(name="creator",
144                          doc="The user who entered the bug into the system")
145     def creator(): return {}
146
147     @_versioned_property(name="reporter",
148                          doc="The user who reported the bug")
149     def reporter(): return {}
150
151     @_versioned_property(name="assigned",
152                          doc="The developer in charge of the bug")
153     def assigned(): return {}
154
155     @_versioned_property(name="time",
156                          doc="An RFC 2822 timestamp for bug creation")
157     def time_string(): return {}
158
159     def _get_time(self):
160         if self.time_string == None or self.time_string == settings_object.EMPTY:
161             return None
162         return utility.str_to_time(self.time_string)
163     def _set_time(self, value):
164         self.time_string = utility.time_to_str(value)
165     time = property(fget=_get_time,
166                     fset=_set_time,
167                     doc="An integer version of .time_string")
168
169     @_versioned_property(name="summary",
170                          doc="A one-line bug description")
171     def summary(): return {}
172
173     def _get_comment_root(self, load_full=False):
174         if self.sync_with_disk:
175             return comment.loadComments(self, load_full=load_full)
176         else:
177             return comment.Comment(self, uuid=comment.INVALID_UUID)
178
179     @Property
180     @cached_property(generator=_get_comment_root)
181     @local_property("comment_root")
182     @doc_property(doc="The trunk of the comment tree")
183     def comment_root(): return {}
184
185     def _get_rcs(self):
186         if hasattr(self.bugdir, "rcs"):
187             return self.bugdir.rcs
188
189     @Property
190     @cached_property(generator=_get_rcs)
191     @local_property("rcs")
192     @doc_property(doc="A revision control system instance.")
193     def rcs(): return {}
194
195     def __init__(self, bugdir=None, uuid=None, from_disk=False,
196                  load_comments=False, summary=None):
197         settings_object.SavedSettingsObject.__init__(self)
198         self.bugdir = bugdir
199         self.uuid = uuid
200         if from_disk == True:
201             self.sync_with_disk = True
202         else:
203             self.sync_with_disk = False
204             if uuid == None:
205                 self.uuid = uuid_gen()
206             self.time = int(time.time()) # only save to second precision
207             if self.rcs != None:
208                 self.creator = self.rcs.get_user_id()
209             self.summary = summary
210
211     def __repr__(self):
212         return "Bug(uuid=%r)" % self.uuid
213
214     def _setting_attr_string(self, setting):
215         value = getattr(self, setting)
216         if value == settings_object.EMPTY:
217             return ""
218         else:
219             return str(value)
220
221     def string(self, shortlist=False, show_comments=False):
222         if self.bugdir == None:
223             shortname = self.uuid
224         else:
225             shortname = self.bugdir.bug_shortname(self)
226         if shortlist == False:
227             if self.time_string == "":
228                 timestring = self.time_string
229             else:
230                 htime = utility.handy_time(self.time)
231                 timestring = "%s (%s)" % (htime, self.time_string)
232             info = [("ID", self.uuid),
233                     ("Short name", shortname),
234                     ("Severity", self.severity),
235                     ("Status", self.status),
236                     ("Assigned", self._setting_attr_string("assigned")),
237                     ("Target", self._setting_attr_string("target")),
238                     ("Reporter", self._setting_attr_string("reporter")),
239                     ("Creator", self._setting_attr_string("creator")),
240                     ("Created", timestring)]
241             longest_key_len = max([len(k) for k,v in info])
242             infolines = ["  %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
243             bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
244         else:
245             statuschar = self.status[0]
246             severitychar = self.severity[0]
247             chars = "%c%c" % (statuschar, severitychar)
248             bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
249         
250         if show_comments == True:
251             # take advantage of the string_thread(auto_name_map=True)
252             # SIDE-EFFECT of sorting by comment time.
253             comout = self.comment_root.string_thread(flatten=False,
254                                                      auto_name_map=True,
255                                                      bug_shortname=shortname)
256             output = bugout + '\n' + comout.rstrip('\n')
257         else :
258             output = bugout
259         return output
260
261     def __str__(self):
262         return self.string(shortlist=True)
263
264     def __cmp__(self, other):
265         return cmp_full(self, other)
266
267     def get_path(self, name=None):
268         my_dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
269         if name is None:
270             return my_dir
271         assert name in ["values", "comments"]
272         return os.path.join(my_dir, name)
273
274     def load_settings(self):
275         self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
276         self._setup_saved_settings()
277
278     def load_comments(self, load_full=True):
279         if load_full == True:
280             # Force a complete load of the whole comment tree
281             self.comment_root = self._get_comment_root(load_full=True)
282         else:
283             # Setup for fresh lazy-loading.  Clear _comment_root, so
284             # _get_comment_root returns a fresh version.  Turn of
285             # syncing temporarily so we don't write our blank comment
286             # tree to disk.
287             self.sync_with_disk = False
288             self.comment_root = None
289             self.sync_with_disk = True
290
291     def save_settings(self):
292         assert self.summary != None, "Can't save blank bug"
293         
294         self.rcs.mkdir(self.get_path())
295         path = self.get_path("values")
296         mapfile.map_save(self.rcs, path, self._get_saved_settings())
297         
298     def save(self):
299         self.save_settings()
300
301         if len(self.comment_root) > 0:
302             self.rcs.mkdir(self.get_path("comments"))
303             comment.saveComments(self)
304
305     def remove(self):
306         self.comment_root.remove()
307         path = self.get_path()
308         self.rcs.recursive_remove(path)
309     
310     def comments(self):
311         for comment in self.comment_root.traverse():
312             yield comment
313
314     def new_comment(self, body=None):
315         comm = self.comment_root.new_reply(body=body)
316         return comm
317
318     def comment_from_shortname(self, shortname, *args, **kwargs):
319         return self.comment_root.comment_from_shortname(shortname,
320                                                         *args, **kwargs)
321
322     def comment_from_uuid(self, uuid):
323         return self.comment_root.comment_from_uuid(uuid)
324
325     def comment_shortnames(self, shortname=None):
326         """
327         SIDE-EFFECT : Comment.comment_shortnames will sort the comment
328         tree by comment.time
329         """
330         for id, comment in self.comment_root.comment_shortnames(shortname):
331             yield (id, comment)
332
333
334 # The general rule for bug sorting is that "more important" bugs are
335 # less than "less important" bugs.  This way sorting a list of bugs
336 # will put the most important bugs first in the list.  When relative
337 # importance is unclear, the sorting follows some arbitrary convention
338 # (i.e. dictionary order).
339
340 def cmp_severity(bug_1, bug_2):
341     """
342     Compare the severity levels of two bugs, with more severe bugs
343     comparing as less.
344     >>> bugA = Bug()
345     >>> bugB = Bug()
346     >>> bugA.severity = bugB.severity = "wishlist"
347     >>> cmp_severity(bugA, bugB) == 0
348     True
349     >>> bugB.severity = "minor"
350     >>> cmp_severity(bugA, bugB) > 0
351     True
352     >>> bugA.severity = "critical"
353     >>> cmp_severity(bugA, bugB) < 0
354     True
355     """
356     if not hasattr(bug_2, "severity") :
357         return 1
358     return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
359
360 def cmp_status(bug_1, bug_2):
361     """
362     Compare the status levels of two bugs, with more 'open' bugs
363     comparing as less.
364     >>> bugA = Bug()
365     >>> bugB = Bug()
366     >>> bugA.status = bugB.status = "open"
367     >>> cmp_status(bugA, bugB) == 0
368     True
369     >>> bugB.status = "closed"
370     >>> cmp_status(bugA, bugB) < 0
371     True
372     >>> bugA.status = "fixed"
373     >>> cmp_status(bugA, bugB) > 0
374     True
375     """
376     if not hasattr(bug_2, "status") :
377         return 1
378     val_2 = status_index[bug_2.status]
379     return cmp(status_index[bug_1.status], status_index[bug_2.status])
380
381 def cmp_attr(bug_1, bug_2, attr, invert=False):
382     """
383     Compare a general attribute between two bugs using the conventional
384     comparison rule for that attribute type.  If invert == True, sort
385     *against* that convention.
386     >>> attr="severity"
387     >>> bugA = Bug()
388     >>> bugB = Bug()
389     >>> bugA.severity = "critical"
390     >>> bugB.severity = "wishlist"
391     >>> cmp_attr(bugA, bugB, attr) < 0
392     True
393     >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
394     True
395     >>> bugB.severity = "critical"
396     >>> cmp_attr(bugA, bugB, attr) == 0
397     True
398     """
399     if not hasattr(bug_2, attr) :
400         return 1
401     val_1 = getattr(bug_1, attr)
402     val_2 = getattr(bug_2, attr)
403     if val_1 == settings_object.EMPTY: val_1 = None
404     if val_2 == settings_object.EMPTY: val_2 = None
405     
406     if invert == True :
407         return -cmp(val_1, val_2)
408     else :
409         return cmp(val_1, val_2)
410
411 # alphabetical rankings (a < z)
412 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
413 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
414 # chronological rankings (newer < older)
415 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
416
417 def cmp_full(bug_1, bug_2, cmp_list=(cmp_status,cmp_severity,cmp_assigned,
418                                      cmp_time,cmp_creator)):
419     for comparison in cmp_list :
420         val = comparison(bug_1, bug_2)
421         if val != 0 :
422             return val
423     return 0
424
425 class InvalidValue(ValueError):
426     def __init__(self, name, value):
427         msg = "Cannot assign value %s to %s" % (value, name)
428         Exception.__init__(self, msg)
429         self.name = name
430         self.value = value
431
432 suite = doctest.DocTestSuite()