Merge lp:~wallyworld/launchpad/remove-decoratedbug into lp:launchpad

Proposed by Ian Booth
Status: Merged
Merged at revision: 12539
Proposed branch: lp:~wallyworld/launchpad/remove-decoratedbug
Merge into: lp:launchpad
Diff against target: 718 lines (+228/-171)
14 files modified
lib/lp/code/browser/branch.py (+1/-1)
lib/lp/code/browser/branchmergeproposal.py (+4/-13)
lib/lp/code/browser/decorations.py (+0/-83)
lib/lp/code/browser/tests/test_branch.py (+3/-2)
lib/lp/code/browser/tests/test_branchmergeproposal.py (+12/-1)
lib/lp/code/configure.zcml (+1/-0)
lib/lp/code/interfaces/branchcollection.py (+6/-2)
lib/lp/code/interfaces/branchmergeproposal.py (+3/-0)
lib/lp/code/model/branch.py (+27/-19)
lib/lp/code/model/branchcollection.py (+49/-17)
lib/lp/code/model/branchmergeproposal.py (+16/-2)
lib/lp/code/model/tests/test_branchcollection.py (+59/-18)
lib/lp/code/model/tests/test_branchmergeproposal.py (+41/-7)
lib/lp/code/templates/branch-macros.pt (+6/-6)
To merge this branch: bzr merge lp:~wallyworld/launchpad/remove-decoratedbug
Reviewer Review Type Date Requested Status
Tim Penhey (community) Approve
Review via email: mp+52003@code.launchpad.net

Commit message

Remove the now obsolete DecoratedBug class and use Branch.getRelatedBugTasks() when displaying mp bugs

Description of the change

This branch is the first of 3. The bug which started it all said "Cannot view branch with revisions fixing private bugs". Fixing the root cause is to ensure that private bugs are not exposed via APIs on branch merge proposals.
The core work is done here: remove the now obsolete DecoratedBug class and the linked_bugs property on DecoratedBranch and add a new getRelatedBugTasks() API on branch merge proposal. The subsequent branches expose the new API on the webservice and do some more work to remove the merge proposal related_bugs property.

== Implementation ==

The basis of the approach is to use the recently added Branch.getRelatedBugTasks() API instead of DecoratedBranch.linked_bugs and DecoratedBug.
A new getRelatedBugTasks() API is added to IBranchMergeProposal. Some refactoring is done to use the new APIs. Some common functionality from the Branch.getRelatedBugTasks() was moved to a new filter_one_task_per_bug() helper method.

== Demo and QA ==

Create a merge proposal for a branch which is linked to at least one private bug. The private bug should not show on the merge proposal index page for an unauthorised user.
Merge the source branch of the merge proposal into another branch. The target branch's index page should show the recent revisions with the merge proposal but the private bug should not show for unauthorised users.

== Tests ==

Some new tests were added:
  - browser.tests.test_branchmergeproposal:
      test_linked_bugs_excludes_private_bugs()
  - model.tests.test_branchcollection:
      test_some_revisions_with_private_bugs()
  - model.tests.test_branchmergeproposal:
      test_related_bugtasks_includes_source_bugtasks()
      test_related_bugtasks_excludes_target_bugs()
      test_related_bugtasks_excludes_mutual_bugs()
      test_related_bugtasks_excludes_private_bugs()

Existing test_branchmergeproposal, test_branchcollection, test_branch tests were also run.

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/code/configure.zcml
  lib/lp/code/browser/branch.py
  lib/lp/code/browser/branchmergeproposal.py
  lib/lp/code/browser/decorations.py
  lib/lp/code/browser/tests/test_branch.py
  lib/lp/code/browser/tests/test_branchmergeproposal.py
  lib/lp/code/interfaces/branchcollection.py
  lib/lp/code/interfaces/branchmergeproposal.py
  lib/lp/code/model/branch.py
  lib/lp/code/model/branchcollection.py
  lib/lp/code/model/branchmergeproposal.py
  lib/lp/code/model/tests/test_branchcollection.py
  lib/lp/code/model/tests/test_branchmergeproposal.py
  lib/lp/code/templates/branch-macros.pt

./lib/lp/code/interfaces/branchcollection.py
      50: E301 expected 1 blank line, found 2
./lib/lp/code/model/branch.py
     317: Line exceeds 78 characters.
     739: Line exceeds 78 characters.

To post a comment you must log in.
Revision history for this message
Tim Penhey (thumper) wrote :

In the function filter_one_task_per_bug, I think it would be better
to use the enumerate function to create values for the sorting, rather
that the length of the list. Also, remembering bug instead of traversing
over the attributes every time. Also indexing [1] looks kinda random.
So...

from collections import namedtuple

