Merge lp:~lifeless/launchpad/bug-607935 into lp:launchpad

Proposed by Robert Collins
Status: Merged
Approved by: Robert Collins
Approved revision: no longer in the source branch.
Merged at revision: 12398
Proposed branch: lp:~lifeless/launchpad/bug-607935
Merge into: lp:launchpad
Diff against target: 940 lines (+266/-223)
12 files modified
lib/lp/bugs/browser/bugcomment.py (+39/-38)
lib/lp/bugs/browser/bugtask.py (+84/-77)
lib/lp/bugs/browser/tests/bug-views.txt (+5/-4)
lib/lp/bugs/browser/tests/test_bugcomment.py (+33/-31)
lib/lp/bugs/configure.zcml (+1/-1)
lib/lp/bugs/doc/bug.txt (+22/-4)
lib/lp/bugs/doc/bugcomment.txt (+18/-30)
lib/lp/bugs/interfaces/bug.py (+9/-2)
lib/lp/bugs/interfaces/bugmessage.py (+1/-0)
lib/lp/bugs/model/bug.py (+50/-32)
lib/lp/bugs/templates/bugcomment-macros.pt (+1/-1)
lib/lp/bugs/templates/bugtask-index.pt (+3/-3)
To merge this branch: bzr merge lp:~lifeless/launchpad/bug-607935
Reviewer Review Type Date Requested Status
Stuart Bishop (community) Approve
Review via email: mp+49915@code.launchpad.net

Commit message

[r=stub][bug=607935] Reduce overhead when showing only some bug comments.

Description of the change

In this iteration I start doing range requests on BugMessage.index and no longer read in every bug message for every page load.

This required refactoring the stack by which such messages are obtained - we really should ditch BugComment I think and just use BugMessage directly (having it delegate to Message for the message fields). But thats a different project.

Anyhow, there were some properties on the bug task view that required access to all messages to work - e.g. the concept of 'how many bug comments are visible with ?show=all present' requires us to load the text of every bug message because of the algorithm in play. I've taken the view that our users will value speed of page loading over absolute precision and adjusted these to show the total messages in the system, rather than the total that would be shown. This is massively faster.

The largest expected benefit from this branch is less Disk IO - e.g. in bug one, we should skip messages 41->1280 or so. Thats a good 5 seconds of IO when it occurs - and it occurs frequently.

As a drive by I fixed the bug message grouping logic to be stable on bugmessage.index if/when two bug comments have the same datetime - which can happen in tests (there was a fragile test failing 1/3rds of the time for me).

I also fixed the logic for putting 'show all message' fillers in to show them where any hidden message is, though still guarded by the overall check for whether we're bulk-hiding messages. This was needed to avoid going up to two identical queries to get different ranges of comments.

To post a comment you must log in.
Revision history for this message
Stuart Bishop (stub) wrote :

Mainly fine.

 - Remove the commented out eager bugwatch loader if it isn't needed.
 - Add a comment in build_comments_from_chunks() explaining that bugwatches will have been preloaded at this point so the code is not retrieving them one at a time from the db.
 - I see no tests for getMessagesForView's new slice behavior, in particular checking for off-by-one errors.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/bugs/browser/bugcomment.py'
