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