Merge lp:~allenap/launchpad/bug-activity-grouping-bug-353890 into lp:launchpad

Proposed by Gavin Panella on 2010-12-20
Status: Merged
Approved by: Gavin Panella on 2010-12-21
Approved revision: no longer in the source branch.
Merged at revision: 12131
Proposed branch: lp:~allenap/launchpad/bug-activity-grouping-bug-353890
Merge into: lp:launchpad
Diff against target: 1137 lines (+510/-277)
8 files modified
lib/lp/bugs/browser/bugcomment.py (+89/-8)
lib/lp/bugs/browser/bugtask.py (+101/-124)
lib/lp/bugs/browser/tests/bug-views.txt (+40/-57)
lib/lp/bugs/browser/tests/test_bugcomment.py (+178/-0)
lib/lp/bugs/browser/tests/test_bugtask.py (+31/-2)
lib/lp/bugs/doc/bugcomment.txt (+3/-3)
lib/lp/bugs/stories/bugs/xx-bug-activity.txt (+67/-82)
lib/lp/bugs/templates/bugtask-index.pt (+1/-1)
To merge this branch: bzr merge lp:~allenap/launchpad/bug-activity-grouping-bug-353890
Reviewer Review Type Date Requested Status
Leonard Richardson (community) 2010-12-20 Approve on 2010-12-21
Review via email: mp+44247@code.launchpad.net

Commit Message

[r=leonardr][ui=none][bug=353890] Group bug comments and activities that occur within a small time window. Previously these were grouped only when they happened at exactly the same moment.

Description of the Change

This branch groups together related comments and activity for display
on bug pages.

Related means: a string of 2 or more activities by a single user
happening within a given time window (5 minutes for now). There can be
zero or one comments in any given string of related activities.

Previously only activities happening at exactly the same moment would
be grouped, but this doesn't work so well with the AJAXy stuff.

The steps I took:

- Break out the code that identifies interesting activities into a new
  interesting_activity property.

  I have added a single test for this property. I am considering
  testing this more extensively, but I'm not yet sure if I would be
  repeating work done elsewhere.

- Ditch the activity_by_date property.

- New function group_comments_with_activity() that takes a sequence
  (or iterable) of comments and another of activity and merges the
  two, working out what is related according to the rule defined
  earlier.

  This is tested exclusively using stubs, so it's very fast to
  test. Existing tests (which I have updated) already provide
  integration tests.

- The activity_and_comments property was updated to further process
  what comes out of group_comments_with_activity().

- A big pile of doctest changes and improvements.

To post a comment you must log in.
Gavin Panella (allenap) wrote :

I also fixed some lint in utilities/format-imports.

Gavin Panella (allenap) wrote :

Ignore the last comment; wrong merge proposal.

Leonard Richardson (leonardr) wrote :

Approved with minor changes, as discussed on IRC.

On line 292, the lambda expression should just be moved into the comprehension. It doesn't add anything.

first_newest_comment is a confusing name. You suggested renaming newest_comments to recent_comments, and having oldest_recent_comment instead.

Typo on line 651: "no activities is"

test_common_actor_over_a_prolonged_time() would be easier to understand if you mentioned that the grouping window is 5 minutes.

test_interleaved_activity_with_comments_by_common_actor() has an inaccurate name since there is only one comment.

On line 878, &\x238594; => →