--- lib/lp/bugs/browser/bugcomment.py 2010-12-20 13:39:41 +0000
+++ lib/lp/bugs/browser/bugcomment.py 2011-02-16 20:08:14 +0000
@@ -55,41 +55,33 @@
55COMMENT_ACTIVITY_GROUPING_WINDOW = timedelta(minutes=5)55COMMENT_ACTIVITY_GROUPING_WINDOW = timedelta(minutes=5)
5656
5757
58def build_comments_from_chunks(chunks, bugtask, truncate=False):58def build_comments_from_chunks(bugtask, truncate=False, slice_info=None):
59 """Build BugComments from MessageChunks."""59 """Build BugComments from MessageChunks.
60
61 :param truncate: Perform truncation of large messages.
62 :param slice_info: If not None, an iterable of slices to retrieve.
63 """
64 chunks = bugtask.bug.getMessagesForView(slice_info=slice_info)
65 # This would be better as part of indexed_messages eager loading.
60 comments = {}66 comments = {}
61 index = 067 for bugmessage, message, chunk in chunks:
62 for chunk in chunks:68 bug_comment = comments.get(message.id)
63 message_id = chunk.message.id
64 bug_comment = comments.get(message_id)
65 if bug_comment is None:69 if bug_comment is None:
66 bug_comment = BugComment(70 bug_comment = BugComment(bugmessage.index, message, bugtask,
67 index, chunk.message, bugtask)71 visible=bugmessage.visible)
68 comments[message_id] = bug_comment72 comments[message.id] = bug_comment
69 index += 173 # This code path is currently only used from BugTask view which
74 # have already loaded all the bug watches. If we start lazy loading
75 # those, or not needing them we will need to batch lookup watches
76 # here.
77 if bugmessage.bugwatchID is not None:
78 bug_comment.bugwatch = bugmessage.bugwatch
79 bug_comment.synchronized = (
80 bugmessage.remote_comment_id is not None)
70 bug_comment.chunks.append(chunk)81 bug_comment.chunks.append(chunk)
7182
72 # Set up the bug watch for all the imported comments. We do it
73 # outside the for loop to avoid issuing one db query per comment.
74 imported_bug_messages = getUtility(IBugMessageSet).getImportedBugMessages(
75 bugtask.bug)
76 for bug_message in imported_bug_messages:
77 message_id = bug_message.message.id
78 comments[message_id].bugwatch = bug_message.bugwatch
79 comments[message_id].synchronized = (
80 bug_message.remote_comment_id is not None)
81
82 for bug_message in bugtask.bug.bug_messages:
83 comment = comments.get(bug_message.messageID, None)
84 # XXX intellectronica 2009-04-22, bug=365092: Currently, there are
85 # some bug messages for which no chunks exist in the DB, so we need to
86 # make sure that we skip them, since the corresponding message wont
87 # have been added to the comments dictionary in the section above.
88 if comment is not None:
89 comment.visible = bug_message.visible
90
91 for comment in comments.values():83 for comment in comments.values():
92 # Once we have all the chunks related to a comment set up,84 # Once we have all the chunks related to a comment populated,
93 # we get the text set up for display.85 # we get the text set up for display.
94 comment.setupText(truncate=truncate)86 comment.setupText(truncate=truncate)
95 return comments87 return comments
@@ -101,20 +93,28 @@
101 Generates a stream of comment instances (with the activity grouped within)93 Generates a stream of comment instances (with the activity grouped within)
102 or `list`s of grouped activities.94 or `list`s of grouped activities.
10395
104 :param comments: An iterable of `BugComment` instances.96 :param comments: An iterable of `BugComment` instances, which should be
97 sorted by index already.
105 :param activities: An iterable of `BugActivity` instances.98 :param activities: An iterable of `BugActivity` instances.
106 """99 """
107 window = COMMENT_ACTIVITY_GROUPING_WINDOW100 window = COMMENT_ACTIVITY_GROUPING_WINDOW
108101
109 comment_kind = "comment"102 comment_kind = "comment"
103 if comments:
104 max_index = comments[-1].index + 1
105 else:
106 max_index = 0
110 comments = (107 comments = (
111 (comment.datecreated, comment.owner, comment_kind, comment)108 (comment.datecreated, comment.index, comment.owner, comment_kind, comment)
112 for comment in comments)109 for comment in comments)
113 activity_kind = "activity"110 activity_kind = "activity"
114 activity = (111 activity = (
115 (activity.datechanged, activity.person, activity_kind, activity)112 (activity.datechanged, max_index, activity.person, activity_kind, activity)
116 for activity in activities)113 for activity in activities)
117 events = sorted(chain(comments, activity), key=itemgetter(0, 1))114 # when an action and a comment happen at the same time, the action comes
115 # second, when two events are tied the comment index is used to
116 # disambiguate.
117 events = sorted(chain(comments, activity), key=itemgetter(0, 1, 2))
118118
119 def gen_event_windows(events):119 def gen_event_windows(events):
120 """Generate event windows.120 """Generate event windows.
@@ -123,12 +123,12 @@
123 an integer, and is incremented each time the windowing conditions are123 an integer, and is incremented each time the windowing conditions are
124 triggered.124 triggered.
125125
126 :param events: An iterable of `(date, actor, kind, event)` tuples in126 :param events: An iterable of `(date, ignored, actor, kind, event)`
127 order.127 tuples in order.
128 """128 """
129 window_comment, window_actor = None, None129 window_comment, window_actor = None, None
130 window_index, window_end = 0, None130 window_index, window_end = 0, None
131 for date, actor, kind, event in events:131 for date, _, actor, kind, event in events:
132 window_ended = (132 window_ended = (
133 # A window may contain only one comment.133 # A window may contain only one comment.
134 (window_comment is not None and kind is comment_kind) or134 (window_comment is not None and kind is comment_kind) or
@@ -174,7 +174,7 @@
174 """174 """
175 implements(IBugComment)175 implements(IBugComment)
176176
177 def __init__(self, index, message, bugtask, activity=None):177 def __init__(self, index, message, bugtask, activity=None, visible=True):
178 self.index = index178 self.index = index
179 self.bugtask = bugtask179 self.bugtask = bugtask
180 self.bugwatch = None180 self.bugwatch = None
@@ -195,6 +195,7 @@
195 self.activity = activity195 self.activity = activity
196196
197 self.synchronized = False197 self.synchronized = False
198 self.visible = visible
198199
199 @property200 @property
200 def show_for_admin(self):201 def show_for_admin(self):
201202
=== modified file 'lib/lp/bugs/browser/bugtask.py'
--- lib/lp/bugs/browser/bugtask.py 2011-02-11 18:15:28 +0000
+++ lib/lp/bugs/browser/bugtask.py 2011-02-16 20:08:14 +0000
@@ -293,20 +293,30 @@
293 return title.strip()293 return title.strip()
294294
295295
296def get_comments_for_bugtask(bugtask, truncate=False):296def get_comments_for_bugtask(bugtask, truncate=False, for_display=False,
297 slice_info=None):
297 """Return BugComments related to a bugtask.298 """Return BugComments related to a bugtask.
298299
299 This code builds a sorted list of BugComments in one shot,300 This code builds a sorted list of BugComments in one shot,
300 requiring only two database queries. It removes the titles301 requiring only two database queries. It removes the titles
301 for those comments which do not have a "new" subject line302 for those comments which do not have a "new" subject line
303
304 :param for_display: If true, the zeroth comment is given an empty body so
305 that it will be filtered by get_visible_comments.
306 :param slice_info: If not None, defines a list of slices of the comments to
307 retrieve.
302 """308 """
303 chunks = bugtask.bug.getMessageChunks()309 comments = build_comments_from_chunks(bugtask, truncate=truncate,
304 comments = build_comments_from_chunks(chunks, bugtask, truncate=truncate)310 slice_info=slice_info)
311 # TODO: further fat can be shaved off here by limiting the attachments we
312 # query to those that slice_info would include.
305 for attachment in bugtask.bug.attachments_unpopulated:313 for attachment in bugtask.bug.attachments_unpopulated:
306 message_id = attachment.message.id314 message_id = attachment.message.id
307 # All attachments are related to a message, so we can be315 # All attachments are related to a message, so we can be
308 # sure that the BugComment is already created.316 # sure that the BugComment is already created.
309 assert message_id in comments, message_id317 if message_id not in comments:
318 # We are not showing this message.
319 break
310 if attachment.type == BugAttachmentType.PATCH:320 if attachment.type == BugAttachmentType.PATCH:
311 comments[message_id].patches.append(attachment)321 comments[message_id].patches.append(attachment)
312 else:322 else:
@@ -321,6 +331,12 @@
321 # this comment has a new title, so make that the rolling focus331 # this comment has a new title, so make that the rolling focus
322 current_title = comment.title332 current_title = comment.title
323 comment.display_title = True333 comment.display_title = True
334 if for_display and comments and comments[0].index==0:
335 # We show the text of the first comment as the bug description,
336 # or via the special link "View original description", but we want
337 # to display attachments filed together with the bug in the
338 # comment list.
339 comments[0].text_for_display = ''
324 return comments340 return comments
325341
326342
@@ -344,7 +360,8 @@
344360
345 # These two lines are here to fill the ValidPersonOrTeamCache cache,361 # These two lines are here to fill the ValidPersonOrTeamCache cache,
346 # so that checking owner.is_valid_person, when rendering the link,362 # so that checking owner.is_valid_person, when rendering the link,
347 # won't issue a DB query.363 # won't issue a DB query. Note that this should be obsolete now with
364 # getMessagesForView improvements.
348 commenters = set(comment.owner for comment in visible_comments)365 commenters = set(comment.owner for comment in visible_comments)
349 getUtility(IPersonSet).getValidPersons(commenters)366 getUtility(IPersonSet).getValidPersons(commenters)
350367
@@ -686,7 +703,8 @@
686 # This line of code keeps the view's query count down,703 # This line of code keeps the view's query count down,
687 # possibly using witchcraft. It should be rewritten to be704 # possibly using witchcraft. It should be rewritten to be
688 # useful or removed in favour of making other queries more705 # useful or removed in favour of making other queries more
689 # efficient.706 # efficient. The witchcraft is because the subscribers are accessed
707 # in the initial page load, so the data is actually used.
690 if self.user is not None:708 if self.user is not None:
691 list(bug.getSubscribersForPerson(self.user))709 list(bug.getSubscribersForPerson(self.user))
692710
@@ -789,14 +807,8 @@
789 @cachedproperty807 @cachedproperty
790 def comments(self):808 def comments(self):
791 """Return the bugtask's comments."""809 """Return the bugtask's comments."""
792 comments = get_comments_for_bugtask(self.context, truncate=True)810 return get_comments_for_bugtask(self.context, truncate=True,
793 # We show the text of the first comment as the bug description,811 for_display=True)
794 # or via the special link "View original description", but we want
795 # to display attachments filed together with the bug in the
796 # comment list.
797 comments[0].text_for_display = ''
798 assert len(comments) > 0, "A bug should have at least one comment."
799 return comments
800812
801 @cachedproperty813 @cachedproperty
802 def interesting_activity(self):814 def interesting_activity(self):
@@ -834,11 +846,22 @@
834 + config.malone.comments_list_truncate_newest_to846 + config.malone.comments_list_truncate_newest_to
835 < config.malone.comments_list_max_length)847 < config.malone.comments_list_max_length)
836848
837 recent_comments = self.visible_recent_comments_for_display849 if not self.visible_comments_truncated_for_display:
838 oldest_comments = self.visible_oldest_comments_for_display850 comments=self.comments
851 else:
852 # the comment function takes 0-offset counts where comment 0 is
853 # the initial description, so we need to add one to the limits
854 # to adjust.
855 oldest_count = 1 + self.visible_initial_comments
856 new_count = 1 + self.total_comments-self.visible_recent_comments
857 comments = get_comments_for_bugtask(
858 self.context, truncate=True, for_display=True,
859 slice_info=[
860 slice(None, oldest_count), slice(new_count, None)])
861 visible_comments = get_visible_comments(comments)
839862
840 event_groups = group_comments_with_activity(863 event_groups = group_comments_with_activity(
841 comments=chain(oldest_comments, recent_comments),864 comments=visible_comments,
842 activities=self.interesting_activity)865 activities=self.interesting_activity)
843866
844 def group_activities_by_target(activities):867 def group_activities_by_target(activities):
@@ -881,77 +904,61 @@
881904
882 events = map(event_dict, event_groups)905 events = map(event_dict, event_groups)
883906
884 # Insert blank if we're showing only a subset of the comment list.907 # Insert blanks if we're showing only a subset of the comment list.
885 if len(recent_comments) > 0:908 if self.visible_comments_truncated_for_display:
886 # Find the oldest recent comment in the event list.909 # Find the oldest recent comment in the event list.
887 oldest_recent_comment = recent_comments[0]910 index = 0
888 for index, event in enumerate(events):911 prev_comment = None
889 if event.get("comment") is oldest_recent_comment:912 while index < len(events):
890 num_hidden = (913 event = events[index]
891 len(self.visible_comments)914 comment = event.get("comment")
892 - len(oldest_comments)915 if prev_comment is None:
893 - len(recent_comments))916 prev_comment = comment
917 index += 1
918 continue
919 if comment is None:
920 index += 1
921 continue
922 if prev_comment.index + 1 != comment.index:
923 # There is a gap here, record it.
894 separator = {924 separator = {
895 'date': oldest_recent_comment.datecreated,925 'date': prev_comment.datecreated,
896 'num_hidden': num_hidden,926 'num_hidden': comment.index - prev_comment.index
897 }927 }
898 events.insert(index, separator)928 events.insert(index, separator)
899 break929 index += 1
900930 prev_comment = comment
931 index += 1
901 return events932 return events
902933
903 @cachedproperty934 @property
904 def visible_comments(self):935 def visible_initial_comments(self):
905 """All visible comments.936 """How many initial comments are being shown."""
906937 return config.malone.comments_list_truncate_oldest_to
907 See `get_visible_comments` for the definition of a "visible"938
908 comment.939 @property
909 """940 def visible_recent_comments(self):
910 return get_visible_comments(self.comments)941 """How many recent comments are being shown."""
911942 return config.malone.comments_list_truncate_newest_to
912 @cachedproperty943
913 def visible_oldest_comments_for_display(self):944 @cachedproperty
914 """The list of oldest visible comments to be rendered.
915
916 This considers truncating the comment list if there are tons
917 of comments, but also obeys any explicitly requested ways to
918 display comments (currently only "all" is recognised).
919 """
920 show_all = (self.request.form_ng.getOne('comments') == 'all')
921 max_comments = config.malone.comments_list_max_length
922 if show_all or len(self.visible_comments) <= max_comments:
923 return self.visible_comments
924 else:
925 oldest_count = config.malone.comments_list_truncate_oldest_to
926 return self.visible_comments[:oldest_count]
927
928 @cachedproperty
929 def visible_recent_comments_for_display(self):
930 """The list of recent visible comments to be rendered.
931
932 If the number of comments is beyond the maximum threshold, this
933 returns the most recent few comments. If we're under the threshold,
934 then visible_oldest_comments_for_display will be returning the bugs,
935 so this routine will return an empty set to avoid duplication.
936 """
937 show_all = (self.request.form_ng.getOne('comments') == 'all')
938 max_comments = config.malone.comments_list_max_length
939 total = len(self.visible_comments)
940 if show_all or total <= max_comments:
941 return []
942 else:
943 start = total - config.malone.comments_list_truncate_newest_to
944 return self.visible_comments[start:total]
945
946 @property
947 def visible_comments_truncated_for_display(self):945 def visible_comments_truncated_for_display(self):
948 """Whether the visible comment list is truncated for display."""946 """Whether the visible comment list is truncated for display."""
949 return (len(self.visible_comments) >947 show_all = (self.request.form_ng.getOne('comments') == 'all')
950 len(self.visible_oldest_comments_for_display))948 if show_all:
949 return False
950 max_comments = config.malone.comments_list_max_length
951 return self.total_comments > max_comments
952
953 @cachedproperty
954 def total_comments(self):
955 """We count all comments because the db cannot do visibility yet."""
956 return self.context.bug.bug_messages.count() - 1
951957
952 def wasDescriptionModified(self):958 def wasDescriptionModified(self):
953 """Return a boolean indicating whether the description was modified"""959 """Return a boolean indicating whether the description was modified"""
954 return self.comments[0].text_contents != self.context.bug.description960 return (self.context.bug.indexed_messages[0].text_contents !=
961 self.context.bug.description)
955962
956 @cachedproperty963 @cachedproperty
957 def linked_branches(self):964 def linked_branches(self):
958965
=== modified file 'lib/lp/bugs/browser/tests/bug-views.txt'
--- lib/lp/bugs/browser/tests/bug-views.txt 2010-12-21 20:57:11 +0000
+++ lib/lp/bugs/browser/tests/bug-views.txt 2011-02-16 20:08:14 +0000
@@ -206,16 +206,17 @@
206 True206 True
207207
208The displayable comments for a bug can be obtained from the view208The displayable comments for a bug can be obtained from the view
209property visible_oldest_comments_for_display.209property activity_and_comments.
210210
211 >>> viewable_comments = ubuntu_bugview.visible_oldest_comments_for_display211 >>> comments = [event.get('comment') for event in
212 ... ubuntu_bugview.activity_and_comments if event.get('comment')]
212213
213Because we omit the first comment, and because the third comment is214Because we omit the first comment, and because the third comment is
214indentical to the second, we really only display one comment:215indentical to the second, we really only display one comment:
215216
216 >>> print len(viewable_comments)217 >>> print len(comments)
217 1218 1
218 >>> [(c.index, c.owner.name, c.text_contents) for c in viewable_comments]219 >>> [(c.index, c.owner.name, c.text_contents) for c in comments]
219 [(1, u'name16', u'I can reproduce this bug.')]220 [(1, u'name16', u'I can reproduce this bug.')]
220221
221(Unregister our listener, since we no longer need it.)222(Unregister our listener, since we no longer need it.)
222223
=== modified file 'lib/lp/bugs/browser/tests/test_bugcomment.py'
--- lib/lp/bugs/browser/tests/test_bugcomment.py 2010-12-21 15:31:17 +0000
+++ lib/lp/bugs/browser/tests/test_bugcomment.py 2011-02-16 20:08:14 +0000
@@ -19,11 +19,11 @@
1919
20class BugActivityStub:20class BugActivityStub:
2121
22 def __init__(self, datechanged, person=None):22 def __init__(self, datechanged, owner=None):
23 self.datechanged = datechanged23 self.datechanged = datechanged
24 if person is None:24 if owner is None:
25 person = PersonStub()25 owner = PersonStub()
26 self.person = person26 self.person = owner
2727
28 def __repr__(self):28 def __repr__(self):
29 return "BugActivityStub(%r, %r)" % (29 return "BugActivityStub(%r, %r)" % (
@@ -32,16 +32,18 @@
3232
33class BugCommentStub:33class BugCommentStub:
3434
35 def __init__(self, datecreated, owner=None):35 def __init__(self, datecreated, index, owner=None):
36 self.datecreated = datecreated36 self.datecreated = datecreated
37 if owner is None:37 if owner is None:
38 owner = PersonStub()38 owner = PersonStub()
39 self.owner = owner39 self.owner = owner
40 self.activity = []40 self.activity = []
41 self.index = index
4142
42 def __repr__(self):43 def __repr__(self):
43 return "BugCommentStub(%r, %r)" % (44 return "BugCommentStub(%r, %d, %r)" % (
44 self.datecreated.strftime('%Y-%m-%d--%H%M'), self.owner)45 self.datecreated.strftime('%Y-%m-%d--%H%M'),
46 self.index, self.owner)
4547
4648
47class PersonStub:49class PersonStub:
@@ -61,8 +63,8 @@
61 def setUp(self):63 def setUp(self):
62 super(TestGroupCommentsWithActivities, self).setUp()64 super(TestGroupCommentsWithActivities, self).setUp()
63 self.now = datetime.now(utc)65 self.now = datetime.now(utc)
64 self.timestamps = (66 self.time_index = (
65 self.now + timedelta(minutes=counter)67 (self.now + timedelta(minutes=counter), counter)
66 for counter in count(1))68 for counter in count(1))
6769
68 def group(self, comments, activities):70 def group(self, comments, activities):
@@ -79,7 +81,7 @@
79 # When no activities are passed in, and the comments passed in don't81 # When no activities are passed in, and the comments passed in don't
80 # have any common actors, no grouping is possible.82 # have any common actors, no grouping is possible.
81 comments = [83 comments = [
82 BugCommentStub(next(self.timestamps))84 BugCommentStub(*next(self.time_index))
83 for number in xrange(5)]85 for number in xrange(5)]
84 self.assertEqual(86 self.assertEqual(
85 comments, self.group(comments=comments, activities=[]))87 comments, self.group(comments=comments, activities=[]))
@@ -88,7 +90,7 @@
88 # When no comments are passed in, and the activities passed in don't90 # When no comments are passed in, and the activities passed in don't
89 # have any common actors, no grouping is possible.91 # have any common actors, no grouping is possible.
90 activities = [92 activities = [
91 BugActivityStub(next(self.timestamps))93 BugActivityStub(next(self.time_index)[0])
92 for number in xrange(5)]94 for number in xrange(5)]
93 self.assertEqual(95 self.assertEqual(
94 [[activity] for activity in activities], self.group(96 [[activity] for activity in activities], self.group(
@@ -97,13 +99,13 @@
97 def test_no_common_actor(self):99 def test_no_common_actor(self):
98 # When each activities and comment given has a different actor then no100 # When each activities and comment given has a different actor then no
99 # grouping is possible.101 # grouping is possible.
100 activity1 = BugActivityStub(next(self.timestamps))102 activity1 = BugActivityStub(next(self.time_index)[0])
101 comment1 = BugCommentStub(next(self.timestamps))103 comment1 = BugCommentStub(*next(self.time_index))
102 activity2 = BugActivityStub(next(self.timestamps))104 activity2 = BugActivityStub(next(self.time_index)[0])
103 comment2 = BugCommentStub(next(self.timestamps))105 comment2 = BugCommentStub(*next(self.time_index))
104106
105 activities = set([activity1, activity2])107 activities = set([activity1, activity2])
106 comments = set([comment1, comment2])108 comments = list([comment1, comment2])
107109
108 self.assertEqual(110 self.assertEqual(
109 [[activity1], comment1, [activity2], comment2],111 [[activity1], comment1, [activity2], comment2],
@@ -113,8 +115,8 @@
113 # An activity shortly after a comment by the same person is grouped115 # An activity shortly after a comment by the same person is grouped
114 # into the comment.116 # into the comment.
115 actor = PersonStub()117 actor = PersonStub()
116 comment = BugCommentStub(next(self.timestamps), actor)118 comment = BugCommentStub(*next(self.time_index), owner=actor)
117 activity = BugActivityStub(next(self.timestamps), actor)119 activity = BugActivityStub(next(self.time_index)[0], owner=actor)
118 grouped = self.group(comments=[comment], activities=[activity])120 grouped = self.group(comments=[comment], activities=[activity])
119 self.assertEqual([comment], grouped)121 self.assertEqual([comment], grouped)
120 self.assertEqual([activity], comment.activity)122 self.assertEqual([activity], comment.activity)
@@ -123,8 +125,8 @@
123 # An activity shortly before a comment by the same person is grouped125 # An activity shortly before a comment by the same person is grouped
124 # into the comment.126 # into the comment.
125 actor = PersonStub()127 actor = PersonStub()
126 activity = BugActivityStub(next(self.timestamps), actor)128 activity = BugActivityStub(next(self.time_index)[0], owner=actor)
127 comment = BugCommentStub(next(self.timestamps), actor)129 comment = BugCommentStub(*next(self.time_index), owner=actor)
128 grouped = self.group(comments=[comment], activities=[activity])130 grouped = self.group(comments=[comment], activities=[activity])
129 self.assertEqual([comment], grouped)131 self.assertEqual([comment], grouped)
130 self.assertEqual([activity], comment.activity)132 self.assertEqual([activity], comment.activity)
@@ -133,9 +135,9 @@
133 # Activities shortly before and after a comment are grouped into the135 # Activities shortly before and after a comment are grouped into the
134 # comment's activity.136 # comment's activity.
135 actor = PersonStub()137 actor = PersonStub()
136 activity1 = BugActivityStub(next(self.timestamps), actor)138 activity1 = BugActivityStub(next(self.time_index)[0], owner=actor)
137 comment = BugCommentStub(next(self.timestamps), actor)139 comment = BugCommentStub(*next(self.time_index), owner=actor)
138 activity2 = BugActivityStub(next(self.timestamps), actor)140 activity2 = BugActivityStub(next(self.time_index)[0], owner=actor)
139 grouped = self.group(141 grouped = self.group(
140 comments=[comment], activities=[activity1, activity2])142 comments=[comment], activities=[activity1, activity2])
141 self.assertEqual([comment], grouped)143 self.assertEqual([comment], grouped)
@@ -146,7 +148,7 @@
146 # Anything outside of that window is considered separate.148 # Anything outside of that window is considered separate.
147 actor = PersonStub()149 actor = PersonStub()
148 activities = [150 activities = [
149 BugActivityStub(next(self.timestamps), actor)151 BugActivityStub(next(self.time_index)[0], owner=actor)
150 for count in xrange(8)]152 for count in xrange(8)]
151 grouped = self.group(comments=[], activities=activities)153 grouped = self.group(comments=[], activities=activities)
152 self.assertEqual(2, len(grouped))154 self.assertEqual(2, len(grouped))
@@ -156,8 +158,8 @@
156 def test_two_comments_by_common_actor(self):158 def test_two_comments_by_common_actor(self):
157 # Only one comment will ever appear in a group.159 # Only one comment will ever appear in a group.
158 actor = PersonStub()160 actor = PersonStub()
159 comment1 = BugCommentStub(next(self.timestamps), actor)161 comment1 = BugCommentStub(*next(self.time_index), owner=actor)
160 comment2 = BugCommentStub(next(self.timestamps), actor)162 comment2 = BugCommentStub(*next(self.time_index), owner=actor)
161 grouped = self.group(comments=[comment1, comment2], activities=[])163 grouped = self.group(comments=[comment1, comment2], activities=[])
162 self.assertEqual([comment1, comment2], grouped)164 self.assertEqual([comment1, comment2], grouped)
163165
@@ -165,11 +167,11 @@
165 # Activity gets associated with earlier comment when all other factors167 # Activity gets associated with earlier comment when all other factors
166 # are unchanging.168 # are unchanging.
167 actor = PersonStub()169 actor = PersonStub()
168 activity1 = BugActivityStub(next(self.timestamps), actor)170 activity1 = BugActivityStub(next(self.time_index)[0], owner=actor)
169 comment1 = BugCommentStub(next(self.timestamps), actor)171 comment1 = BugCommentStub(*next(self.time_index), owner=actor)
170 activity2 = BugActivityStub(next(self.timestamps), actor)172 activity2 = BugActivityStub(next(self.time_index)[0], owner=actor)
171 comment2 = BugCommentStub(next(self.timestamps), actor)173 comment2 = BugCommentStub(*next(self.time_index), owner=actor)
172 activity3 = BugActivityStub(next(self.timestamps), actor)174 activity3 = BugActivityStub(next(self.time_index)[0], owner=actor)
173 grouped = self.group(175 grouped = self.group(
174 comments=[comment1, comment2],176 comments=[comment1, comment2],
175 activities=[activity1, activity2, activity3])177 activities=[activity1, activity2, activity3])
176178
=== modified file 'lib/lp/bugs/configure.zcml'
--- lib/lp/bugs/configure.zcml 2011-02-14 00:15:22 +0000
+++ lib/lp/bugs/configure.zcml 2011-02-16 20:08:14 +0000
@@ -734,7 +734,7 @@
734 hasBranch734 hasBranch
735 security_related735 security_related
736 tags736 tags
737 getMessageChunks737 getMessagesForView
738 isSubscribedToDupes738 isSubscribedToDupes
739 getSubscribersFromDuplicates739 getSubscribersFromDuplicates
740 getSubscriptionsFromDuplicates740 getSubscriptionsFromDuplicates
741741
=== modified file 'lib/lp/bugs/doc/bug.txt'
--- lib/lp/bugs/doc/bug.txt 2011-02-02 09:55:25 +0000
+++ lib/lp/bugs/doc/bug.txt 2011-02-16 20:08:14 +0000
@@ -1116,15 +1116,15 @@
1116------------1116------------
11171117
1118A bug comment is actually made up of a number of chunks. The1118A bug comment is actually made up of a number of chunks. The
1119IBug.getMessageChunks() method allows you to retreive these chunks in a1119IBug.getMessagesForView() method allows you to get all the data needed to
1120single shot.1120show messages in the bugtask index template in one shot.
11211121
1122 >>> from canonical.ftests.pgsql import CursorWrapper1122 >>> from canonical.ftests.pgsql import CursorWrapper
1123 >>> CursorWrapper.record_sql = True1123 >>> CursorWrapper.record_sql = True
1124 >>> queries = len(CursorWrapper.last_executed_sql)1124 >>> queries = len(CursorWrapper.last_executed_sql)
11251125
1126 >>> chunks = bug_two.getMessageChunks()1126 >>> chunks = bug_two.getMessagesForView(None)
1127 >>> for chunk in sorted(chunks, key=lambda x:x.id):1127 >>> for _, _1, chunk in sorted(chunks, key=lambda x:x[2].id):
1128 ... (chunk.id, chunk.message.id, chunk.message.owner.id,1128 ... (chunk.id, chunk.message.id, chunk.message.owner.id,
1129 ... chunk.content[:30])1129 ... chunk.content[:30])
1130 (4, 1, 16, u'Problem exists between chair a')1130 (4, 1, 16, u'Problem exists between chair a')
@@ -1141,6 +1141,24 @@
1141 >>> len(CursorWrapper.last_executed_sql) - queries <= 31141 >>> len(CursorWrapper.last_executed_sql) - queries <= 3
1142 True1142 True
11431143
1144getMessagesForView supports slicing operations:
1145
1146 >>> def message_ids(slices):
1147 ... chunks = bug_two.getMessagesForView(slices)
1148 ... return sorted(set(
1149 ... bugmessage.index for bugmessage, _, _1 in chunks))
1150 >>> message_ids([slice(1, 2)])
1151 [1]
1152
1153We use this to get the first N and last M messages in big bugs:
1154 >>> message_ids([slice(None, 1), slice(2, None)])
1155 [0, 2]
1156
1157We also support a negative lookup though the bug view does not use that at the
1158moment:
1159 >>> message_ids([slice(None, 1), slice(-1, None)])
1160 [0, 2]
1161
1144Bugs have a special attribute, `indexed_messages` which returns the collection1162Bugs have a special attribute, `indexed_messages` which returns the collection
1145of messages, each decorated with the index of that message in its context1163of messages, each decorated with the index of that message in its context
1146(the bug) and the primary bug task. This is used for providing an efficient1164(the bug) and the primary bug task. This is used for providing an efficient
11471165
=== modified file 'lib/lp/bugs/doc/bugcomment.txt'
--- lib/lp/bugs/doc/bugcomment.txt 2010-12-21 15:07:26 +0000
+++ lib/lp/bugs/doc/bugcomment.txt 2011-02-16 20:08:14 +0000
@@ -14,7 +14,7 @@
14The bug's description starts out identical to its first comment. In the course14The bug's description starts out identical to its first comment. In the course
15of a bug's life, the description may be updated, but the first comment stays15of a bug's life, the description may be updated, but the first comment stays
16intact. To improve readability, we never display the first comment in the bug16intact. To improve readability, we never display the first comment in the bug
17page, and this is why visible_oldest_comments_for_display doesn't include it:17page, and this is why the event stream elides it doesn't include it:
1818
19 >>> bug_ten = getUtility(IBugSet).get(10)19 >>> bug_ten = getUtility(IBugSet).get(10)
20 >>> bug_ten_bugtask = bug_ten.bugtasks[0]20 >>> bug_ten_bugtask = bug_ten.bugtasks[0]
@@ -22,8 +22,10 @@
22 >>> bug_view = getMultiAdapter(22 >>> bug_view = getMultiAdapter(
23 ... (bug_ten_bugtask, LaunchpadTestRequest()), name='+index')23 ... (bug_ten_bugtask, LaunchpadTestRequest()), name='+index')
24 >>> bug_view.initialize()24 >>> bug_view.initialize()
25 >>> rendered_comments = bug_view.visible_oldest_comments_for_display25 >>> def bug_comments(bug_view):
26 >>> [bug_comment.index for bug_comment in rendered_comments]26 ... return [event.get('comment') for event in
27 ... bug_view.activity_and_comments if event.get('comment')]
28 >>> [bug_comment.index for bug_comment in bug_comments(bug_view)]
27 [1]29 [1]
2830
29In the case of bug 10, the first comment is identical to the bug's31In the case of bug 10, the first comment is identical to the bug's
@@ -42,11 +44,10 @@
42stored as bug attchments of the first comment. Similary, the first44stored as bug attchments of the first comment. Similary, the first
43comment of bugs imported from other bug trackers may have attachments.45comment of bugs imported from other bug trackers may have attachments.
44We display these attachments in the comment section of the Web UI,46We display these attachments in the comment section of the Web UI,
45hence visible_oldest_comments_for_display contains the first comment, if47hence the activity stream contains the first comment, if it has attachments.
46it has attachments.
4748
48Currently, the first comment of bug 11 has no attachments, hence49Currently, the first comment of bug 11 has no attachments, hence
49BugTaskView.visible_oldest_comments_for_display does not return the50BugTaskView.activity_and_comments does not return the
50first comment.51first comment.
5152
52 >>> bug_11 = getUtility(IBugSet).get(11)53 >>> bug_11 = getUtility(IBugSet).get(11)
@@ -54,12 +55,12 @@
54 >>> bug_11_view = getMultiAdapter(55 >>> bug_11_view = getMultiAdapter(
55 ... (bug_11_bugtask, LaunchpadTestRequest()), name='+index')56 ... (bug_11_bugtask, LaunchpadTestRequest()), name='+index')
56 >>> bug_11_view.initialize()57 >>> bug_11_view.initialize()
57 >>> rendered_comments = bug_11_view.visible_oldest_comments_for_display58 >>> rendered_comments = bug_comments(bug_11_view)
58 >>> [bug_comment.index for bug_comment in rendered_comments]59 >>> [bug_comment.index for bug_comment in rendered_comments]
59 [1, 2, 3, 4, 5, 6]60 [1, 2, 3, 4, 5, 6]
6061
61If we add an attachment to the first comment, this comment is included62If we add an attachment to the first comment, this comment is included
62in visible_oldest_comments_for_display...63in activity_and_comments...
6364
64 >>> import StringIO65 >>> import StringIO
65 >>> login("test@canonical.com")66 >>> login("test@canonical.com")
@@ -71,7 +72,7 @@
71 >>> bug_11_view = getMultiAdapter(72 >>> bug_11_view = getMultiAdapter(
72 ... (bug_11_bugtask, LaunchpadTestRequest()), name='+index')73 ... (bug_11_bugtask, LaunchpadTestRequest()), name='+index')
73 >>> bug_11_view.initialize()74 >>> bug_11_view.initialize()
74 >>> rendered_comments = bug_11_view.visible_oldest_comments_for_display75 >>> rendered_comments = bug_comments(bug_11_view)
75 >>> [bug_comment.index for bug_comment in rendered_comments]76 >>> [bug_comment.index for bug_comment in rendered_comments]
76 [0, 1, 2, 3, 4, 5, 6]77 [0, 1, 2, 3, 4, 5, 6]
77 >>>78 >>>
@@ -112,13 +113,13 @@
112comments have been truncated:113comments have been truncated:
113114
114 >>> [(bug_comment.index, bug_comment.was_truncated)115 >>> [(bug_comment.index, bug_comment.was_truncated)
115 ... for bug_comment in bug_view.visible_oldest_comments_for_display]116 ... for bug_comment in bug_comments(bug_view)]
116 [(1, True), (2, True)]117 [(1, True), (2, True)]
117118
118Let's take a closer look at one of the truncated comments. We can119Let's take a closer look at one of the truncated comments. We can
119display the truncated text using text_for_display:120display the truncated text using text_for_display:
120121
121 >>> comment_one = bug_view.visible_oldest_comments_for_display[0]122 >>> comment_one = bug_comments(bug_view)[0]
122 >>> print comment_one.text_for_display #doctest: -ELLIPSIS123 >>> print comment_one.text_for_display #doctest: -ELLIPSIS
123 This would be a real...124 This would be a real...
124125
@@ -222,7 +223,7 @@
222 ... (bug_three_bugtask, LaunchpadTestRequest()), name='+index')223 ... (bug_three_bugtask, LaunchpadTestRequest()), name='+index')
223 >>> bug_view.initialize()224 >>> bug_view.initialize()
224 >>> [(c.index, c.title, c.text_for_display)225 >>> [(c.index, c.title, c.text_for_display)
225 ... for c in bug_view.visible_oldest_comments_for_display]226 ... for c in bug_comments(bug_view)]
226 [(1, u'Hi', u'Hello there'),227 [(1, u'Hi', u'Hello there'),
227 (3, u'Ho', u'Hello there'),228 (3, u'Ho', u'Hello there'),
228 (5, u'Ho', u'Hello there'),229 (5, u'Ho', u'Hello there'),
@@ -236,9 +237,8 @@
236comments: visible_comments_truncated_for_display.237comments: visible_comments_truncated_for_display.
237238
238This is normally false, but for bugs with lots of comments, the239This is normally false, but for bugs with lots of comments, the
239visible_comments_truncated_for_display property becomes True and the240visible_comments_truncated_for_display property becomes True and the activity
240visible_oldest_comments_for_display list is truncated to just the oldest241stream has the middle comments elided.
241comments.
242242
243The configuration keys comments_list_max_length,243The configuration keys comments_list_max_length,
244comments_list_truncate_oldest_to, and comments_list_truncate_newest_to244comments_list_truncate_oldest_to, and comments_list_truncate_newest_to
@@ -272,24 +272,14 @@
272 >>> bug = factory.makeBug()272 >>> bug = factory.makeBug()
273 >>> add_comments(bug, 9)273 >>> add_comments(bug, 9)
274274
275If we create a view for this, we can see that all 9 comments are275If we create a view for this, we can see that truncation is disabled.
276visible, and we can see that the list has not been truncated.
277276
278 >>> bug_view = getMultiAdapter(277 >>> bug_view = getMultiAdapter(
279 ... (bug.default_bugtask, LaunchpadTestRequest()), name='+index')278 ... (bug.default_bugtask, LaunchpadTestRequest()), name='+index')
280 >>> bug_view.initialize()279 >>> bug_view.initialize()
281
282 >>> len(bug_view.visible_oldest_comments_for_display)
283 9
284 >>> bug_view.visible_comments_truncated_for_display280 >>> bug_view.visible_comments_truncated_for_display
285 False281 False
286282
287When comments aren't being truncated and empty set will be returned by
288visible_recent_comments_for_display.
289
290 >>> len(bug_view.visible_recent_comments_for_display)
291 0
292
293Add two more comments, and the list will be truncated to only 8 total.283Add two more comments, and the list will be truncated to only 8 total.
294284
295 >>> add_comments(bug, 2)285 >>> add_comments(bug, 2)
@@ -300,9 +290,9 @@
300290
301 >>> bug_view.visible_comments_truncated_for_display291 >>> bug_view.visible_comments_truncated_for_display
302 True292 True
303 >>> len(bug_view.visible_oldest_comments_for_display)293 >>> bug_view.visible_initial_comments
304 3294 3
305 >>> len(bug_view.visible_recent_comments_for_display)295 >>> bug_view.visible_recent_comments
306 5296 5
307297
308The display of all comments can be requested with a form parameter.298The display of all comments can be requested with a form parameter.
@@ -312,8 +302,6 @@
312 ... (bug.default_bugtask, request), name='+index')302 ... (bug.default_bugtask, request), name='+index')
313 >>> bug_view.initialize()303 >>> bug_view.initialize()
314304
315 >>> len(bug_view.visible_oldest_comments_for_display)
316 11
317 >>> bug_view.visible_comments_truncated_for_display305 >>> bug_view.visible_comments_truncated_for_display
318 False306 False
319307
320308
=== modified file 'lib/lp/bugs/interfaces/bug.py'
--- lib/lp/bugs/interfaces/bug.py 2011-02-16 13:42:51 +0000
+++ lib/lp/bugs/interfaces/bug.py 2011-02-16 20:08:14 +0000
@@ -695,8 +695,15 @@
695 remote bug tracker, if it's an imported comment.695 remote bug tracker, if it's an imported comment.
696 """696 """
697697
698 def getMessageChunks():698 def getMessagesForView(slice_info):
699 """Return MessageChunks corresponding to comments made on this bug"""699 """Return BugMessage,Message,MessageChunks for renderinger.
700
701 This eager loads message.owner validity associated with the
702 bugmessages.
703
704 :param slice_info: Either None or a list of slices to constraint the
705 returned rows. The step parameter in each slice is ignored.
706 """
700707
701 def getNullBugTask(product=None, productseries=None,708 def getNullBugTask(product=None, productseries=None,
702 sourcepackagename=None, distribution=None,709 sourcepackagename=None, distribution=None,
703710
=== modified file 'lib/lp/bugs/interfaces/bugmessage.py'
--- lib/lp/bugs/interfaces/bugmessage.py 2011-01-31 08:08:04 +0000
+++ lib/lp/bugs/interfaces/bugmessage.py 2011-02-16 20:08:14 +0000
@@ -51,6 +51,7 @@
51 message = Object(schema=IMessage, title=u"The message.")51 message = Object(schema=IMessage, title=u"The message.")
52 bugwatch = Object(schema=IBugWatch,52 bugwatch = Object(schema=IBugWatch,
53 title=u"A bugwatch to which the message pertains.")53 title=u"A bugwatch to which the message pertains.")
54 bugwatchID = Int(title=u'The bugwatch id.', readonly=True)
54 remote_comment_id = TextLine(55 remote_comment_id = TextLine(
55 title=u"The id this comment has in the bugwatch's bug tracker.")56 title=u"The id this comment has in the bugwatch's bug tracker.")
56 visible = Bool(title=u"This message is visible or not.", required=False,57 visible = Bool(title=u"This message is visible or not.", required=False,
5758
=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py 2011-02-14 00:15:22 +0000
+++ lib/lp/bugs/model/bug.py 2011-02-16 20:08:14 +0000
@@ -437,6 +437,8 @@
437 @property437 @property
438 def indexed_messages(self):438 def indexed_messages(self):
439 """See `IMessageTarget`."""439 """See `IMessageTarget`."""
440 # Note that this is a decorated result set, so will cache its value (in
441 # the absence of slices)
440 return self._indexed_messages(include_content=True)442 return self._indexed_messages(include_content=True)
441443
442 def _indexed_messages(self, include_content=False, include_parents=True):444 def _indexed_messages(self, include_content=False, include_parents=True):
@@ -1369,39 +1371,55 @@
1369 """See `IBug`."""1371 """See `IBug`."""
1370 return self._question_from_bug1372 return self._question_from_bug
13711373
1372 def getMessageChunks(self):1374 def getMessagesForView(self, slice_info):
1373 """See `IBug`."""1375 """See `IBug`."""
1374 query = """1376 # Note that this function and indexed_messages have significant overlap
1375 Message.id = MessageChunk.message AND1377 # and could stand to be refactored.
1376 BugMessage.message = Message.id AND1378 slices = []
1377 BugMessage.bug = %s1379 if slice_info is not None:
1378 """ % sqlvalues(self)1380 # NB: This isn't a full implementation of the slice protocol,
13791381 # merely the bits needed by BugTask:+index.
1380 chunks = MessageChunk.select(query,1382 for slice in slice_info:
1381 clauseTables=["BugMessage", "Message"],1383 if not slice.start:
1382 # XXX: kiko 2006-09-16 bug=60745:1384 assert slice.stop > 0, slice.stop
1383 # There is an issue that presents itself1385 slices.append(BugMessage.index < slice.stop)
1384 # here if we prejoin message.owner: because Message is1386 elif not slice.stop:
1385 # already in the clauseTables, the SQL generated joins1387 if slice.start < 0:
1386 # against message twice and that causes the results to1388 # If the high index is N, a slice of -1: should
1387 # break.1389 # return index N - so we need to add one to the
1388 prejoinClauseTables=["Message"],1390 # range.
1389 # Note the ordering by Message.id here; while datecreated in1391 slices.append(BugMessage.index >= SQL(
1390 # production is never the same, it can be in the test suite.1392 "(select max(index) from "
1391 orderBy=["Message.datecreated", "Message.id",1393 "bugmessage where bug=%s) + 1 - %s" % (
1392 "MessageChunk.sequence"])1394 sqlvalues(self.id, -slice.start))))
1393 chunks = list(chunks)1395 else:
13941396 slices.append(BugMessage.index >= slice.start)
1395 # Since we can't prejoin, cache all people at once so we don't1397 else:
1396 # have to do it while rendering, which is a big deal for bugs1398 slices.append(And(BugMessage.index >= slice.start,
1397 # with a million comments.1399 BugMessage.index < slice.stop))
1398 owner_ids = set()1400 if slices:
1399 for chunk in chunks:1401 ranges = [Or(*slices)]
1400 if chunk.message.ownerID:1402 else:
1401 owner_ids.add(str(chunk.message.ownerID))1403 ranges = []
1402 list(Person.select("ID in (%s)" % ",".join(owner_ids)))1404 # We expect:
14031405 # 1 bugmessage -> 1 message -> small N chunks. For now, using a wide
1404 return chunks1406 # query seems fine as we have to join out from bugmessage anyway.
1407 result = Store.of(self).find((BugMessage, Message, MessageChunk),
1408 Message.id==MessageChunk.messageID,
1409 BugMessage.messageID==Message.id,
1410 BugMessage.bug==self.id,
1411 *ranges)
1412 result.order_by(BugMessage.index, MessageChunk.sequence)
1413 def eager_load_owners(rows):
1414 owners = set()
1415 for row in rows:
1416 owners.add(row[1].ownerID)
1417 owners.discard(None)
1418 if not owners:
1419 return
1420 PersonSet().getPrecachedPersonsFromIDs(owners,
1421 need_validity=True)
1422 return DecoratedResultSet(result, pre_iter_hook=eager_load_owners)
14051423
1406 def getNullBugTask(self, product=None, productseries=None,1424 def getNullBugTask(self, product=None, productseries=None,
1407 sourcepackagename=None, distribution=None,1425 sourcepackagename=None, distribution=None,
14081426
=== modified file 'lib/lp/bugs/templates/bugcomment-macros.pt'
--- lib/lp/bugs/templates/bugcomment-macros.pt 2010-07-01 21:53:22 +0000
+++ lib/lp/bugs/templates/bugcomment-macros.pt 2011-02-16 20:08:14 +0000
@@ -66,7 +66,7 @@
66 class="sprite retry"66 class="sprite retry"
67 style="white-space: nowrap">67 style="white-space: nowrap">
68 view all <span68 view all <span
69 tal:replace="view/visible_comments/count:len"69 tal:replace="view/total_comments"
70 /> comments</a>70 /> comments</a>
71 </td>71 </td>
72 </tr>72 </tr>
7373
=== modified file 'lib/lp/bugs/templates/bugtask-index.pt'
--- lib/lp/bugs/templates/bugtask-index.pt 2011-02-15 18:16:28 +0000
+++ lib/lp/bugs/templates/bugtask-index.pt 2011-02-16 20:08:14 +0000
@@ -320,16 +320,16 @@
320 tal:condition="view/visible_comments_truncated_for_display">320 tal:condition="view/visible_comments_truncated_for_display">
321 <div class="informational message">321 <div class="informational message">
322 Displaying first <span322 Displaying first <span
323 tal:replace="view/visible_oldest_comments_for_display/count:len">23</span>323 tal:replace="view/visible_initial_comments">23</span>
324 and last <span324 and last <span
325 tal:replace="view/visible_recent_comments_for_display/count:len">32</span>325 tal:replace="view/visible_recent_comments">32</span>
326 comments.326 comments.
327 <tal:what-next327 <tal:what-next
328 define="view_all_href328 define="view_all_href
329 string:${context/fmt:url}?comments=all">329 string:${context/fmt:url}?comments=all">
330 <a href="#" tal:attributes="href view_all_href">330 <a href="#" tal:attributes="href view_all_href">
331 View all <span331 View all <span
332 tal:replace="view/visible_comments/count:len" />332 tal:replace="view/total_comments" />
333 comments</a> or <a href="#" tal:attributes="href333 comments</a> or <a href="#" tal:attributes="href
334 view_all_href">add a comment</a>.334 view_all_href">add a comment</a>.
335 </tal:what-next>335 </tal:what-next>