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