You also showed me, and I approve, this change to interesting_activity: http://paste.ubuntu.com/546305/

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/browser/bugcomment.py'
2--- lib/lp/bugs/browser/bugcomment.py 2010-09-24 22:30:48 +0000
3+++ lib/lp/bugs/browser/bugcomment.py 2010-12-21 16:34:36 +0000
4@@ -6,18 +6,24 @@
5 __metaclass__ = type
6 __all__ = [
7 'BugComment',
8+ 'BugCommentBoxExpandedReplyView',
9+ 'BugCommentBoxView',
10+ 'BugCommentBreadcrumb',
11 'BugCommentView',
12- 'BugCommentBoxView',
13- 'BugCommentBoxExpandedReplyView',
14 'BugCommentXHTMLRepresentation',
15- 'BugCommentBreadcrumb',
16 'build_comments_from_chunks',
17+ 'group_comments_with_activity',
18 ]
19
20 from datetime import (
21 datetime,
22 timedelta,
23 )
24+from itertools import (
25+ chain,
26+ groupby,
27+ )
28+from operator import itemgetter
29
30 from lazr.restful.interfaces import IWebServiceClientRequest
31 from pytz import utc
32@@ -44,7 +50,9 @@
33 IBugComment,
34 IBugMessageSet,
35 )
36-from lp.registry.interfaces.person import IPersonSet
37+
38+
39+COMMENT_ACTIVITY_GROUPING_WINDOW = timedelta(minutes=5)
40
41
42 def build_comments_from_chunks(chunks, bugtask, truncate=False):
43@@ -87,6 +95,72 @@
44 return comments
45
46
47+def group_comments_with_activity(comments, activities):
48+ """Group comments and activity together for human consumption.
49+
50+ Generates a stream of comment instances (with the activity grouped within)
51+ or `list`s of grouped activities.
52+
53+ :param comments: An iterable of `BugComment` instances.
54+ :param activities: An iterable of `BugActivity` instances.
55+ """
56+ window = COMMENT_ACTIVITY_GROUPING_WINDOW
57+
58+ comment_kind = "comment"
59+ comments = (
60+ (comment.datecreated, comment.owner, comment_kind, comment)
61+ for comment in comments)
62+ activity_kind = "activity"
63+ activity = (
64+ (activity.datechanged, activity.person, activity_kind, activity)
65+ for activity in activities)
66+ events = sorted(chain(comments, activity), key=itemgetter(0, 1))
67+
68+ def gen_event_windows(events):
69+ """Generate event windows.
70+
71+ Yields `(window_index, kind, event)` tuples, where `window_index` is
72+ an integer, and is incremented each time the windowing conditions are
73+ triggered.
74+
75+ :param events: An iterable of `(date, actor, kind, event)` tuples in
76+ order.
77+ """
78+ window_comment, window_actor = None, None
79+ window_index, window_end = 0, None
80+ for date, actor, kind, event in events:
81+ window_ended = (
82+ # A window may contain only one comment.
83+ (window_comment is not None and kind is comment_kind) or
84+ # All events must have happened within a given timeframe.
85+ (window_end is None or date >= window_end) or
86+ # All events within the window must belong to the same actor.
87+ (window_actor is None or actor != window_actor))
88+ if window_ended:
89+ window_comment, window_actor = None, actor
90+ window_index, window_end = window_index + 1, date + window
91+ if kind is comment_kind:
92+ window_comment = event
93+ yield window_index, kind, event
94+
95+ event_windows = gen_event_windows(events)
96+ event_windows_grouper = groupby(event_windows, itemgetter(0))
97+ for window_index, window_group in event_windows_grouper:
98+ window_group = [
99+ (kind, event) for (index, kind, event) in window_group]
100+ for kind, event in window_group:
101+ if kind is comment_kind:
102+ window_comment = event
103+ window_comment.activity.extend(
104+ event for (kind, event) in window_group
105+ if kind is activity_kind)
106+ yield window_comment
107+ # There's only one comment per window.
108+ break
109+ else:
110+ yield [event for (kind, event) in window_group]
111+
112+
113 class BugComment:
114 """Data structure that holds all data pertaining to a bug comment.
115
116@@ -208,10 +282,17 @@
117 obfuscated for unauthenticated users.
118 """
119 now = datetime.now(tz=utc)
120- # The major factor in how long we can cache a bug comment is
121- # the timestamp. The rendering of the timestamp changes every
122- # minute for the first hour because we say '7 minutes ago'.
123- if self.datecreated > now - timedelta(hours=1):
124+
125+ # The major factor in how long we can cache a bug comment is the
126+ # timestamp. For up to 5 minutes comments and activity can be grouped
127+ # together as related, so do not cache.
128+ if self.datecreated > now - COMMENT_ACTIVITY_GROUPING_WINDOW:
129+ # Don't return 0 because that indicates no time limit.
130+ return -1
131+
132+ # The rendering of the timestamp changes every minute for the first
133+ # hour because we say '7 minutes ago'.
134+ elif self.datecreated > now - timedelta(hours=1):
135 return 60
136
137 # Don't cache for long if we are waiting for synchronization.
138
139=== modified file 'lib/lp/bugs/browser/bugtask.py'
140--- lib/lp/bugs/browser/bugtask.py 2010-11-24 10:10:07 +0000
141+++ lib/lp/bugs/browser/bugtask.py 2010-12-21 16:34:36 +0000
142@@ -11,12 +11,14 @@
143 'BugListingPortletInfoView',
144 'BugListingPortletStatsView',
145 'BugNominationsView',
146+ 'BugsBugTaskSearchListingView',
147 'bugtarget_renderer',
148 'BugTargetTraversalMixin',
149 'BugTargetView',
150+ 'bugtask_heat_html',
151+ 'BugTaskBreadcrumb',
152 'BugTaskContextMenu',
153 'BugTaskCreateQuestionView',
154- 'BugTaskBreadcrumb',
155 'BugTaskEditView',
156 'BugTaskExpirableListingView',
157 'BugTaskListingItem',
158@@ -25,22 +27,20 @@
159 'BugTaskPortletView',
160 'BugTaskPrivacyAdapter',
161 'BugTaskRemoveQuestionView',
162+ 'BugTasksAndNominationsView',
163 'BugTaskSearchListingView',
164 'BugTaskSetNavigation',
165 'BugTaskStatusView',
166 'BugTaskTableRowView',
167 'BugTaskTextView',
168 'BugTaskView',
169- 'BugTasksAndNominationsView',
170- 'bugtask_heat_html',
171- 'BugsBugTaskSearchListingView',
172 'calculate_heat_display',
173- 'NominationsReviewTableBatchNavigatorView',
174- 'TextualBugTaskSearchListingView',
175 'get_buglisting_search_filter_url',
176 'get_comments_for_bugtask',
177 'get_sortorder_from_request',
178 'get_visible_comments',
179+ 'NominationsReviewTableBatchNavigatorView',
180+ 'TextualBugTaskSearchListingView',
181 ]
182
183 import cgi
184@@ -48,14 +48,15 @@
185 datetime,
186 timedelta,
187 )
188+from itertools import (
189+ chain,
190+ groupby,
191+ )
192 from math import (
193 floor,
194 log,
195 )
196-from operator import (
197- attrgetter,
198- itemgetter,
199- )
200+from operator import attrgetter
201 import re
202 import urllib
203
204@@ -75,7 +76,7 @@
205 IWebServiceClientRequest,
206 )
207 from lazr.uri import URI
208-import pytz
209+from pytz import utc
210 from simplejson import dumps
211 from z3c.ptcompat import ViewPageTemplateFile
212 from zope import (
213@@ -203,7 +204,10 @@
214 BugTextView,
215 BugViewMixin,
216 )
217-from lp.bugs.browser.bugcomment import build_comments_from_chunks
218+from lp.bugs.browser.bugcomment import (
219+ build_comments_from_chunks,
220+ group_comments_with_activity,
221+ )
222 from lp.bugs.interfaces.bug import (
223 IBug,
224 IBugSet,
225@@ -812,62 +816,20 @@
226 return comments
227
228 @cachedproperty
229- def activity_by_date(self):
230- """Return a dict of `BugActivityItem`s for the current bug.
231-
232- The `BugActivityItem`s will be keyed by the date on which they
233- occurred.
234- """
235- activity_by_date = {}
236+ def interesting_activity(self):
237+ """A sequence of interesting bug activity."""
238+ bug_change_re = (
239+ 'affects|description|security vulnerability|'
240+ 'summary|tags|visibility')
241 bugtask_change_re = (
242 '[a-z0-9][a-z0-9\+\.\-]+( \([A-Za-z0-9\s]+\))?: '
243 '(assignee|importance|milestone|status)')
244- interesting_changes = [
245- 'description',
246- 'security vulnerability',
247- 'summary',
248- 'tags',
249- 'visibility',
250- 'affects',
251- bugtask_change_re,
252- ]
253-
254- # Turn the interesting_changes list into a regex so that we can
255- # do complex matches.
256- interesting_changes_expression = "|".join(interesting_changes)
257- interesting_changes_regex = re.compile(
258- "^(%s)$" % interesting_changes_expression)
259-
260- for activity in self.context.bug.activity:
261- # If we're not interested in the change, skip it.
262- if interesting_changes_regex.match(activity.whatchanged) is None:
263- continue
264-
265- activity = BugActivityItem(activity)
266- if activity.datechanged not in activity_by_date:
267- activity_by_date[activity.datechanged] = {
268- activity.target: [activity]}
269- else:
270- activity_dict = activity_by_date[activity.datechanged]
271- if activity.target in activity_dict:
272- activity_dict[activity.target].append(activity)
273- else:
274- activity_dict[activity.target] = [activity]
275-
276- # Sort all the lists to ensure that changes are written out in
277- # alphabetical order.
278- for date, activity_by_target in activity_by_date.items():
279- # We convert each {target: activity_list} mapping into a list
280- # of {target, activity_list} dicts for the sake of making
281- # them usable in templates.
282- activity_by_date[date] = [{
283- 'target': target,
284- 'activity': sorted(
285- activity_list, key=attrgetter('attribute')),
286- }
287- for target, activity_list in activity_by_target.items()]
288-
289- return activity_by_date
290+ interesting_match = re.compile(
291+ "^(%s|%s)$" % (bug_change_re, bugtask_change_re)).match
292+ return tuple(
293+ BugActivityItem(activity)
294+ for activity in self.context.bug.activity
295+ if interesting_match(activity.whatchanged) is not None)
296
297 @cachedproperty
298 def activity_and_comments(self):
299@@ -878,66 +840,82 @@
300 then as if owned by the person who posted the first action
301 that day.
302
303- If the number of comments exceeds the configured maximum limit,
304- the list will be truncated to just the first and last sets of
305- comments. The division between the newest and oldest is marked
306- by an entry in the list with the key 'num_hidden' defined.
307+ If the number of comments exceeds the configured maximum limit, the
308+ list will be truncated to just the first and last sets of comments.
309+
310+ The division between the most recent and oldest is marked by an entry
311+ in the list with the key 'num_hidden' defined.
312 """
313- activity_and_comments = []
314- activity_log = self.activity_by_date
315-
316 # Ensure truncation results in < max_length comments as expected
317 assert(config.malone.comments_list_truncate_oldest_to
318 + config.malone.comments_list_truncate_newest_to
319 < config.malone.comments_list_max_length)
320
321- newest_comments = self.visible_newest_comments_for_display
322+ recent_comments = self.visible_recent_comments_for_display
323 oldest_comments = self.visible_oldest_comments_for_display
324
325- # Oldest comments and activities
326- for comment in oldest_comments:
327- # Move any corresponding activities into the comment
328- comment.activity = activity_log.pop(comment.datecreated, [])
329- comment.activity.sort(key=itemgetter('target'))
330-
331- activity_and_comments.append({
332- 'comment': comment,
333- 'date': comment.datecreated,
334- })
335-
336- # Insert blank if we're showing only a subset of the comment list
337- if len(newest_comments) > 0:
338- activity_and_comments.append({
339- 'num_hidden': (len(self.visible_comments)
340- - len(oldest_comments)
341- - len(newest_comments)),
342- 'date': newest_comments[0].datecreated,
343- })
344-
345- # Most recent comments and activities (if showing a subset)
346- for comment in newest_comments:
347- # Move any corresponding activities into the comment
348- comment.activity = activity_log.pop(comment.datecreated, [])
349- comment.activity.sort(key=itemgetter('target'))
350-
351- activity_and_comments.append({
352- 'comment': comment,
353- 'date': comment.datecreated,
354- })
355-
356- # Add the remaining activities not associated with any visible
357- # comments to the activity_for_comments list. For each
358- # activity dict we use the person responsible for the first
359- # activity item as the owner of the list of activities.
360- for date, activity_dict in activity_log.items():
361- activity_and_comments.append({
362- 'activity': activity_dict,
363- 'date': date,
364- 'person': activity_dict[0]['activity'][0].person,
365- })
366-
367- activity_and_comments.sort(key=itemgetter('date'))
368- return activity_and_comments
369+ event_groups = group_comments_with_activity(
370+ comments=chain(oldest_comments, recent_comments),
371+ activities=self.interesting_activity)
372+
373+ def group_activities_by_target(activities):
374+ activities = sorted(
375+ activities, key=attrgetter(
376+ "datechanged", "target", "attribute"))
377+ return [
378+ {"target": target, "activity": list(activity)}
379+ for target, activity in groupby(
380+ activities, attrgetter("target"))]
381+
382+ def comment_event_dict(comment):
383+ actors = set(activity.person for activity in comment.activity)
384+ actors.add(comment.owner)
385+ assert len(actors) == 1, actors
386+ dates = set(activity.datechanged for activity in comment.activity)
387+ dates.add(comment.datecreated)
388+ comment.activity = group_activities_by_target(comment.activity)
389+ return {
390+ "comment": comment,
391+ "date": min(dates),
392+ "person": actors.pop(),
393+ }
394+
395+ def activity_event_dict(activities):
396+ actors = set(activity.person for activity in activities)
397+ assert len(actors) == 1, actors
398+ dates = set(activity.datechanged for activity in activities)
399+ return {
400+ "activity": group_activities_by_target(activities),
401+ "date": min(dates),
402+ "person": actors.pop(),
403+ }
404+
405+ def event_dict(event_group):
406+ if isinstance(event_group, list):
407+ return activity_event_dict(event_group)
408+ else:
409+ return comment_event_dict(event_group)
410+
411+ events = map(event_dict, event_groups)
412+
413+ # Insert blank if we're showing only a subset of the comment list.
414+ if len(recent_comments) > 0:
415+ # Find the oldest recent comment in the event list.
416+ oldest_recent_comment = recent_comments[0]
417+ for index, event in enumerate(events):
418+ if event.get("comment") is oldest_recent_comment:
419+ num_hidden = (
420+ len(self.visible_comments)
421+ - len(oldest_comments)
422+ - len(recent_comments))
423+ separator = {
424+ 'date': oldest_recent_comment.datecreated,
425+ 'num_hidden': num_hidden,
426+ }
427+ events.insert(index, separator)
428+ break
429+
430+ return events
431
432 @cachedproperty
433 def visible_comments(self):
434@@ -965,14 +943,13 @@
435 return self.visible_comments[:oldest_count]
436
437 @cachedproperty
438- def visible_newest_comments_for_display(self):
439- """The list of newest visible comments to be rendered.
440+ def visible_recent_comments_for_display(self):
441+ """The list of recent visible comments to be rendered.
442
443 If the number of comments is beyond the maximum threshold, this
444- returns the newest few comments. If we're under the threshold,
445- then visible_oldest_comments_for_display will be returning the
446- bugs, so this routine will return an empty set to avoid
447- duplication.
448+ returns the most recent few comments. If we're under the threshold,
449+ then visible_oldest_comments_for_display will be returning the bugs,
450+ so this routine will return an empty set to avoid duplication.
451 """
452 show_all = (self.request.form_ng.getOne('comments') == 'all')
453 max_comments = config.malone.comments_list_max_length
454@@ -1010,7 +987,7 @@
455
456 expire_after = timedelta(days=config.malone.days_before_expiration)
457 expiration_date = self.context.bug.date_last_updated + expire_after
458- remaining_time = expiration_date - datetime.now(pytz.timezone('UTC'))
459+ remaining_time = expiration_date - datetime.now(utc)
460 return remaining_time.days
461
462 @property
463
464=== modified file 'lib/lp/bugs/browser/tests/bug-views.txt'
465--- lib/lp/bugs/browser/tests/bug-views.txt 2010-12-20 23:13:36 +0000
466+++ lib/lp/bugs/browser/tests/bug-views.txt 2010-12-21 16:34:36 +0000
467@@ -760,16 +760,6 @@
468 >>> view = getMultiAdapter(
469 ... (bug.bugtasks[0], request), name="+index")
470
471-The activity_by_date property of BugTaskView returns a dict of
472-BugActivityItems grouped by the date on which they occurred. The items
473-are returned as a list of dicts containig a list of activity items for a
474-given target.
475-
476- >>> print pretty(view.activity_by_date)
477- {datetime.datetime(2009, 3, 26...):
478- [{'activity': [<...BugActivityItem...>],
479- 'target': None}]}
480-
481 The activity_and_comments property of BugTaskView is a list of comments
482 and activity on a bug, ordered by the date that they occurred. Each item
483 is encapsulated in a dict, in the form: {'comment': <BugComment>} or
484@@ -793,28 +783,40 @@
485 ... (bug.bugtasks[0], request), name="+index")
486 >>> view.initialize()
487
488- >>> activity_and_comments = view.activity_and_comments
489- >>> for activity_or_comment in activity_and_comments:
490- ... if 'activity' in activity_or_comment:
491- ... activity_dict = activity_or_comment['activity']
492- ... for activity_for_target in activity_dict:
493- ... target_name = activity_for_target['target']
494- ... activity_items = activity_for_target['activity']
495- ... if target_name is not None:
496- ... print "Changed in %s:" % target_name
497- ... for activity_item in activity_items:
498- ... print "%s: %s => %s" % (
499- ... activity_item.change_summary,
500- ... activity_item.oldvalue,
501- ... activity_item.newvalue)
502- ...
503- ... if 'comment' in activity_or_comment:
504- ... comment = activity_or_comment['comment']
505- ... print comment.text_for_display
506- summary: A bug title => A new bug title
507+ >>> def print_activities(activities):
508+ ... for activity in activities:
509+ ... target_name = activity['target']
510+ ... if target_name is None:
511+ ... print "Changed:"
512+ ... else:
513+ ... print "Changed in %s:" % target_name
514+ ... activity_items = activity['activity']
515+ ... for activity_item in activity_items:
516+ ... print "* %s: %s => %s" % (
517+ ... activity_item.change_summary,
518+ ... activity_item.oldvalue,
519+ ... activity_item.newvalue)
520+
521+ >>> def print_comment(comment):
522+ ... print comment.text_for_display
523+ ... print_activities(comment.activity)
524+
525+ >>> def print_activity_and_comments(activity_and_comments):
526+ ... for activity_or_comment in activity_and_comments:
527+ ... print "-- {person.name} --".format(**activity_or_comment)
528+ ... if 'activity' in activity_or_comment:
529+ ... print_activities(activity_or_comment["activity"])
530+ ... if 'comment' in activity_or_comment:
531+ ... print_comment(activity_or_comment["comment"])
532+
533+ >>> print_activity_and_comments(view.activity_and_comments)
534+ -- name16 --
535+ Changed:
536+ * summary: A bug title => A new bug title
537+ -- name16 --
538 A comment, for the reading of.
539 Changed in testproduct:
540- status: New => Confirmed
541+ * status: New => Confirmed
542
543 If a comment and a BugActivity item occur at the same time, the activity
544 item will be returned in the comment's activity property rather than as
545@@ -841,36 +843,17 @@
546 before, plus the new comment, since the changes we just made were
547 grouped with that comment.
548
549- >>> activity_and_comments = view.activity_and_comments
550- >>> for activity_or_comment in activity_and_comments:
551- ... if 'activity' in activity_or_comment:
552- ... for activity_dict in activity_or_comment['activity']:
553- ... for activity in activity_dict['activity']:
554- ... print "%s: %s => %s" % (
555- ... activity.whatchanged, activity.oldvalue,
556- ... activity.newvalue)
557- ... if 'comment' in activity_or_comment:
558- ... comment = activity_or_comment['comment']
559- ... print comment.text_for_display
560- summary: A bug title => A new bug title
561+ >>> print_activity_and_comments(view.activity_and_comments)
562+ -- name16 --
563+ Changed:
564+ * summary: A bug title => A new bug title
565+ -- name16 --
566 A comment, for the reading of.
567+ -- name16 --
568 I triaged it.
569-
570-But if we look at the most recent comment's activity property we'll see
571-that the activity recorded there reflects the changes that go with the
572-comment.
573-
574- >>> comment = activity_and_comments[-1]['comment']
575- >>> for activity_dict in comment.activity:
576- ... if activity_dict['target'] is not None:
577- ... print "Changed in %s:" % activity_dict['target']
578- ... for activity in activity_dict['activity']:
579- ... print "%s: %s => %s" % (
580- ... activity.attribute, activity.oldvalue,
581- ... activity.newvalue)
582 Changed in testproduct:
583- importance: Undecided => High
584- status: New => Confirmed
585+ * importance: Undecided => High
586+ * status: New => Confirmed
587
588
589 Getting the list of possible duplicates for a new bug
590
591=== added file 'lib/lp/bugs/browser/tests/test_bugcomment.py'
592--- lib/lp/bugs/browser/tests/test_bugcomment.py 1970-01-01 00:00:00 +0000
593+++ lib/lp/bugs/browser/tests/test_bugcomment.py 2010-12-21 16:34:36 +0000
594@@ -0,0 +1,178 @@
595+# Copyright 2010 Canonical Ltd. This software is licensed under the
596+# GNU Affero General Public License version 3 (see the file LICENSE).
597+
598+"""Tests for the bugcomment module."""
599+
600+__metaclass__ = type
601+
602+from datetime import (
603+ datetime,
604+ timedelta,
605+ )
606+from itertools import count
607+
608+from pytz import utc
609+
610+from lp.bugs.browser.bugcomment import group_comments_with_activity
611+from lp.testing import TestCase
612+
613+
614+class BugActivityStub:
615+
616+ def __init__(self, datechanged, person=None):
617+ self.datechanged = datechanged
618+ if person is None:
619+ person = PersonStub()
620+ self.person = person
621+
622+ def __repr__(self):
623+ return "BugActivityStub(%r, %r)" % (
624+ self.datechanged.strftime('%Y-%m-%d--%H%M'), self.person)
625+
626+
627+class BugCommentStub:
628+
629+ def __init__(self, datecreated, owner=None):
630+ self.datecreated = datecreated
631+ if owner is None:
632+ owner = PersonStub()
633+ self.owner = owner
634+ self.activity = []
635+
636+ def __repr__(self):
637+ return "BugCommentStub(%r, %r)" % (
638+ self.datecreated.strftime('%Y-%m-%d--%H%M'), self.owner)
639+
640+
641+class PersonStub:
642+
643+ ids = count(1)
644+
645+ def __init__(self):
646+ self.id = next(self.ids)
647+
648+ def __repr__(self):
649+ return "PersonStub#%d" % self.id
650+
651+
652+class TestGroupCommentsWithActivities(TestCase):
653+ """Tests for `group_comments_with_activities`."""
654+
655+ def setUp(self):
656+ super(TestGroupCommentsWithActivities, self).setUp()
657+ self.now = datetime.now(utc)
658+ self.timestamps = (
659+ self.now + timedelta(minutes=counter)
660+ for counter in count(1))
661+
662+ def group(self, comments, activities):
663+ return list(
664+ group_comments_with_activity(
665+ comments=comments, activities=activities))
666+
667+ def test_empty(self):
668+ # Given no comments or activities the result is also empty.
669+ self.assertEqual(
670+ [], self.group(comments=[], activities=[]))
671+
672+ def test_activity_empty_no_common_actor(self):
673+ # When no activities are passed in, and the comments passed in don't
674+ # have any common actors, no grouping is possible.
675+ comments = [
676+ BugCommentStub(next(self.timestamps))
677+ for number in xrange(5)]
678+ self.assertEqual(
679+ comments, self.group(comments=comments, activities=[]))
680+
681+ def test_comments_empty_no_common_actor(self):
682+ # When no comments are passed in, and the activities passed in don't
683+ # have any common actors, no grouping is possible.
684+ activities = [
685+ BugActivityStub(next(self.timestamps))
686+ for number in xrange(5)]
687+ self.assertEqual(
688+ [[activity] for activity in activities], self.group(
689+ comments=[], activities=activities))
690+
691+ def test_no_common_actor(self):
692+ # When each activities and comment given has a different actor then no
693+ # grouping is possible.
694+ activity1 = BugActivityStub(next(self.timestamps))
695+ comment1 = BugCommentStub(next(self.timestamps))
696+ activity2 = BugActivityStub(next(self.timestamps))
697+ comment2 = BugCommentStub(next(self.timestamps))
698+
699+ activities = set([activity1, activity2])
700+ comments = set([comment1, comment2])
701+
702+ self.assertEqual(
703+ [[activity1], comment1, [activity2], comment2],
704+ self.group(comments=comments, activities=activities))
705+
706+ def test_comment_then_activity_close_by_common_actor(self):
707+ # An activity shortly after a comment by the same person is grouped
708+ # into the comment.
709+ actor = PersonStub()
710+ comment = BugCommentStub(next(self.timestamps), actor)
711+ activity = BugActivityStub(next(self.timestamps), actor)
712+ grouped = self.group(comments=[comment], activities=[activity])
713+ self.assertEqual([comment], grouped)
714+ self.assertEqual([activity], comment.activity)
715+
716+ def test_activity_then_comment_close_by_common_actor(self):
717+ # An activity shortly before a comment by the same person is grouped
718+ # into the comment.
719+ actor = PersonStub()
720+ activity = BugActivityStub(next(self.timestamps), actor)
721+ comment = BugCommentStub(next(self.timestamps), actor)
722+ grouped = self.group(comments=[comment], activities=[activity])
723+ self.assertEqual([comment], grouped)
724+ self.assertEqual([activity], comment.activity)
725+
726+ def test_interleaved_activity_with_comment_by_common_actor(self):
727+ # Activities shortly before and after a comment are grouped into the
728+ # comment's activity.
729+ actor = PersonStub()
730+ activity1 = BugActivityStub(next(self.timestamps), actor)
731+ comment = BugCommentStub(next(self.timestamps), actor)
732+ activity2 = BugActivityStub(next(self.timestamps), actor)
733+ grouped = self.group(
734+ comments=[comment], activities=[activity1, activity2])
735+ self.assertEqual([comment], grouped)
736+ self.assertEqual([activity1, activity2], comment.activity)
737+
738+ def test_common_actor_over_a_prolonged_time(self):
739+ # There is a timeframe for grouping events, 5 minutes by default.
740+ # Anything outside of that window is considered separate.
741+ actor = PersonStub()
742+ activities = [
743+ BugActivityStub(next(self.timestamps), actor)
744+ for count in xrange(8)]
745+ grouped = self.group(comments=[], activities=activities)
746+ self.assertEqual(2, len(grouped))
747+ self.assertEqual(activities[:5], grouped[0])
748+ self.assertEqual(activities[5:], grouped[1])
749+
750+ def test_two_comments_by_common_actor(self):
751+ # Only one comment will ever appear in a group.
752+ actor = PersonStub()
753+ comment1 = BugCommentStub(next(self.timestamps), actor)
754+ comment2 = BugCommentStub(next(self.timestamps), actor)
755+ grouped = self.group(comments=[comment1, comment2], activities=[])
756+ self.assertEqual([comment1, comment2], grouped)
757+
758+ def test_two_comments_with_activity_by_common_actor(self):
759+ # Activity gets associated with earlier comment when all other factors
760+ # are unchanging.
761+ actor = PersonStub()
762+ activity1 = BugActivityStub(next(self.timestamps), actor)
763+ comment1 = BugCommentStub(next(self.timestamps), actor)
764+ activity2 = BugActivityStub(next(self.timestamps), actor)
765+ comment2 = BugCommentStub(next(self.timestamps), actor)
766+ activity3 = BugActivityStub(next(self.timestamps), actor)
767+ grouped = self.group(
768+ comments=[comment1, comment2],
769+ activities=[activity1, activity2, activity3])
770+ self.assertEqual([comment1, comment2], grouped)
771+ self.assertEqual([activity1, activity2], comment1.activity)
772+ self.assertEqual([activity3], comment2.activity)
773
774=== modified file 'lib/lp/bugs/browser/tests/test_bugtask.py'
775--- lib/lp/bugs/browser/tests/test_bugtask.py 2010-10-26 15:47:24 +0000
776+++ lib/lp/bugs/browser/tests/test_bugtask.py 2010-12-21 16:34:36 +0000
777@@ -3,13 +3,14 @@
778
779 __metaclass__ = type
780
781-
782+from datetime import datetime
783 from doctest import DocTestSuite
784 import unittest
785
786+from pytz import UTC
787 from storm.store import Store
788 from testtools.matchers import LessThan
789-
790+from zope.component import getUtility
791 from zope.security.proxy import removeSecurityProxy
792
793 from canonical.launchpad.ftests import (
794@@ -31,7 +32,9 @@
795 BugTaskEditView,
796 BugTasksAndNominationsView,
797 )
798+from lp.bugs.interfaces.bugactivity import IBugActivitySet
799 from lp.bugs.interfaces.bugtask import BugTaskStatus
800+from lp.services.propertycache import get_property_cache
801 from lp.testing import (
802 person_logged_in,
803 TestCaseWithFactory,
804@@ -87,6 +90,32 @@
805 LessThan(count_with_no_teams + 3),
806 ))
807
808+ def test_interesting_activity(self):
809+ # The interesting_activity property returns a tuple of interesting
810+ # `BugActivityItem`s.
811+ bug = self.factory.makeBug()
812+ view = create_initialized_view(
813+ bug.default_bugtask, name=u'+index', rootsite='bugs')
814+
815+ def add_activity(what, old=None, new=None, message=None):
816+ getUtility(IBugActivitySet).new(
817+ bug, datetime.now(UTC), bug.owner, whatchanged=what,
818+ oldvalue=old, newvalue=new, message=message)
819+ del get_property_cache(view).interesting_activity
820+
821+ # A fresh bug has no interesting activity.
822+ self.assertEqual((), view.interesting_activity)
823+
824+ # Some activity is not considered interesting.
825+ add_activity("boring")
826+ self.assertEqual((), view.interesting_activity)
827+
828+ # A description change is interesting.
829+ add_activity("description")
830+ self.assertEqual(1, len(view.interesting_activity))
831+ [activity] = view.interesting_activity
832+ self.assertEqual("description", activity.whatchanged)
833+
834
835 class TestBugTasksAndNominationsView(TestCaseWithFactory):
836
837
838=== modified file 'lib/lp/bugs/doc/bugcomment.txt'
839--- lib/lp/bugs/doc/bugcomment.txt 2010-10-18 22:24:59 +0000
840+++ lib/lp/bugs/doc/bugcomment.txt 2010-12-21 16:34:36 +0000
841@@ -285,9 +285,9 @@
842 False
843
844 When comments aren't being truncated and empty set will be returned by
845-visible_newest_comments_for_display.
846+visible_recent_comments_for_display.
847
848- >>> len(bug_view.visible_newest_comments_for_display)
849+ >>> len(bug_view.visible_recent_comments_for_display)
850 0
851
852 Add two more comments, and the list will be truncated to only 8 total.
853@@ -302,7 +302,7 @@
854 True
855 >>> len(bug_view.visible_oldest_comments_for_display)
856 3
857- >>> len(bug_view.visible_newest_comments_for_display)
858+ >>> len(bug_view.visible_recent_comments_for_display)
859 5
860
861 The display of all comments can be requested with a form parameter.
862
863=== modified file 'lib/lp/bugs/stories/bugs/xx-bug-activity.txt'
864--- lib/lp/bugs/stories/bugs/xx-bug-activity.txt 2010-09-24 22:30:48 +0000
865+++ lib/lp/bugs/stories/bugs/xx-bug-activity.txt 2010-12-21 16:34:36 +0000
866@@ -48,11 +48,11 @@
867 Some bug activity is show interleaved with comments on a bug's main
868 page.
869
870- >>> def print_comments(page):
871+ >>> def print_comments(page, subset=slice(-1, None)):
872 ... """Print all the comments on the page."""
873 ... comment_divs = find_tags_by_class(page, 'boardComment')
874- ... for div_tag in comment_divs:
875- ... print extract_text(div_tag)
876+ ... for div_tag in comment_divs[subset]:
877+ ... print extract_text(div_tag).replace("&#8594;", "=>")
878 ... print '-' * 8
879
880 >>> user_browser.open(
881@@ -60,13 +60,13 @@
882 >>> user_browser.getControl(name='field.comment').value = (
883 ... "Here's a comment for testing, like.")
884 >>> user_browser.getControl('Post Comment').click()
885- >>> print_comments(user_browser.contents)
886+ >>> print_comments(user_browser.contents, slice(None))
887 In...
888 Bug Watch Updater
889 on 2007-12-18
890 Changed in thunderbird:
891 status:
892- Unknown &#8594; New
893+ Unknown => New
894 --------
895 No Privileges Person
896 ...
897@@ -84,7 +84,7 @@
898 that have been added.
899
900 >>> user_browser.open('http://launchpad.dev/bugs/15')
901- >>> print_comments(user_browser.contents)
902+ >>> print_comments(user_browser.contents, slice(None))
903 In...
904 Bug
905 ...
906@@ -105,16 +105,19 @@
907
908 >>> admin_browser.open('http://launchpad.dev/bugs/15')
909 >>> print_comments(admin_browser.contents)
910- In...
911- Bug
912- ...
913- --------
914- Foo Bar ... ago
915- visibility: public &#8594; private
916+ Foo Bar
917+ ... ago
918+ summary:
919+ - Nonsensical bugs are useless
920+ + A new title for this bug
921+ visibility:
922+ public => private
923 --------
924
925-Changes to a bug's security_related field will be displayed as 'security
926-vulnerability' changes, with values of 'yes' or 'no'.
927+Changes to a bug's security_related field will be displayed as
928+'security vulnerability' changes, with values of 'yes' or 'no'. Note
929+that changes are grouped together if they occur at similar times, but
930+are still shown in the order the changes were made.
931
932 >>> admin_browser.open(
933 ... 'http://bugs.launchpad.dev/redfish/+bug/15/+secrecy')
934@@ -124,33 +127,12 @@
935
936 >>> admin_browser.open('http://launchpad.dev/bugs/15')
937 >>> print_comments(admin_browser.contents)
938- In...
939- Bug
940- ...
941- --------
942- Foo Bar ... ago
943- security vulnerability: no &#8594; yes
944- --------
945-
946-Changes will be grouped together if they occur at the same time.
947-
948- >>> admin_browser.open(
949- ... 'http://bugs.launchpad.dev/redfish/+bug/15/+secrecy')
950- >>> admin_browser.getControl(
951- ... "This bug is a security vulnerability").selected = False
952- >>> admin_browser.getControl(
953- ... "This bug report should be private").selected = False
954- >>> admin_browser.getControl("Change").click()
955-
956- >>> admin_browser.open('http://launchpad.dev/bugs/15')
957- >>> print_comments(admin_browser.contents)
958- In...
959- Bug
960- ...
961- --------
962- Foo Bar ... ago
963- security vulnerability: yes &#8594; no
964- visibility: private &#8594; public
965+ Foo Bar
966+ ... ago
967+ summary:
968+ ...
969+ security vulnerability:
970+ no => yes
971 --------
972
973 Changes to the bug's description will simply be displayed as 'description:
974@@ -164,12 +146,12 @@
975
976 >>> admin_browser.open('http://launchpad.dev/bugs/15')
977 >>> print_comments(admin_browser.contents)
978- In...
979- Bug
980+ Foo Bar
981+ ... ago
982+ summary:
983 ...
984- --------
985- Foo Bar ... ago
986- description: updated
987+ description:
988+ updated
989 --------
990
991 Changes to the bug's tags will be show in the form tags removed or tags
992@@ -183,13 +165,17 @@
993
994 >>> admin_browser.open('http://launchpad.dev/bugs/15')
995 >>> print_comments(admin_browser.contents)
996- In...
997- Bug
998+ Foo Bar
999+ ... ago
1000+ summary:
1001 ...
1002- --------
1003- Foo Bar ... ago
1004- tags: added: tag1 tag2 tag3
1005- --------
1006+ tags:
1007+ added: tag1 tag2 tag3
1008+ --------
1009+
1010+When two similar activities are grouped into the same comment - like
1011+two sets of tag changes - they are displayed in the order they were
1012+made.
1013
1014 >>> admin_browser.open(
1015 ... 'http://bugs.launchpad.dev/redfish/+bug/15/+edit')
1016@@ -199,13 +185,15 @@
1017
1018 >>> admin_browser.open('http://launchpad.dev/bugs/15')
1019 >>> print_comments(admin_browser.contents)
1020- In...
1021- Bug
1022+ Foo Bar
1023+ ... ago
1024+ summary:
1025 ...
1026- --------
1027- Foo Bar ... ago
1028- tags: added: tag4
1029- removed: tag3
1030+ tags:
1031+ added: tag1 tag2 tag3
1032+ tags:
1033+ added: tag4
1034+ removed: tag3
1035 --------
1036
1037 Changes to a BugTask's attributes will show up listed under the task's
1038@@ -231,16 +219,19 @@
1039 >>> admin_browser.getControl("Save Changes").click()
1040
1041 >>> print_comments(admin_browser.contents)
1042- In...
1043- Bug
1044+ Foo Bar
1045+ ... ago
1046+ summary:
1047 ...
1048- --------
1049- Foo Bar ... ago
1050 Changed in redfish:
1051- assignee: nobody &#8594; Foo Bar (name16)
1052- importance: Undecided &#8594; High
1053- milestone: none &#8594; foo
1054- status: New &#8594; Confirmed
1055+ assignee:
1056+ nobody => Foo Bar (name16)
1057+ importance:
1058+ Undecided => High
1059+ milestone:
1060+ none => foo
1061+ status:
1062+ New => Confirmed
1063 --------
1064
1065 If a change is made to a bug task which is targeted to a distro source
1066@@ -252,13 +243,11 @@
1067 >>> admin_browser.getControl('Status').value = ['Confirmed']
1068 >>> admin_browser.getControl("Save Changes").click()
1069 >>> print_comments(admin_browser.contents)
1070- Sample Person
1071- ...
1072 Foo Bar
1073- ...
1074+ ... ago
1075 Changed in mozilla-firefox (Ubuntu):
1076 status:
1077- New &#8594; Confirmed
1078+ New => Confirmed
1079 --------
1080
1081 If a change has a comment associated with it it will be displayed in the
1082@@ -273,18 +262,18 @@
1083 >>> admin_browser.getControl('Comment').value = "Lookit, a change!"
1084 >>> admin_browser.getControl("Save Changes").click()
1085 >>> print_comments(admin_browser.contents)
1086- Sample Person
1087- ...
1088 Foo Bar
1089 wrote
1090- ...
1091+ ... ago:
1092 #2
1093 Lookit, a change!
1094 Changed in mozilla-firefox (Ubuntu):
1095+ status:
1096+ New => Confirmed
1097 importance:
1098- Medium &#8594; Low
1099+ Medium => Low
1100 status:
1101- Confirmed &#8594; New
1102+ Confirmed => New
1103 --------
1104
1105 If a target of a bug task is changed the old and new value will be shown.
1106@@ -296,15 +285,11 @@
1107 ... 'Package').value = 'linux-source-2.6.15'
1108 >>> admin_browser.getControl("Save Changes").click()
1109 >>> print_comments(admin_browser.contents)
1110- Sample Person
1111- ...
1112 Foo Bar
1113 wrote
1114- ...
1115- --------
1116- Foo Bar
1117+ ... ago:
1118+ #2
1119 ...
1120 affects:
1121- mozilla-firefox (Ubuntu) &#8594; linux-source-2.6.15 (Ubuntu)
1122+ mozilla-firefox (Ubuntu) => linux-source-2.6.15 (Ubuntu)
1123 --------
1124-
1125
1126=== modified file 'lib/lp/bugs/templates/bugtask-index.pt'
1127--- lib/lp/bugs/templates/bugtask-index.pt 2010-10-26 21:26:27 +0000
1128+++ lib/lp/bugs/templates/bugtask-index.pt 2010-12-21 16:34:36 +0000
1129@@ -312,7 +312,7 @@
1130 Displaying first <span
1131 tal:replace="view/visible_oldest_comments_for_display/count:len">23</span>
1132 and last <span
1133- tal:replace="view/visible_newest_comments_for_display/count:len">32</span>
1134+ tal:replace="view/visible_recent_comments_for_display/count:len">32</span>
1135 comments.
1136 <tal:what-next
1137 define="view_all_href