TaskIndex = namedtuple('TaskIndex', 'sort_order task')
for pos, task in enumerate(tasks):
    bug = task.bug
    if bug not in order:
        order[bug] = TaskIndex(pos, None)
    if task.target == bugtarget:
        order[bug].task = task
for task in tasks:
    index = order[task.bug]
    if index.task is None:
        index.task = task
return [index.task for index in sorted(order.values)]

That's all I have to say :-), and you still have more additions than
removals.

review: Approve
Revision history for this message
Ian Booth (wallyworld) wrote :

I extracted the filter_one_task_per_bug() code directly from the
getRelatedBugTasks() method so I could reuse it, hence I didn't pay too
much attention to it. I'll fix the issues you raise though.

On 04/03/11 13:08, Tim Penhey wrote:
> Review: Approve
> In the function filter_one_task_per_bug, I think it would be better
> to use the enumerate function to create values for the sorting, rather
> that the length of the list. Also, remembering bug instead of traversing
> over the attributes every time. Also indexing [1] looks kinda random.
> So...
>
> from collections import namedtuple
>
> TaskIndex = namedtuple('TaskIndex', 'sort_order task')
> for pos, task in enumerate(tasks):
> bug = task.bug
> if bug not in order:
> order[bug] = TaskIndex(pos, None)
> if task.target == bugtarget:
> order[bug].task = task
> for task in tasks:
> index = order[task.bug]
> if index.task is None:
> index.task = task
> return [index.task for index in sorted(order.values)]
>
> That's all I have to say :-), and you still have more additions than
> removals.
>

Revision history for this message
Robert Collins (lifeless) wrote :

On Fri, Mar 4, 2011 at 4:08 PM, Tim Penhey <email address hidden> wrote:
> Review: Approve
> In the function filter_one_task_per_bug, I think it would be better
> to use the enumerate function to create values for the sorting, rather
> that the length of the list.  Also, remembering bug instead of traversing
> over the attributes every time.  Also indexing [1] looks kinda random.
> So...
>
> from collections import namedtuple
>
> TaskIndex = namedtuple('TaskIndex', 'sort_order task')
> for pos, task in enumerate(tasks):
>    bug = task.bug
>    if bug not in order:
>        order[bug] = TaskIndex(pos, None)
>    if task.target == bugtarget:
>        order[bug].task = task
> for task in tasks:
>    index = order[task.bug]
>    if index.task is None:
>        index.task = task
> return [index.task for index in sorted(order.values)]
>
> That's all I have to say :-), and you still have more additions than
> removals.

That code won't work because tuples are immutable :) I suspect you'll
end up with a longer function that is less clear once you fix that.

Revision history for this message
Ian Booth (wallyworld) wrote :

>
> That code won't work because tuples are immutable :) I suspect you'll
> end up with a longer function that is less clear once you fix that.
>

Yes, so I've just used the suggested enumerate improvement.

There's some code which provides a mutable named tuple implementation:

http://code.activestate.com/recipes/576555/

