Added 'allow_no_rcs' flag to RCS file system access methods.
[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 import mapfile
25 import comment
26 import utility
27
28
29 ### Define and describe valid bug categories
30 # Use a tuple of (category, description) tuples since we don't have
31 # ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
32
33 # in order of increasing severity
34 severity_level_def = (
35   ("wishlist","A feature that could improve usefullness, but not a bug."),
36   ("minor","The standard bug level."),
37   ("serious","A bug that requires workarounds."),
38   ("critical","A bug that prevents some features from working at all."),
39   ("fatal","A bug that makes the package unusable."))
40
41 # in order of increasing resolution
42 # roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html
43 active_status_def = (
44   ("unconfirmed","A possible bug which lacks independent existance confirmation."),
45   ("open","A working bug that has not been assigned to a developer."),
46   ("assigned","A working bug that has been assigned to a developer."),
47   ("test","The code has been adjusted, but the fix is still being tested."))
48 inactive_status_def = (
49   ("closed", "The bug is no longer relevant."),
50   ("fixed", "The bug should no longer occur."),
51   ("wontfix","It's not a bug, it's a feature."),
52   ("disabled", "?"))
53
54
55 ### Convert the description tuples to more useful formats
56
57 severity_values = tuple([val for val,description in severity_level_def])
58 severity_description = dict(severity_level_def)
59 severity_index = {}
60 for i in range(len(severity_values)):
61     severity_index[severity_values[i]] = i
62
63 active_status_values = tuple(val for val,description in active_status_def)
64 inactive_status_values = tuple(val for val,description in inactive_status_def)
65 status_values = active_status_values + inactive_status_values
66 status_description = dict(active_status_def+inactive_status_def)
67 status_index = {}
68 for i in range(len(status_values)):
69     status_index[status_values[i]] = i
70
71
72 def checked_property(name, valid):
73     """
74     Provide access to an attribute name, testing for valid values.
75     """
76     def getter(self):
77         value = getattr(self, "_"+name)
78         if value not in valid:
79             raise InvalidValue(name, value)
80         return value
81
82     def setter(self, value):
83         if value not in valid:
84             raise InvalidValue(name, value)
85         return setattr(self, "_"+name, value)
86     return property(getter, setter)
87
88
89 class Bug(object):
90     severity = checked_property("severity", severity_values)
91     status = checked_property("status", status_values)
92
93     def _get_active(self):
94         return self.status in active_status_values
95
96     active = property(_get_active)
97
98     def __init__(self, bugdir=None, uuid=None, from_disk=False,
99                  load_comments=False, summary=None):
100         self.bugdir = bugdir
101         if bugdir != None:
102             self.rcs = bugdir.rcs
103         else:
104             self.rcs = None
105         if from_disk == True:
106             self._comments_loaded = False
107             self.uuid = uuid
108             self.load(load_comments=load_comments)
109         else:
110             # Note: defaults should match those in Bug.load()
111             self._comments_loaded = True
112             if uuid != None:
113                 self.uuid = uuid
114             else:
115                 self.uuid = uuid_gen()
116             self.summary = summary
117             if self.rcs != None:
118                 self.creator = self.rcs.get_user_id()
119             else:
120                 self.creator = None
121             self.target = None
122             self.status = "open"
123             self.severity = "minor"
124             self.assigned = None
125             self.time = int(time.time()) # only save to second precision
126             self.comment_root = comment.Comment(self, uuid=comment.INVALID_UUID)
127
128     def __repr__(self):
129         return "Bug(uuid=%r)" % self.uuid
130
131     def string(self, shortlist=False, show_comments=False):
132         if self.bugdir == None:
133             shortname = self.uuid
134         else:
135             shortname = self.bugdir.bug_shortname(self)
136         if shortlist == False:
137             if self.time == None:
138                 timestring = ""
139             else:
140                 htime = utility.handy_time(self.time)
141                 ftime = utility.time_to_str(self.time)
142                 timestring = "%s (%s)" % (htime, ftime)
143             info = [("ID", self.uuid),
144                     ("Short name", shortname),
145                     ("Severity", self.severity),
146                     ("Status", self.status),
147                     ("Assigned", self.assigned),
148                     ("Target", self.target),
149                     ("Creator", self.creator),
150                     ("Created", timestring)]
151             newinfo = []
152             for k,v in info:
153                 if v == None:
154                     newinfo.append((k,""))
155                 else:
156                     newinfo.append((k,v))
157             info = newinfo
158             longest_key_len = max([len(k) for k,v in info])
159             infolines = ["  %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
160             bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
161         else:
162             statuschar = self.status[0]
163             severitychar = self.severity[0]
164             chars = "%c%c" % (statuschar, severitychar)
165             bugout = "%s:%s: %s" % (shortname, chars, self.summary.rstrip('\n'))
166         
167         if show_comments == True:
168             if self._comments_loaded == False:
169                 self.load_comments()
170             comout = self.comment_root.string_thread(auto_name_map=True,
171                                                      bug_shortname=shortname)
172             output = bugout + '\n' + comout.rstrip('\n')
173         else :
174             output = bugout
175         return output
176
177     def __str__(self):
178         return self.string(shortlist=True)
179
180     def __cmp__(self, other):
181         return cmp_full(self, other)
182
183     def get_path(self, name=None):
184         my_dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
185         if name is None:
186             return my_dir
187         assert name in ["values", "comments"]
188         return os.path.join(my_dir, name)
189
190     def load(self, load_comments=False):
191         map = mapfile.map_load(self.rcs, self.get_path("values"))
192         self.summary = map.get("summary")
193         self.creator = map.get("creator")
194         self.target = map.get("target")
195         self.status = map.get("status", "open")
196         self.severity = map.get("severity", "minor")
197         self.assigned = map.get("assigned")
198         self.time = map.get("time")
199         if self.time is not None:
200             self.time = utility.str_to_time(self.time)
201
202         if load_comments == True:
203             self.load_comments()
204
205     def load_comments(self):
206         self.comment_root = comment.loadComments(self)
207         self._comments_loaded = True
208
209     def _add_attr(self, map, name):
210         value = getattr(self, name)
211         if value is not None:
212             map[name] = value
213
214     def save(self):
215         assert self.summary != None, "Can't save blank bug"
216         map = {}
217         self._add_attr(map, "assigned")
218         self._add_attr(map, "summary")
219         self._add_attr(map, "creator")
220         self._add_attr(map, "target")
221         self._add_attr(map, "status")
222         self._add_attr(map, "severity")
223         if self.time is not None:
224             map["time"] = utility.time_to_str(self.time)
225
226         self.rcs.mkdir(self.get_path())
227         path = self.get_path("values")
228         mapfile.map_save(self.rcs, path, map)
229
230         if self._comments_loaded:
231             if len(self.comment_root) > 0:
232                 self.rcs.mkdir(self.get_path("comments"))
233                 comment.saveComments(self)
234
235     def remove(self):
236         self.load_comments()
237         self.comment_root.remove()
238         path = self.get_path()
239         self.rcs.recursive_remove(path)
240     
241     def new_comment(self, body=None):
242         comm = comment.comment_root.new_reply(body=body)
243         return comm
244
245     def comment_from_shortname(self, shortname, *args, **kwargs):
246         return self.comment_root.comment_from_shortname(shortname, *args, **kwargs)
247
248     def comment_from_uuid(self, uuid):
249         return self.comment_root.comment_from_uuid(uuid)
250
251
252 # the general rule for bug sorting is that "more important" bugs are
253 # less than "less important" bugs.  This way sorting a list of bugs
254 # will put the most important bugs first in the list.  When relative
255 # importance is unclear, the sorting follows some arbitrary convention
256 # (i.e. dictionary order).
257
258 def cmp_severity(bug_1, bug_2):
259     """
260     Compare the severity levels of two bugs, with more severe bugs
261     comparing as less.
262     >>> bugA = Bug()
263     >>> bugB = Bug()
264     >>> bugA.severity = bugB.severity = "wishlist"
265     >>> cmp_severity(bugA, bugB) == 0
266     True
267     >>> bugB.severity = "minor"
268     >>> cmp_severity(bugA, bugB) > 0
269     True
270     >>> bugA.severity = "critical"
271     >>> cmp_severity(bugA, bugB) < 0
272     True
273     """
274     if not hasattr(bug_2, "severity") :
275         return 1
276     return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
277
278 def cmp_status(bug_1, bug_2):
279     """
280     Compare the status levels of two bugs, with more 'open' bugs
281     comparing as less.
282     >>> bugA = Bug()
283     >>> bugB = Bug()
284     >>> bugA.status = bugB.status = "open"
285     >>> cmp_status(bugA, bugB) == 0
286     True
287     >>> bugB.status = "closed"
288     >>> cmp_status(bugA, bugB) < 0
289     True
290     >>> bugA.status = "fixed"
291     >>> cmp_status(bugA, bugB) > 0
292     True
293     """
294     if not hasattr(bug_2, "status") :
295         return 1
296     val_2 = status_index[bug_2.status]
297     return cmp(status_index[bug_1.status], status_index[bug_2.status])
298
299 def cmp_attr(bug_1, bug_2, attr, invert=False):
300     """
301     Compare a general attribute between two bugs using the conventional
302     comparison rule for that attribute type.  If invert == True, sort
303     *against* that convention.
304     >>> attr="severity"
305     >>> bugA = Bug()
306     >>> bugB = Bug()
307     >>> bugA.severity = "critical"
308     >>> bugB.severity = "wishlist"
309     >>> cmp_attr(bugA, bugB, attr) < 0
310     True
311     >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
312     True
313     >>> bugB.severity = "critical"
314     >>> cmp_attr(bugA, bugB, attr) == 0
315     True
316     """
317     if not hasattr(bug_2, attr) :
318         return 1
319     if invert == True :
320         return -cmp(getattr(bug_1, attr), getattr(bug_2, attr))
321     else :
322         return cmp(getattr(bug_1, attr), getattr(bug_2, attr))
323
324 # alphabetical rankings (a < z)
325 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
326 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
327 # chronological rankings (newer < older)
328 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
329
330 def cmp_full(bug_1, bug_2, cmp_list=(cmp_status,cmp_severity,cmp_assigned,
331                                      cmp_time,cmp_creator)):
332     for comparison in cmp_list :
333         val = comparison(bug_1, bug_2)
334         if val != 0 :
335             return val
336     return 0
337
338 class InvalidValue(ValueError):
339     def __init__(self, name, value):
340         msg = "Cannot assign value %s to %s" % (value, name)
341         Exception.__init__(self, msg)
342         self.name = name
343         self.value = value
344
345 suite = doctest.DocTestSuite()