Merged be.diff-subscribe
[be.git] / libbe / diff.py
1 # Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
2 #                         Gianluca Montecchi <gian@grys.it>
3 #                         W. Trevor King <wking@drexel.edu>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License along
16 # with this program; if not, write to the Free Software Foundation, Inc.,
17 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
18
19 """Compare two bug trees."""
20
21 import difflib
22 import types
23
24 import libbe
25 from libbe import bugdir, bug, settings_object, tree
26 from libbe.utility import time_to_str
27 if libbe.TESTING == True:
28     import doctest
29
30
31 class SubscriptionType (tree.Tree):
32     """
33     Trees of subscription types to allow users to select exactly what
34     notifications they want to subscribe to.
35     """
36     def __init__(self, type_name, *args, **kwargs):
37         tree.Tree.__init__(self, *args, **kwargs)
38         self.type = type_name
39     def __str__(self):
40         return self.type
41     def __cmp__(self, other):
42         return cmp(self.type, other.type)
43     def __repr__(self):
44         return "<SubscriptionType: %s>" % str(self)
45     def string_tree(self, indent=0):
46         lines = []
47         for depth,node in self.thread():
48             lines.append("%s%s" % (" "*(indent+2*depth), node))
49         return "\n".join(lines)
50
51 BUGDIR_ID = "DIR"
52 BUGDIR_TYPE_NEW = SubscriptionType("new")
53 BUGDIR_TYPE_MOD = SubscriptionType("mod")
54 BUGDIR_TYPE_REM = SubscriptionType("rem")
55 BUGDIR_TYPE_ALL = SubscriptionType("all",
56                       [BUGDIR_TYPE_NEW, BUGDIR_TYPE_MOD, BUGDIR_TYPE_REM])
57
58 # same name as BUGDIR_TYPE_ALL for consistency
59 BUG_TYPE_ALL = SubscriptionType(str(BUGDIR_TYPE_ALL))
60
61 INVALID_TYPE = SubscriptionType("INVALID")
62
63 class InvalidType (ValueError):
64     def __init__(self, type_name, type_root):
65         msg = "Invalid type %s for tree:\n%s" \
66             % (type_name, type_root.string_tree(4))
67         ValueError.__init__(self, msg)
68         self.type_name = type_name
69         self.type_root = type_root
70
71 def type_from_name(name, type_root, default=None, default_ok=False):
72     if name == str(type_root):
73         return type_root
74     for t in type_root.traverse():
75         if name == str(t):
76             return t
77     if default_ok:
78         return default
79     raise InvalidType(name, type_root)
80
81 class Subscription (object):
82     """
83     >>> subscriptions = [Subscription('XYZ', 'all'),
84     ...                  Subscription('DIR', 'new'),
85     ...                  Subscription('ABC', BUG_TYPE_ALL),]
86     >>> print sorted(subscriptions)
87     [<Subscription: DIR (new)>, <Subscription: ABC (all)>, <Subscription: XYZ (all)>]
88     """
89     def __init__(self, id, subscription_type, **kwargs):
90         if 'type_root' not in kwargs:
91             if id == BUGDIR_ID:
92                 kwargs['type_root'] = BUGDIR_TYPE_ALL 
93             else:
94                 kwargs['type_root'] = BUG_TYPE_ALL 
95         if type(subscription_type) in types.StringTypes:
96             subscription_type = type_from_name(subscription_type, **kwargs)
97         self.id = id
98         self.type = subscription_type
99     def __cmp__(self, other):
100         for attr in 'id', 'type':
101             value = cmp(getattr(self, attr), getattr(other, attr))
102             if value != 0:
103                 if self.id == BUGDIR_ID:
104                     return -1
105                 elif other.id == BUGDIR_ID:
106                     return 1
107                 return value
108     def __str__(self):
109         return str(self.type)
110     def __repr__(self):
111         return "<Subscription: %s (%s)>" % (self.id, self.type)
112
113 def subscriptions_from_string(string=None, subscription_sep=',', id_sep=':'):
114     """
115     >>> subscriptions_from_string(None)
116     [<Subscription: DIR (all)>]
117     >>> subscriptions_from_string('DIR:new,DIR:rem,ABC:all,XYZ:all')
118     [<Subscription: DIR (new)>, <Subscription: DIR (rem)>, <Subscription: ABC (all)>, <Subscription: XYZ (all)>]
119     >>> subscriptions_from_string('DIR::new')
120     Traceback (most recent call last):
121       ...
122     ValueError: Invalid subscription "DIR::new", should be ID:TYPE
123     """
124     if string == None:
125         return [Subscription(BUGDIR_ID, BUGDIR_TYPE_ALL)]
126     subscriptions = []
127     for subscription in string.split(','):
128         fields = subscription.split(':')
129         if len(fields) != 2:
130             raise ValueError('Invalid subscription "%s", should be ID:TYPE'
131                              % subscription)
132         id,type = fields
133         subscriptions.append(Subscription(id, type))
134     return subscriptions
135
136 class DiffTree (tree.Tree):
137     """
138     A tree holding difference data for easy report generation.
139     >>> bugdir = DiffTree("bugdir")
140     >>> bdsettings = DiffTree("settings", data="target: None -> 1.0")
141     >>> bugdir.append(bdsettings)
142     >>> bugs = DiffTree("bugs", "bug-count: 5 -> 6")
143     >>> bugdir.append(bugs)
144     >>> new = DiffTree("new", "new bugs: ABC, DEF")
145     >>> bugs.append(new)
146     >>> rem = DiffTree("rem", "removed bugs: RST, UVW")
147     >>> bugs.append(rem)
148     >>> print bugdir.report_string()
149     target: None -> 1.0
150     bug-count: 5 -> 6
151       new bugs: ABC, DEF
152       removed bugs: RST, UVW
153     >>> print "\\n".join(bugdir.paths())
154     bugdir
155     bugdir/settings
156     bugdir/bugs
157     bugdir/bugs/new
158     bugdir/bugs/rem
159     >>> bugdir.child_by_path("/") == bugdir
160     True
161     >>> bugdir.child_by_path("/bugs") == bugs
162     True
163     >>> bugdir.child_by_path("/bugs/rem") == rem
164     True
165     >>> bugdir.child_by_path("bugdir") == bugdir
166     True
167     >>> bugdir.child_by_path("bugdir/") == bugdir
168     True
169     >>> bugdir.child_by_path("bugdir/bugs") == bugs
170     True
171     >>> bugdir.child_by_path("/bugs").masked = True
172     >>> print bugdir.report_string()
173     target: None -> 1.0
174     """
175     def __init__(self, name, data=None, data_part_fn=str,
176                  requires_children=False, masked=False):
177         tree.Tree.__init__(self)
178         self.name = name
179         self.data = data
180         self.data_part_fn = data_part_fn
181         self.requires_children = requires_children
182         self.masked = masked
183     def paths(self, parent_path=None):
184         paths = []
185         if parent_path == None:
186             path = self.name
187         else:
188             path = "%s/%s" % (parent_path, self.name)
189         paths.append(path)
190         for child in self:
191             paths.extend(child.paths(path))
192         return paths
193     def child_by_path(self, path):
194         if hasattr(path, "split"): # convert string path to a list of names
195             names = path.split("/")
196             if names[0] == "":
197                 names[0] = self.name # replace root with self
198             if len(names) > 1 and names[-1] == "":
199                 names = names[:-1] # strip empty tail
200         else: # it was already an array
201             names = path
202         assert len(names) > 0, path
203         if names[0] == self.name:
204             if len(names) == 1:
205                 return self
206             for child in self:
207                 if names[1] == child.name:
208                     return child.child_by_path(names[1:])
209         if len(names) == 1:
210             raise KeyError, "%s doesn't match '%s'" % (names, self.name)
211         raise KeyError, "%s points to child not in %s" % (names, [c.name for c in self])
212     def report_string(self):
213         report = self.report()
214         if report == None:
215             return ''
216         return '\n'.join(report)
217     def report(self, root=None, parent=None, depth=0):
218         if root == None:
219             root = self.make_root()
220         if self.masked == True:
221             return root
222         data_part = self.data_part(depth)
223         if self.requires_children == True \
224                 and len([c for c in self if c.masked == False]) == 0:
225             pass
226         else:
227             self.join(root, parent, data_part)
228             if data_part != None:
229                 depth += 1
230             for child in self:
231                 root = child.report(root, self, depth)
232         return root
233     def make_root(self):
234         return []
235     def join(self, root, parent, data_part):
236         if data_part != None:
237             root.append(data_part)
238     def data_part(self, depth, indent=True):
239         if self.data == None:
240             return None
241         if hasattr(self, "_cached_data_part"):
242             return self._cached_data_part
243         data_part = self.data_part_fn(self.data)
244         if indent == True:
245             data_part_lines = data_part.splitlines()
246             indent = "  "*(depth)
247             line_sep = "\n"+indent
248             data_part = indent+line_sep.join(data_part_lines)
249         self._cached_data_part = data_part
250         return data_part
251
252 class Diff (object):
253     """
254     Difference tree generator for BugDirs.
255     >>> import copy
256     >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
257     >>> bd.user_id = "John Doe <j@doe.com>"
258     >>> bd_new = copy.deepcopy(bd)
259     >>> bd_new.target = "1.0"
260     >>> a = bd_new.bug_from_uuid("a")
261     >>> rep = a.comment_root.new_reply("I'm closing this bug")
262     >>> rep.uuid = "acom"
263     >>> rep.date = "Thu, 01 Jan 1970 00:00:00 +0000"
264     >>> a.status = "closed"
265     >>> b = bd_new.bug_from_uuid("b")
266     >>> bd_new.remove_bug(b)
267     >>> c = bd_new.new_bug("c", "Bug C")
268     >>> d = Diff(bd, bd_new)
269     >>> r = d.report_tree()
270     >>> print "\\n".join(r.paths())
271     bugdir
272     bugdir/settings
273     bugdir/bugs
274     bugdir/bugs/new
275     bugdir/bugs/new/c
276     bugdir/bugs/rem
277     bugdir/bugs/rem/b
278     bugdir/bugs/mod
279     bugdir/bugs/mod/a
280     bugdir/bugs/mod/a/settings
281     bugdir/bugs/mod/a/comments
282     bugdir/bugs/mod/a/comments/new
283     bugdir/bugs/mod/a/comments/new/acom
284     bugdir/bugs/mod/a/comments/rem
285     bugdir/bugs/mod/a/comments/mod
286     >>> print r.report_string()
287     Changed bug directory settings:
288       target: None -> 1.0
289     New bugs:
290       c:om: Bug C
291     Removed bugs:
292       b:cm: Bug B
293     Modified bugs:
294       a:cm: Bug A
295         Changed bug settings:
296           status: open -> closed
297         New comments:
298           from John Doe <j@doe.com> on Thu, 01 Jan 1970 00:00:00 +0000
299             I'm closing this bug...
300
301     You can also limit the report generation by providing a list of
302     subscriptions.
303
304     >>> subscriptions = [Subscription('DIR', BUGDIR_TYPE_NEW),
305     ...                  Subscription('b', BUG_TYPE_ALL)]
306     >>> r = d.report_tree(subscriptions)
307     >>> print r.report_string()
308     New bugs:
309       c:om: Bug C
310     Removed bugs:
311       b:cm: Bug B
312
313     While sending subscriptions to report_tree() makes the report
314     generation more efficient (because you may not need to compare
315     _all_ the bugs, etc.), sometimes you will have several sets of
316     subscriptions.  In that case, it's better to run full_report()
317     first, and then use report_tree() to avoid redundant comparisons.
318
319     >>> d.full_report()
320     >>> print d.report_tree([subscriptions[0]]).report_string()
321     New bugs:
322       c:om: Bug C
323     >>> print d.report_tree([subscriptions[1]]).report_string()
324     Removed bugs:
325       b:cm: Bug B
326
327     >>> bd.cleanup()
328     """
329     def __init__(self, old_bugdir, new_bugdir):
330         self.old_bugdir = old_bugdir
331         self.new_bugdir = new_bugdir
332
333     # data assembly methods
334
335     def _changed_bugs(self, subscriptions):
336         """
337         Search for differences in all bugs between .old_bugdir and
338         .new_bugdir.  Returns
339           (added_bugs, modified_bugs, removed_bugs)
340         where added_bugs and removed_bugs are lists of added and
341         removed bugs respectively.  modified_bugs is a list of
342         (old_bug,new_bug) pairs.
343         """
344         bugdir_types = [s.type for s in subscriptions if s.id == BUGDIR_ID]
345         new_uuids = []
346         old_uuids = []
347         for bd_type in [BUGDIR_TYPE_ALL, BUGDIR_TYPE_NEW, BUGDIR_TYPE_MOD]:
348             if bd_type in bugdir_types:
349                 new_uuids = list(self.new_bugdir.uuids())
350                 break
351         for bd_type in [BUGDIR_TYPE_ALL, BUGDIR_TYPE_REM]:
352             if bd_type in bugdir_types:
353                 old_uuids = list(self.old_bugdir.uuids())
354                 break
355         subscribed_bugs = [s.id for s in subscriptions
356                            if BUG_TYPE_ALL.has_descendant( \
357                                      s.type, match_self=True)]
358         new_uuids.extend([s for s in subscribed_bugs
359                           if self.new_bugdir.has_bug(s)])
360         new_uuids = sorted(set(new_uuids))
361         old_uuids.extend([s for s in subscribed_bugs
362                           if self.old_bugdir.has_bug(s)])
363         old_uuids = sorted(set(old_uuids))
364         added = []
365         removed = []
366         modified = []
367         for uuid in new_uuids:
368             new_bug = self.new_bugdir.bug_from_uuid(uuid)
369             try:
370                 old_bug = self.old_bugdir.bug_from_uuid(uuid)
371             except KeyError:
372                 if BUGDIR_TYPE_ALL in bugdir_types \
373                         or BUGDIR_TYPE_NEW in bugdir_types \
374                         or uuid in subscribed_bugs:
375                     added.append(new_bug)
376                 continue
377             if BUGDIR_TYPE_ALL in bugdir_types \
378                     or BUGDIR_TYPE_MOD in bugdir_types \
379                     or uuid in subscribed_bugs:
380                 if old_bug.sync_with_disk == True:
381                     old_bug.load_comments()
382                 if new_bug.sync_with_disk == True:
383                     new_bug.load_comments()
384                 if old_bug != new_bug:
385                     modified.append((old_bug, new_bug))
386         for uuid in old_uuids:
387             if not self.new_bugdir.has_bug(uuid):
388                 old_bug = self.old_bugdir.bug_from_uuid(uuid)
389                 removed.append(old_bug)
390         added.sort()
391         removed.sort()
392         modified.sort(self._bug_modified_cmp)
393         return (added, modified, removed)
394     def _bug_modified_cmp(self, left, right):
395         return cmp(left[1], right[1])
396     def _changed_comments(self, old, new):
397         """
398         Search for differences in all loaded comments between the bugs
399         old and new.  Returns
400           (added_comments, modified_comments, removed_comments)
401         analogous to ._changed_bugs.
402         """
403         if hasattr(self, "__changed_comments"):
404             if new.uuid in self.__changed_comments:
405                 return self.__changed_comments[new.uuid]
406         else:
407             self.__changed_comments = {}
408         added = []
409         removed = []
410         modified = []
411         old.comment_root.sort(key=lambda comm : comm.time)
412         new.comment_root.sort(key=lambda comm : comm.time)
413         old_comment_ids = [c.uuid for c in old.comments()]
414         new_comment_ids = [c.uuid for c in new.comments()]
415         for uuid in new_comment_ids:
416             new_comment = new.comment_from_uuid(uuid)
417             try:
418                 old_comment = old.comment_from_uuid(uuid)
419             except KeyError:
420                 added.append(new_comment)
421             else:
422                 if old_comment != new_comment:
423                     modified.append((old_comment, new_comment))
424         for uuid in old_comment_ids:
425             if uuid not in new_comment_ids:
426                 old_comment = old.comment_from_uuid(uuid)
427                 removed.append(old_comment)
428         self.__changed_comments[new.uuid] = (added, modified, removed)
429         return self.__changed_comments[new.uuid]
430     def _attribute_changes(self, old, new, attributes):
431         """
432         Take two objects old and new, and compare the value of *.attr
433         for attr in the list attribute names.  Returns a list of
434           (attr_name, old_value, new_value)
435         tuples.
436         """
437         change_list = []
438         for attr in attributes:
439             old_value = getattr(old, attr)
440             new_value = getattr(new, attr)
441             if old_value != new_value:
442                 change_list.append((attr, old_value, new_value))
443         if len(change_list) >= 0:
444             return change_list
445         return None
446     def _settings_properties_attribute_changes(self, old, new,
447                                               hidden_properties=[]):
448         properties = sorted(new.settings_properties)
449         for p in hidden_properties:
450             properties.remove(p)
451         attributes = [settings_object.setting_name_to_attr_name(None, p)
452                       for p in properties]
453         return self._attribute_changes(old, new, attributes)
454     def _bugdir_attribute_changes(self):
455         return self._settings_properties_attribute_changes( \
456             self.old_bugdir, self.new_bugdir,
457             ["vcs_name"]) # tweaked by bugdir.duplicate_bugdir
458     def _bug_attribute_changes(self, old, new):
459         return self._settings_properties_attribute_changes(old, new)
460     def _comment_attribute_changes(self, old, new):
461         return self._settings_properties_attribute_changes(old, new)
462
463     # report generation methods
464
465     def full_report(self, diff_tree=DiffTree):
466         """
467         Generate a full report for efficiency if you'll be using
468         .report_tree() with several sets of subscriptions.
469         """
470         self._cached_full_report = self.report_tree(diff_tree=diff_tree,
471                                                     allow_cached=False)
472         self._cached_full_report_diff_tree = diff_tree
473     def _sub_report(self, subscriptions):
474         """
475         Return ._cached_full_report masked for subscriptions.
476         """
477         root = self._cached_full_report
478         bugdir_types = [s.type for s in subscriptions if s.id == BUGDIR_ID]
479         subscribed_bugs = [s.id for s in subscriptions
480                            if BUG_TYPE_ALL.has_descendant( \
481                                      s.type, match_self=True)]
482         selected_by_bug = [node.name
483                            for node in root.child_by_path('bugdir/bugs')]
484         if BUGDIR_TYPE_ALL in bugdir_types:
485             for node in root.traverse():
486                 node.masked = False
487             selected_by_bug = []
488         else:
489             try:
490                 node = root.child_by_path('bugdir/settings')
491                 node.masked = True
492             except KeyError:
493                 pass
494         for name,type in (('new', BUGDIR_TYPE_NEW),
495                           ('mod', BUGDIR_TYPE_MOD),
496                           ('rem', BUGDIR_TYPE_REM)):
497             if type in bugdir_types:
498                 bugs = root.child_by_path('bugdir/bugs/%s' % name)
499                 for bug_node in bugs:
500                     for node in bug_node.traverse():
501                         node.masked = False
502                 selected_by_bug.remove(name)
503         for name in selected_by_bug:
504             bugs = root.child_by_path('bugdir/bugs/%s' % name)
505             for bug_node in bugs:
506                 if bug_node.name in subscribed_bugs:
507                     for node in bug_node.traverse():
508                         node.masked = False
509                 else:
510                     for node in bug_node.traverse():
511                         node.masked = True
512         return root
513     def report_tree(self, subscriptions=None, diff_tree=DiffTree,
514                     allow_cached=True):
515         """
516         Pretty bare to make it easy to adjust to specific cases.  You
517         can pass in a DiffTree subclass via diff_tree to override the
518         default report assembly process.
519         """
520         if allow_cached == True \
521                 and hasattr(self, '_cached_full_report') \
522                 and diff_tree == self._cached_full_report_diff_tree:
523             return self._sub_report(subscriptions)
524         if subscriptions == None:
525             subscriptions = [Subscription(BUGDIR_ID, BUGDIR_TYPE_ALL)]
526         bugdir_settings = sorted(self.new_bugdir.settings_properties)
527         bugdir_settings.remove("vcs_name") # tweaked by bugdir.duplicate_bugdir
528         root = diff_tree("bugdir")
529         bugdir_subscriptions = [s.type for s in subscriptions
530                                 if s.id == BUGDIR_ID]
531         if BUGDIR_TYPE_ALL in bugdir_subscriptions:
532             bugdir_attribute_changes = self._bugdir_attribute_changes()
533             if len(bugdir_attribute_changes) > 0:
534                 bugdir = diff_tree("settings", bugdir_attribute_changes,
535                                    self.bugdir_attribute_change_string)
536                 root.append(bugdir)
537         bug_root = diff_tree("bugs")
538         root.append(bug_root)
539         add,mod,rem = self._changed_bugs(subscriptions)
540         bnew = diff_tree("new", "New bugs:", requires_children=True)
541         bug_root.append(bnew)
542         for bug in add:
543             b = diff_tree(bug.uuid, bug, self.bug_add_string)
544             bnew.append(b)
545         brem = diff_tree("rem", "Removed bugs:", requires_children=True)
546         bug_root.append(brem)
547         for bug in rem:
548             b = diff_tree(bug.uuid, bug, self.bug_rem_string)
549             brem.append(b)
550         bmod = diff_tree("mod", "Modified bugs:", requires_children=True)
551         bug_root.append(bmod)
552         for old,new in mod:
553             b = diff_tree(new.uuid, (old,new), self.bug_mod_string)
554             bmod.append(b)
555             bug_attribute_changes = self._bug_attribute_changes(old, new)
556             if len(bug_attribute_changes) > 0:
557                 bset = diff_tree("settings", bug_attribute_changes,
558                                  self.bug_attribute_change_string)
559                 b.append(bset)
560             if old.summary != new.summary:
561                 data = (old.summary, new.summary)
562                 bsum = diff_tree("summary", data, self.bug_summary_change_string)
563                 b.append(bsum)
564             cr = diff_tree("comments")
565             b.append(cr)
566             a,m,d = self._changed_comments(old, new)
567             cnew = diff_tree("new", "New comments:", requires_children=True)
568             for comment in a:
569                 c = diff_tree(comment.uuid, comment, self.comment_add_string)
570                 cnew.append(c)
571             crem = diff_tree("rem", "Removed comments:",requires_children=True)
572             for comment in d:
573                 c = diff_tree(comment.uuid, comment, self.comment_rem_string)
574                 crem.append(c)
575             cmod = diff_tree("mod","Modified comments:",requires_children=True)
576             for o,n in m:
577                 c = diff_tree(n.uuid, (o,n), self.comment_mod_string)
578                 cmod.append(c)
579                 comm_attribute_changes = self._comment_attribute_changes(o, n)
580                 if len(comm_attribute_changes) > 0:
581                     cset = diff_tree("settings", comm_attribute_changes,
582                                      self.comment_attribute_change_string)
583                 if o.body != n.body:
584                     data = (o.body, n.body)
585                     cbody = diff_tree("cbody", data,
586                                       self.comment_body_change_string)
587                     c.append(cbody)
588             cr.extend([cnew, crem, cmod])
589         return root
590
591     # change data -> string methods.
592     # Feel free to play with these in subclasses.
593
594     def attribute_change_string(self, attribute_changes, indent=0):
595         indent_string = "  "*indent
596         change_strings = [u"%s: %s -> %s" % f for f in attribute_changes]
597         for i,change_string in enumerate(change_strings):
598             change_strings[i] = indent_string+change_string
599         return u"\n".join(change_strings)
600     def bugdir_attribute_change_string(self, attribute_changes):
601         return "Changed bug directory settings:\n%s" % \
602             self.attribute_change_string(attribute_changes, indent=1)
603     def bug_attribute_change_string(self, attribute_changes):
604         return "Changed bug settings:\n%s" % \
605             self.attribute_change_string(attribute_changes, indent=1)
606     def comment_attribute_change_string(self, attribute_changes):
607         return "Changed comment settings:\n%s" % \
608             self.attribute_change_string(attribute_changes, indent=1)
609     def bug_add_string(self, bug):
610         return bug.string(shortlist=True)
611     def bug_rem_string(self, bug):
612         return bug.string(shortlist=True)
613     def bug_mod_string(self, bugs):
614         old_bug,new_bug = bugs
615         return new_bug.string(shortlist=True)
616     def bug_summary_change_string(self, summaries):
617         old_summary,new_summary = summaries
618         return "summary changed:\n  %s\n  %s" % (old_summary, new_summary)
619     def _comment_summary_string(self, comment):
620         return "from %s on %s" % (comment.author, time_to_str(comment.time))
621     def comment_add_string(self, comment):
622         summary = self._comment_summary_string(comment)
623         first_line = comment.body.splitlines()[0]
624         return "%s\n  %s..." % (summary, first_line)
625     def comment_rem_string(self, comment):
626         summary = self._comment_summary_string(comment)
627         first_line = comment.body.splitlines()[0]
628         return "%s\n  %s..." % (summary, first_line)
629     def comment_mod_string(self, comments):
630         old_comment,new_comment = comments
631         return self._comment_summary_string(new_comment)
632     def comment_body_change_string(self, bodies):
633         old_body,new_body = bodies
634         return difflib.unified_diff(old_body, new_body)
635
636
637 if libbe.TESTING == True:
638     suite = doctest.DocTestSuite()