We could consider using this at some point.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/browser/branch.py'
2--- lib/lp/code/browser/branch.py 2011-02-27 19:45:44 +0000
3+++ lib/lp/code/browser/branch.py 2011-03-04 11:06:55 +0000
4@@ -613,7 +613,7 @@
5 def revision_info(self):
6 collection = getUtility(IAllBranches).visibleByUser(self.user)
7 return collection.getExtendedRevisionDetails(
8- self.context.latest_revisions)
9+ self.user, self.context.latest_revisions)
10
11 @cachedproperty
12 def latest_code_import_results(self):
13
14=== modified file 'lib/lp/code/browser/branchmergeproposal.py'
15--- lib/lp/code/browser/branchmergeproposal.py 2011-02-27 20:49:43 +0000
16+++ lib/lp/code/browser/branchmergeproposal.py 2011-03-04 11:06:55 +0000
17@@ -94,10 +94,7 @@
18 from lp.app.browser.tales import DateTimeFormatterAPI
19 from lp.code.adapters.branch import BranchMergeProposalDelta
20 from lp.code.browser.codereviewcomment import CodeReviewDisplayComment
21-from lp.code.browser.decorations import (
22- DecoratedBranch,
23- DecoratedBug,
24- )
25+from lp.code.browser.decorations import DecoratedBranch
26 from lp.code.enums import (
27 BranchMergeProposalStatus,
28 BranchType,
29@@ -681,18 +678,12 @@
30 """Return whether or not the merge proposal has a linked bug or spec.
31 """
32 branch = self.context.source_branch
33- return branch.linked_bugs or branch.spec_links
34-
35- @cachedproperty
36- def linked_bugs(self):
37- """Return DecoratedBugs linked to the source branch."""
38- return [DecoratedBug(bug, self.context.source_branch)
39- for bug in self.context.related_bugs]
40+ return self.linked_bugtasks or branch.spec_links
41
42 @cachedproperty
43 def linked_bugtasks(self):
44- """Return DecoratedBugs linked to the source branch."""
45- return [bug.bugtask for bug in self.linked_bugs]
46+ """Return BugTasks linked to the source branch."""
47+ return self.context.getRelatedBugTasks(self.user)
48
49 @property
50 def edit_description_link_class(self):
51
52=== modified file 'lib/lp/code/browser/decorations.py'
53--- lib/lp/code/browser/decorations.py 2011-02-25 09:35:49 +0000
54+++ lib/lp/code/browser/decorations.py 2011-03-04 11:06:55 +0000
55@@ -6,16 +6,10 @@
56 __metaclass__ = type
57 __all__ = [
58 'DecoratedBranch',
59- 'DecoratedBug',
60 ]
61
62-
63-from collections import defaultdict
64-
65 from lazr.delegates import delegates
66
67-from canonical.launchpad.webapp.authorization import check_permission
68-from lp.bugs.interfaces.bug import IBug
69 from lp.code.interfaces.branch import (
70 BzrIdentityMixin,
71 IBranch,
72@@ -23,64 +17,6 @@
73 from lp.services.propertycache import cachedproperty
74
75
76-class DecoratedBug:
77- """Provide some cached attributes to a normal bug.
78-
79- We provide cached properties where sensible, and override default bug
80- behaviour where the cached properties can be used to avoid extra database
81- queries.
82- """
83- delegates(IBug, 'bug')
84-
85- def __init__(self, bug, branch, tasks=None):
86- self.bug = bug
87- self.branch = branch
88- if tasks is None:
89- tasks = self.bug.bugtasks
90- self.tasks = tasks
91-
92- @property
93- def bugtasks(self):
94- """This needs to be a property rather than an attribute.
95-
96- If we try to assign to self.bugtasks, the lazr.delegates things we are
97- trying to assign to the property of the bug.
98- """
99- return self.tasks
100-
101- @property
102- def default_bugtask(self):
103- """Use the first bugtask.
104-
105- Use the cached tasks as calling default_bugtask on the bug object
106- causes a DB query.
107- """
108- return self.bugtasks[0]
109-
110- def getBugTask(self, target):
111- """Get the bugtask for a specific target.
112-
113- This method is overridden rather than letting it fall through to the
114- underlying bug as the underlying implementation gets the bugtasks from
115- self, which would in that case be the normal bug model object, which
116- would then hit the database to get the tasks.
117- """
118- # Copied from Bug.getBugTarget to avoid importing.
119- for bugtask in self.bugtasks:
120- if bugtask.target == target:
121- return bugtask
122- return None
123-
124- @property
125- def bugtask(self):
126- """Return the bugtask for the branch project, or the default bugtask.
127-
128- This method defers the identitication of the appropriate task to the
129- branch target.
130- """
131- return self.branch.target.getBugTask(self)
132-
133-
134 class DecoratedBranch(BzrIdentityMixin):
135 """Wrap a number of the branch accessors to cache results.
136
137@@ -91,25 +27,6 @@
138 def __init__(self, branch):
139 self.branch = branch
140
141- @cachedproperty
142- def linked_bugs(self):
143- """Override the default behaviour of the branch object.
144-
145- The default behaviour is just to get the bugs. We want to display the
146- tasks however, and to avoid the extra database queries to get the
147- tasks, we get them all at once, and provide decorated bugs (that have
148- their tasks cached).
149- """
150- # To whomever it may concern, this function should be pushed down to
151- # the model, and the related visibility checks made part of the query.
152- # Alternatively it may be unused at this stage.
153- bugs = defaultdict(list)
154- for bug in self.branch.linked_bugs:
155- bugs[bug.id].extend(bug.bugtasks)
156- return [DecoratedBug(bug, self.branch, tasks)
157- for bug, tasks in bugs.iteritems()
158- if check_permission('launchpad.View', bug)]
159-
160 @property
161 def displayname(self):
162 """Override the default model property.
163
164=== modified file 'lib/lp/code/browser/tests/test_branch.py'
165--- lib/lp/code/browser/tests/test_branch.py 2011-02-27 21:14:53 +0000
166+++ lib/lp/code/browser/tests/test_branch.py 2011-03-04 11:06:55 +0000
167@@ -508,7 +508,8 @@
168 for x in range(0, 2):
169 bug = self.factory.makeBug()
170 mp.source_branch.linkBug(bug, branch.owner)
171- linked_bug_urls.append(canonical_url(bug, rootsite='bugs'))
172+ linked_bug_urls.append(
173+ canonical_url(bug.default_bugtask, rootsite='bugs'))
174 bug_text = "Bug #%s: %s" % (bug.id, bug.title)
175 linked_bug_text.append(bug_text)
176
177@@ -534,7 +535,7 @@
178 .*
179 Testing the email address in revisions\n
180 email Bob \(bob@example.com\) for details.
181- Merged branch %s with linked bugs
182+ Merged branch %s
183 %s
184 """ % (branch_display_name, linked_bug_rendered_text)
185
186
187=== modified file 'lib/lp/code/browser/tests/test_branchmergeproposal.py'
188--- lib/lp/code/browser/tests/test_branchmergeproposal.py 2010-12-22 00:26:20 +0000
189+++ lib/lp/code/browser/tests/test_branchmergeproposal.py 2011-03-04 11:06:55 +0000
190@@ -726,7 +726,18 @@
191 self.bmp.source_branch.linkBug(bug, self.bmp.registrant)
192 self.bmp.target_branch.linkBug(bug, self.bmp.registrant)
193 view = create_initialized_view(self.bmp, '+index')
194- self.assertEqual([], view.linked_bugs)
195+ self.assertEqual([], view.linked_bugtasks)
196+
197+ def test_linked_bugs_excludes_private_bugs(self):
198+ """List bugs that are linked to the source only."""
199+ bug = self.factory.makeBug()
200+ person = self.factory.makePerson()
201+ private_bug = self.factory.makeBug(owner=person, private=True)
202+ self.bmp.source_branch.linkBug(bug, self.bmp.registrant)
203+ with person_logged_in(person):
204+ self.bmp.source_branch.linkBug(private_bug, self.bmp.registrant)
205+ view = create_initialized_view(self.bmp, '+index')
206+ self.assertEqual([bug.default_bugtask], view.linked_bugtasks)
207
208 def makeRevisionGroups(self):
209 review_date = datetime(2009, 9, 10, tzinfo=pytz.UTC)
210
211=== modified file 'lib/lp/code/configure.zcml'
212--- lib/lp/code/configure.zcml 2011-03-02 13:22:31 +0000
213+++ lib/lp/code/configure.zcml 2011-03-04 11:06:55 +0000
214@@ -248,6 +248,7 @@
215 votes
216 all_comments
217 related_bugs
218+ getRelatedBugTasks
219 revision_end_date
220 isMergable
221 getComment
222
223=== modified file 'lib/lp/code/interfaces/branchcollection.py'
224--- lib/lp/code/interfaces/branchcollection.py 2011-03-03 01:13:47 +0000
225+++ lib/lp/code/interfaces/branchcollection.py 2011-03-04 11:06:55 +0000
226@@ -102,12 +102,16 @@
227 are returned.
228 """
229
230- def getExtendedRevisionDetails(revisions):
231+ def getExtendedRevisionDetails(user, revisions):
232 """Return information about the specified revisions on a branch.
233
234 For each revision, see if the revision resulted from merging in a
235 merge proposal, and if so package up the merge proposal and any linked
236- bugs on the merge proposal's source branch.
237+ bug tasks on the merge proposal's source branch.
238+
239+ :param user: The user who is making the request. Only bug tasks
240+ visible to this user are returned.
241+ :param revisions: The revisions we want details for.
242 """
243
244 def getTeamsWithBranches(person):
245
246=== modified file 'lib/lp/code/interfaces/branchmergeproposal.py'
247--- lib/lp/code/interfaces/branchmergeproposal.py 2011-02-04 16:13:13 +0000
248+++ lib/lp/code/interfaces/branchmergeproposal.py 2011-03-04 11:06:55 +0000
249@@ -296,6 +296,9 @@
250 def getComment(id):
251 """Return the CodeReviewComment with the specified ID."""
252
253+ def getRelatedBugTasks(user):
254+ """Return the Bug tasks related to this merge proposal."""
255+
256 def getRevisionsSinceReviewStart():
257 """Return all the revisions added since the review began.
258
259
260=== modified file 'lib/lp/code/model/branch.py'
261--- lib/lp/code/model/branch.py 2011-03-03 01:13:47 +0000
262+++ lib/lp/code/model/branch.py 2011-03-04 11:06:55 +0000
263@@ -7,6 +7,7 @@
264 __all__ = [
265 'Branch',
266 'BranchSet',
267+ 'filter_one_task_per_bug',
268 ]
269
270 from datetime import datetime
271@@ -308,31 +309,14 @@
272 'Bug', joinColumn='branch', otherColumn='bug',
273 intermediateTable='BugBranch', orderBy='id')
274
275- def getLinkedBugTasks(self, user, status_filter):
276+ def getLinkedBugTasks(self, user, status_filter=None):
277 """See `IBranch`."""
278 params = BugTaskSearchParams(user=user, linked_branches=self.id,
279 status=status_filter)
280 tasks = shortlist(getUtility(IBugTaskSet).search(params), 1000)
281 # Post process to discard irrelevant tasks: we only return one task per
282 # bug, and cannot easily express this in sql (yet).
283- order = {}
284- bugtarget = self.target.context
285- # First pass calculates the order and selects the bugtasks that match
286- # our target.
287- # Second pass selects the earliest bugtask where the bug has no task on
288- # our target.
289- for task in tasks:
290- if task.bug not in order:
291- order[task.bug] = [len(order) + 1, None]
292- if task.target == bugtarget:
293- order[task.bug][1] = task
294- for task in tasks:
295- if order[task.bug][1] is None:
296- order[task.bug][1] = task
297- # Now we pull out the tasks
298- result = order.values()
299- result.sort()
300- return [task for pos, task in result]
301+ return filter_one_task_per_bug(self, tasks)
302
303 def linkBug(self, bug, registrant):
304 """See `IBranch`."""
305@@ -1404,3 +1388,27 @@
306 """
307 update_trigger_modified_fields(branch)
308 send_branch_modified_notifications(branch, event)
309+
310+
311+def filter_one_task_per_bug(branch, tasks):
312+ """Given bug tasks for a branch, discard irrelevant ones.
313+
314+ Cannot easily be expressed in SQL yet, so we need this helper method.
315+ """
316+ order = {}
317+ bugtarget = branch.target.context
318+ # First pass calculates the order and selects the bugtasks that match
319+ # our target.
320+ # Second pass selects the earliest bugtask where the bug has no task on
321+ # our target.
322+ for pos, task in enumerate(tasks):
323+ bug = task.bug
324+ if bug not in order:
325+ order[bug] = [pos, None]
326+ if task.target == bugtarget:
327+ order[bug][1] = task
328+ for task in tasks:
329+ index = order[task.bug]
330+ if index[1] is None:
331+ index[1] = task
332+ return [task for pos, task in sorted(order.values())]
333
334=== modified file 'lib/lp/code/model/branchcollection.py'
335--- lib/lp/code/model/branchcollection.py 2011-03-03 03:04:30 +0000
336+++ lib/lp/code/model/branchcollection.py 2011-03-04 11:06:55 +0000
337@@ -26,15 +26,21 @@
338 from canonical.launchpad.components.decoratedresultset import (
339 DecoratedResultSet,
340 )
341+from canonical.launchpad.interfaces.lpstorm import IStore
342 from canonical.launchpad.webapp.interfaces import (
343 DEFAULT_FLAVOR,
344 IStoreSelector,
345 MAIN_STORE,
346 )
347+from canonical.launchpad.searchbuilder import any
348 from canonical.launchpad.webapp.vocabulary import CountableIterator
349 from canonical.lazr.utils import safe_hasattr
350-from lp.bugs.model.bug import Bug
351+from lp.bugs.interfaces.bugtask import (
352+ IBugTaskSet,
353+ BugTaskSearchParams,
354+ )
355 from lp.bugs.model.bugbranch import BugBranch
356+from lp.bugs.model.bugtask import BugTask
357 from lp.code.interfaces.branch import user_has_special_branch_access
358 from lp.code.interfaces.branchcollection import (
359 IBranchCollection,
360@@ -46,7 +52,10 @@
361 from lp.code.enums import BranchMergeProposalStatus
362 from lp.code.interfaces.branchlookup import IBranchLookup
363 from lp.code.interfaces.codehosting import LAUNCHPAD_SERVICES
364-from lp.code.model.branch import Branch
365+from lp.code.model.branch import (
366+ Branch,
367+ filter_one_task_per_bug,
368+ )
369 from lp.code.model.branchmergeproposal import BranchMergeProposal
370 from lp.code.model.branchsubscription import BranchSubscription
371 from lp.code.model.codereviewcomment import CodeReviewComment
372@@ -158,6 +167,7 @@
373 resultset = self.store.using(*tables).find(Branch, *expressions)
374 if not eager_load:
375 return resultset
376+
377 def do_eager_load(rows):
378 branch_ids = set(branch.id for branch in rows)
379 if not branch_ids:
380@@ -254,23 +264,24 @@
381 proposals.order_by(Desc(CodeReviewComment.vote))
382 return proposals
383
384- def getExtendedRevisionDetails(self, revisions):
385+ def getExtendedRevisionDetails(self, user, revisions):
386 """See `IBranchCollection`."""
387
388 if not revisions:
389 return []
390 branch = revisions[0].branch
391
392- def make_rev_info(branch_revision, merge_proposal_revs, linked_bugs):
393+ def make_rev_info(
394+ branch_revision, merge_proposal_revs, linked_bugtasks):
395 rev_info = {
396 'revision': branch_revision,
397- 'linked_bugs': None,
398+ 'linked_bugtasks': None,
399 'merge_proposal': None,
400 }
401 merge_proposal = merge_proposal_revs.get(branch_revision.sequence)
402 rev_info['merge_proposal'] = merge_proposal
403 if merge_proposal is not None:
404- rev_info['linked_bugs'] = linked_bugs.get(
405+ rev_info['linked_bugtasks'] = linked_bugtasks.get(
406 merge_proposal.source_branch.id)
407 return rev_info
408
409@@ -281,19 +292,40 @@
410 merge_proposal_revs = dict(
411 [(mp.merged_revno, mp) for mp in merge_proposals])
412 source_branch_ids = [mp.source_branch.id for mp in merge_proposals]
413-
414- filter = [
415- BugBranch.bug == Bug.id,
416- BugBranch.branchID.is_in(
417- source_branch_ids),
418- ]
419- bugs = self.store.find((Bug, BugBranch), filter)
420- linked_bugs = defaultdict(list)
421- for (bug, bugbranch) in bugs:
422- linked_bugs[bugbranch.branchID].append(bug)
423+ linked_bugtasks = defaultdict(list)
424+
425+ if source_branch_ids:
426+ # We get the bugtasks for our merge proposal branches
427+
428+ # First, the bug ids
429+ params = BugTaskSearchParams(
430+ user=user, status=None,
431+ linked_branches=any(*source_branch_ids))
432+ bug_ids = getUtility(IBugTaskSet).searchBugIds(params)
433+
434+ # Then the bug tasks and branches
435+ store = IStore(BugBranch)
436+ rs = store.using(
437+ BugBranch,
438+ Join(BugTask, BugTask.bugID == BugBranch.bugID),
439+ ).find(
440+ (BugTask, BugBranch),
441+ BugBranch.bugID.is_in(bug_ids),
442+ BugBranch.branchID.is_in(source_branch_ids)
443+ )
444+
445+ # Build up a collection of bugtasks for each branch
446+ bugtasks_for_branch = defaultdict(list)
447+ for bugtask, bugbranch in rs:
448+ bugtasks_for_branch[bugbranch.branch].append(bugtask)
449+
450+ # Now filter those down to one bugtask per branch
451+ for branch, tasks in bugtasks_for_branch.iteritems():
452+ linked_bugtasks[branch.id].extend(
453+ filter_one_task_per_bug(branch, tasks))
454
455 return [make_rev_info(
456- rev, merge_proposal_revs, linked_bugs)
457+ rev, merge_proposal_revs, linked_bugtasks)
458 for rev in revisions]
459
460 def getTeamsWithBranches(self, person):
461
462=== modified file 'lib/lp/code/model/branchmergeproposal.py'
463--- lib/lp/code/model/branchmergeproposal.py 2011-01-27 00:31:44 +0000
464+++ lib/lp/code/model/branchmergeproposal.py 2011-03-04 11:06:55 +0000
465@@ -242,8 +242,22 @@
466
467 Implies that these bugs would be fixed, in the target, by the merge.
468 """
469- return (bug for bug in self.source_branch.linked_bugs
470- if bug not in self.target_branch.linked_bugs)
471+ from operator import attrgetter
472+ bugtasks = self.getRelatedBugTasks(self.registrant)
473+ bugs = set()
474+ for bugtask in bugtasks:
475+ bugs.add(bugtask.bug)
476+ return sorted(bugs, attrgetter('id'))
477+
478+ def getRelatedBugTasks(self, user):
479+ """Bug tasks which are linked to the source but not the target.
480+
481+ Implies that these would be fixed, in the target, by the merge.
482+ """
483+ source_tasks = self.source_branch.getLinkedBugTasks(user)
484+ target_tasks = self.target_branch.getLinkedBugTasks(user)
485+ return [bugtask
486+ for bugtask in source_tasks if bugtask not in target_tasks]
487
488 @property
489 def address(self):
490
491=== modified file 'lib/lp/code/model/tests/test_branchcollection.py'
492--- lib/lp/code/model/tests/test_branchcollection.py 2011-01-27 14:02:38 +0000
493+++ lib/lp/code/model/tests/test_branchcollection.py 2011-03-04 11:06:55 +0000
494@@ -613,17 +613,14 @@
495 self.all_branches = getUtility(IAllBranches)
496
497 def test_empty_revisions(self):
498- rev_details = self.all_branches.getExtendedRevisionDetails([])
499- self.assertEqual([], rev_details)
500- rev_details = self.all_branches.getExtendedRevisionDetails(None)
501- self.assertEqual([], rev_details)
502-
503- def test_some_revisions(self):
504- branch = self.factory.makeBranch()
505- merge_proposals = [
506- self.factory.makeBranchMergeProposal(target_branch=branch)
507- for x in range(0, 2)]
508-
509+ person = self.factory.makePerson()
510+ rev_details = self.all_branches.getExtendedRevisionDetails(person, [])
511+ self.assertEqual([], rev_details)
512+ rev_details = self.all_branches.getExtendedRevisionDetails(
513+ person, None)
514+ self.assertEqual([], rev_details)
515+
516+ def _makeBranchRevisions(self, merge_proposals, branch):
517 expected_rev_details = []
518 with person_logged_in(branch.owner):
519 self.factory.makeRevisionsForBranch(branch, 3)
520@@ -632,7 +629,7 @@
521 branch_revision = branch_revisions[x]
522 rev_info = {
523 'revision': branch_revision,
524- 'linked_bugs': None,
525+ 'linked_bugtasks': None,
526 'merge_proposal': None,
527 }
528 if x < len(merge_proposals):
529@@ -640,20 +637,64 @@
530 branch_revision.sequence)
531 rev_info['merge_proposal'] = merge_proposals[x]
532 expected_rev_details.append(rev_info)
533+ return expected_rev_details, branch_revisions
534+
535+ def test_some_revisions_with_no_bugs(self):
536+ branch = self.factory.makeBranch()
537+ merge_proposals = [
538+ self.factory.makeBranchMergeProposal(target_branch=branch)
539+ for x in range(0, 2)]
540+
541+ expected_rev_details, branch_revisions = (
542+ self._makeBranchRevisions(merge_proposals, branch))
543
544 result = self.all_branches.getExtendedRevisionDetails(
545- branch_revisions)
546+ branch.owner, branch_revisions)
547 self.assertEqual(sorted(expected_rev_details), sorted(result))
548
549- linked_bugs = []
550+ def test_some_revisions_with_bugs(self):
551+ branch = self.factory.makeBranch()
552+ merge_proposals = [
553+ self.factory.makeBranchMergeProposal(target_branch=branch)
554+ for x in range(0, 2)]
555+
556+ expected_rev_details, branch_revisions = (
557+ self._makeBranchRevisions(merge_proposals, branch))
558+
559+ linked_bugtasks = []
560 with person_logged_in(branch.owner):
561 for x in range(0, 2):
562 bug = self.factory.makeBug()
563 merge_proposals[0].source_branch.linkBug(bug, branch.owner)
564- linked_bugs.append(bug)
565- expected_rev_details[0]['linked_bugs'] = linked_bugs
566- result = self.all_branches.getExtendedRevisionDetails(
567- branch_revisions)
568+ linked_bugtasks.append(bug.default_bugtask)
569+ expected_rev_details[0]['linked_bugtasks'] = linked_bugtasks
570+ result = self.all_branches.getExtendedRevisionDetails(
571+ branch.owner, branch_revisions)
572+ self.assertEqual(sorted(expected_rev_details), sorted(result))
573+
574+ def test_some_revisions_with_private_bugs(self):
575+ branch = self.factory.makeBranch()
576+ merge_proposals = [
577+ self.factory.makeBranchMergeProposal(target_branch=branch)
578+ for x in range(0, 2)]
579+
580+ expected_rev_details, branch_revisions = (
581+ self._makeBranchRevisions(merge_proposals, branch))
582+
583+ linked_bugtasks = []
584+ with person_logged_in(branch.owner):
585+ for x in range(0, 4):
586+ private = x%2
587+ bug = self.factory.makeBug(
588+ owner=branch.owner, private=private)
589+ merge_proposals[0].source_branch.linkBug(bug, branch.owner)
590+ if not private:
591+ linked_bugtasks.append(bug.default_bugtask)
592+ expected_rev_details[0]['linked_bugtasks'] = linked_bugtasks
593+
594+ person = self.factory.makePerson()
595+ result = self.all_branches.getExtendedRevisionDetails(
596+ person, branch_revisions)
597 self.assertEqual(sorted(expected_rev_details), sorted(result))
598
599
600
601=== modified file 'lib/lp/code/model/tests/test_branchmergeproposal.py'
602--- lib/lp/code/model/tests/test_branchmergeproposal.py 2011-01-27 00:31:44 +0000
603+++ lib/lp/code/model/tests/test_branchmergeproposal.py 2011-03-04 11:06:55 +0000
604@@ -3,8 +3,6 @@
605
606 # pylint: disable-msg=F0401
607
608-from __future__ import with_statement
609-
610 """Tests for BranchMergeProposals."""
611
612 __metaclass__ = type
613@@ -28,10 +26,7 @@
614 from zope.security.proxy import removeSecurityProxy
615
616 from canonical.database.constants import UTC_NOW
617-from canonical.launchpad.ftests import (
618- import_secret_test_key,
619- syncUpdate,
620- )
621+from canonical.launchpad.ftests import import_secret_test_key
622 from canonical.launchpad.interfaces.launchpad import IPrivacy
623 from canonical.launchpad.interfaces.message import IMessageJob
624 from canonical.launchpad.webapp import canonical_url
625@@ -83,7 +78,6 @@
626 from lp.registry.interfaces.person import IPersonSet
627 from lp.registry.interfaces.product import IProductSet
628 from lp.testing import (
629- ANONYMOUS,
630 login,
631 login_person,
632 person_logged_in,
633@@ -1272,6 +1266,46 @@
634 bmp.target_branch.linkBug(bug, bmp.registrant)
635 self.assertEqual([], list(bmp.related_bugs))
636
637+ def test_related_bugtasks_includes_source_bugtasks(self):
638+ """related_bugtasks includes bugtasks linked to the source branch."""
639+ bmp = self.factory.makeBranchMergeProposal()
640+ source_branch = bmp.source_branch
641+ bug = self.factory.makeBug()
642+ source_branch.linkBug(bug, bmp.registrant)
643+ self.assertEqual(
644+ bug.bugtasks, list(bmp.getRelatedBugTasks(self.user)))
645+
646+ def test_related_bugtasks_excludes_target_bugs(self):
647+ """related_bugtasks ignores bugs linked to the source branch."""
648+ bmp = self.factory.makeBranchMergeProposal()
649+ bug = self.factory.makeBug()
650+ bmp.target_branch.linkBug(bug, bmp.registrant)
651+ self.assertEqual([], list(bmp.getRelatedBugTasks(self.user)))
652+
653+ def test_related_bugtasks_excludes_mutual_bugs(self):
654+ """related_bugtasks ignores bugs linked to both branches."""
655+ bmp = self.factory.makeBranchMergeProposal()
656+ bug = self.factory.makeBug()
657+ bmp.source_branch.linkBug(bug, bmp.registrant)
658+ bmp.target_branch.linkBug(bug, bmp.registrant)
659+ self.assertEqual([], list(bmp.getRelatedBugTasks(self.user)))
660+
661+ def test_related_bugtasks_excludes_private_bugs(self):
662+ """related_bugtasks ignores private bugs for non-authorised users."""
663+ bmp = self.factory.makeBranchMergeProposal()
664+ bug = self.factory.makeBug()
665+ bmp.source_branch.linkBug(bug, bmp.registrant)
666+ person = self.factory.makePerson()
667+ with person_logged_in(person):
668+ private_bug = self.factory.makeBug(private=True, owner=person)
669+ bmp.source_branch.linkBug(private_bug, person)
670+ self.assertEqual(
671+ bug.bugtasks, list(bmp.getRelatedBugTasks(self.user)))
672+ all_bugtasks = list(bug.bugtasks)
673+ all_bugtasks.extend(private_bug.bugtasks)
674+ self.assertEqual(
675+ all_bugtasks, list(bmp.getRelatedBugTasks(person)))
676+
677
678 class TestNotifyModified(TestCaseWithFactory):
679
680
681=== modified file 'lib/lp/code/templates/branch-macros.pt'
682--- lib/lp/code/templates/branch-macros.pt 2011-02-25 05:12:46 +0000
683+++ lib/lp/code/templates/branch-macros.pt 2011-03-04 11:06:55 +0000
684@@ -179,7 +179,7 @@
685 revisions - the revisions to list.
686 or
687 revision_info - extended revision information (revision,
688- merge_proposal, linked_bugs) to list.
689+ merge_proposal, linked_bugtasks) to list.
690
691 </tal:comment>
692 <style type="text/css">
693@@ -214,7 +214,7 @@
694 rev_no - the branch revision to be displayed.
695 or
696 rev_info - a dict of the branch revision information (revision,
697- merge_proposal, linked_bugs) to be displayed.
698+ merge_proposal, linked_bugtasks) to be displayed.
699 codebrowse - the branch's codebrowse root.
700
701 It is expected that this macro is called from within a definition
702@@ -263,12 +263,12 @@
703 Merged branch <a tal:attributes="href merge_proposal/fmt:url"
704 tal:content="merge_proposal/source_branch/displayname">source branch</a>
705 <tal:linkedbugs
706- define="linked_bugs python:rev_info['linked_bugs']"
707- condition="linked_bugs">with linked bugs
708+ define="linked_bugtasks python:rev_info['linked_bugtasks']"
709+ condition="linked_bugtasks">
710 <dl>
711- <tal:bug repeat="bug linked_bugs">
712+ <tal:bug repeat="bugtask linked_bugtasks">
713 <dd class="subordinate revision-comment">
714- <tal:bug-link replace="structure bug/fmt:link"/>
715+ <tal:bug-link replace="structure bugtask/fmt:link"/>
716 </dd>
717 </tal:bug>
718 </dl>