Merge lp:~thumper/launchpad/blueprint-linked-bug-tasks into lp:launchpad

Proposed by Tim Penhey
Status: Merged
Approved by: Robert Collins
Approved revision: no longer in the source branch.
Merged at revision: 12679
Proposed branch: lp:~thumper/launchpad/blueprint-linked-bug-tasks
Merge into: lp:launchpad
Prerequisite: lp:~thumper/launchpad/add-publishing-for-factory-distro-sourcepackage-bug-tasks
Diff against target: 897 lines (+483/-49)
23 files modified
lib/lp/blueprints/browser/specification.py (+1/-2)
lib/lp/blueprints/interfaces/specification.py (+10/-0)
lib/lp/blueprints/model/specification.py (+21/-0)
lib/lp/blueprints/stories/blueprints/xx-buglinks.txt (+1/-1)
lib/lp/blueprints/templates/specification-index.pt (+12/-5)
lib/lp/bugs/configure.zcml (+8/-0)
lib/lp/bugs/interfaces/bugtarget.py (+11/-0)
lib/lp/bugs/interfaces/bugtask.py (+4/-0)
lib/lp/bugs/interfaces/bugtaskfilter.py (+68/-0)
lib/lp/bugs/model/bugtarget.py (+5/-0)
lib/lp/bugs/model/bugtask.py (+18/-9)
lib/lp/bugs/model/tests/test_bugtask.py (+22/-0)
lib/lp/bugs/tests/test_bugtaskfilter.py (+196/-0)
lib/lp/code/interfaces/branch.py (+1/-1)
lib/lp/code/model/branch.py (+2/-26)
lib/lp/code/model/branchcollection.py (+3/-5)
lib/lp/registry/interfaces/distroseries.py (+1/-0)
lib/lp/registry/interfaces/productseries.py (+1/-0)
lib/lp/registry/model/distribution.py (+17/-0)
lib/lp/registry/model/distroseries.py (+20/-0)
lib/lp/registry/model/product.py (+17/-0)
lib/lp/registry/model/productseries.py (+19/-0)
lib/lp/registry/model/sourcepackage.py (+25/-0)
To merge this branch: bzr merge lp:~thumper/launchpad/blueprint-linked-bug-tasks
Reviewer Review Type Date Requested Status
Robert Collins (community) Approve
Ian Booth (community) *code Approve
Review via email: mp+53734@code.launchpad.net

Commit message

[r=lifeless,wallyworld][bug=487337] Show the appropriate bug task for the bugs linked to the blueprints.

Description of the change

This branch primarily fixes bug 487337.

A perhaps slightly over-engineered fix, but it is something that
I've been meaning to do for a while. Choosing the correct bug
task to show for links when we link to bugs not bug tasks has been
a bit of a problem for a while (IMO).

This branch adds a new method in its own module (to avoid circular
dependencies) called filter_bugtasks_by_context. This function
aims to choose the most appropriate bug task for different contexts.
It does this by creating the appropriate weighting calculator for
the context as different contexts put different weights on the different
bug tasks. These are then sorted for any particular bug, and the
bug task with the best weighting is chosen.

This method now replaces the branch specific bugtask chooser.

In order to do this without database queries, the IDs of the various
context objects are used. This meant exposing the field IDs in a few
different interfaces.

The bug task search method is extended to check for a specific blueprint
being linked rather than just any blueprint. This is then used in
the blueprint method getLinkedBugTasks. The user is passed in, and
is used by the searching code to check for visibility, so we don't have
to post process to check for visibility.

Finally, the formatted bugtask is shown on the blueprint page along
with the current status.

To post a comment you must log in.
Revision history for this message
Ian Booth (wallyworld) wrote :

This is great, especially the no sql bit, and it fits in with other work already done to optimise and improve the security around retrieving bug tasks.

I'm wondering how the order of looking for contexts was determined in getLinkedBugTasks():

67 + if self.distroseries is not None:
68 + context = self.distroseries
69 + elif self.distribution is not None:
70 + context = self.distribution
71 + elif self.productseries is not None:
72 + context = self.productseries
73 + else:
74 + context = self.product

distroseries and productseries are set as goals i think? So one of the other is set. And product and distribution as targets. What makes distroseries more important than productseries for example? Perhaps a comment of two to explain would be good. Since this is only used here, there's no need for a helper method unless it may be something useful to abstract out.

review: Approve (*code)
Revision history for this message
Robert Collins (lifeless) wrote :

On Thu, Mar 17, 2011 at 4:56 PM, Ian Booth <email address hidden> wrote:
> Review: Approve *code
> This is great, especially the no sql bit, and it fits in with other work already done to optimise and improve the security around retrieving bug tasks.
>
> I'm wondering how the order of looking for contexts was determined in getLinkedBugTasks():

its arbitrary and the order doesn't matter - see the check constraints
on bugtask.

Revision history for this message
Tim Penhey (thumper) wrote :

On Fri, 18 Mar 2011 08:17:54 Robert Collins wrote:
> On Thu, Mar 17, 2011 at 4:56 PM, Ian Booth <email address hidden> wrote:
> > Review: Approve *code
> > This is great, especially the no sql bit, and it fits in with other work
> > already done to optimise and improve the security around retrieving bug
> > tasks.
>
> > I'm wondering how the order of looking for contexts was determined in
getLinkedBugTasks():
> its arbitrary and the order doesn't matter - see the check constraints
> on bugtask.

It isn't entirely arbitrary. Blueprints use the associated productseries or
distroseries to indicate goals. If a particular bug had a bug task for the
goal series, it made more sense to return that one than to return the more
generic distro or product task.

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

On Fri, Mar 18, 2011 at 9:39 AM, Tim Penhey <email address hidden> wrote:
> On Fri, 18 Mar 2011 08:17:54 Robert Collins wrote:
>> On Thu, Mar 17, 2011 at 4:56 PM, Ian Booth <email address hidden> wrote:
>> > Review: Approve *code
>> > This is great, especially the no sql bit, and it fits in with other work
>> > already done to optimise and improve the security around retrieving bug
>> > tasks.
>>
>> > I'm wondering how the order of looking for contexts was determined in
> getLinkedBugTasks():
>> its arbitrary and the order doesn't matter - see the check constraints
>> on bugtask.
>
> It isn't entirely arbitrary.  Blueprints use the associated productseries or
> distroseries to indicate goals.  If a particular bug had a bug task for the
> goal series, it made more sense to return that one than to return the more
> generic distro or product task.

Bugtasks are constrainted to have only one target: one of (product
series, product, etc etc). So the code that was changed which is
examining only one task, could be in any order and have the same
outcome.

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

Ok, so I like what this achieves.

I think the code could be a little leaner: two specific suggestions.

Rather than classes with __call__, use closures.

Secondly, rather than type inspection, extend the contract for IHasBugs to include getBugTaskWeightFunction - that way you don't need to extend this if block if we add a new IHasBugs, the implementor of a new IHasBugs will just implement that method.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/blueprints/browser/specification.py'
2--- lib/lp/blueprints/browser/specification.py 2011-03-10 01:25:13 +0000
3+++ lib/lp/blueprints/browser/specification.py 2011-03-28 00:03:43 +0000
4@@ -557,8 +557,7 @@
5
6 @cachedproperty
7 def bug_links(self):
8- return [bug_link for bug_link in self.context.bug_links
9- if check_permission('launchpad.View', bug_link.bug)]
10+ return self.context.getLinkedBugTasks(self.user)
11
12
13 class SpecificationView(SpecificationSimpleView):
14
15=== modified file 'lib/lp/blueprints/interfaces/specification.py'
16--- lib/lp/blueprints/interfaces/specification.py 2011-03-24 12:54:40 +0000
17+++ lib/lp/blueprints/interfaces/specification.py 2011-03-28 00:03:43 +0000
18@@ -510,6 +510,16 @@
19 def getBranchLink(branch):
20 """Return the SpecificationBranch link for the branch, or None."""
21
22+ def getLinkedBugTasks(user):
23+ """Return the bug tasks that are relevant to this blueprint.
24+
25+ When multiple tasks are on a bug, if one of the tasks is for the
26+ target, then only that task is returned. Otherwise the default
27+ bug task is returned.
28+
29+ :param user: The user doing the search.
30+ """
31+
32
33 class ISpecificationEditRestricted(Interface):
34 """Specification's attributes and methods protected with launchpad.Edit.
35
36=== modified file 'lib/lp/blueprints/model/specification.py'
37--- lib/lp/blueprints/model/specification.py 2011-03-14 22:14:13 +0000
38+++ lib/lp/blueprints/model/specification.py 2011-03-28 00:03:43 +0000
39@@ -30,6 +30,7 @@
40 SQL,
41 )
42 from storm.store import Store
43+from zope.component import getUtility
44 from zope.event import notify
45 from zope.interface import implements
46
47@@ -77,6 +78,11 @@
48 SpecificationSubscription,
49 )
50 from lp.bugs.interfaces.buglink import IBugLinkTarget
51+from lp.bugs.interfaces.bugtask import (
52+ BugTaskSearchParams,
53+ IBugTaskSet,
54+ )
55+from lp.bugs.interfaces.bugtaskfilter import filter_bugtasks_by_context
56 from lp.bugs.model.buglinktarget import BugLinkTargetMixin
57 from lp.registry.interfaces.distribution import IDistribution
58 from lp.registry.interfaces.distroseries import IDistroSeries
59@@ -669,11 +675,26 @@
60 spec_branch = self.getBranchLink(branch)
61 spec_branch.destroySelf()
62
63+ def getLinkedBugTasks(self, user):
64+ """See `ISpecification`."""
65+ params = BugTaskSearchParams(user=user, linked_blueprints=self.id)
66+ tasks = getUtility(IBugTaskSet).search(params)
67+ if self.distroseries is not None:
68+ context = self.distroseries
69+ elif self.distribution is not None:
70+ context = self.distribution
71+ elif self.productseries is not None:
72+ context = self.productseries
73+ else:
74+ context = self.product
75+ return filter_bugtasks_by_context(context, tasks)
76+
77 def __repr__(self):
78 return '<Specification %s %r for %r>' % (
79 self.id, self.name, self.target.name)
80
81
82+
83 class HasSpecificationsMixin:
84 """A mixin class that implements many of the common shortcut properties
85 for other classes that have specifications.
86
87=== modified file 'lib/lp/blueprints/stories/blueprints/xx-buglinks.txt'
88--- lib/lp/blueprints/stories/blueprints/xx-buglinks.txt 2010-08-26 02:30:06 +0000
89+++ lib/lp/blueprints/stories/blueprints/xx-buglinks.txt 2011-03-28 00:03:43 +0000
90@@ -13,7 +13,7 @@
91 ... 'http://launchpad.dev/firefox/+spec/svg-support')
92 >>> print extract_text(find_tag_by_id(anon_browser.contents, 'bug_links'))
93 Related bugs
94- Bug #1: Firefox does not support SVG
95+ Bug #1: Firefox does not support SVG New
96
97
98 == Adding Links ==
99
100=== modified file 'lib/lp/blueprints/templates/specification-index.pt'
101--- lib/lp/blueprints/templates/specification-index.pt 2011-03-10 01:44:17 +0000
102+++ lib/lp/blueprints/templates/specification-index.pt 2011-03-28 00:03:43 +0000
103@@ -208,11 +208,18 @@
104 <div id="bug_links">
105 <h3>Related bugs</h3>
106
107- <ul tal:condition="view/bug_links">
108- <li tal:repeat="link view/bug_links">
109- <tal:link replace="structure link/bug/fmt:link" />
110- </li>
111- </ul>
112+ <table tal:condition="view/bug_links">
113+ <tr tal:repeat="bugtask view/bug_links">
114+ <td>
115+ <tal:link replace="structure bugtask/fmt:link" />
116+ </td>
117+ <td>
118+ <span tal:content="bugtask/status/title"
119+ tal:attributes="class string:status${bugtask/status/name}"
120+ >Triaged</span>
121+ </td>
122+ </tr>
123+ </table>
124
125 <ul class="horizontal">
126 <li tal:define="link context_menu/linkbug"
127
128=== modified file 'lib/lp/bugs/configure.zcml'
129--- lib/lp/bugs/configure.zcml 2011-03-24 14:13:45 +0000
130+++ lib/lp/bugs/configure.zcml 2011-03-28 00:03:43 +0000
131@@ -168,6 +168,10 @@
132 <facet
133 facet="bugs">
134
135+ <class class="lp.bugs.interfaces.bugtaskfilter.OrderedBugTask">
136+ <allow attributes="rank id task"/>
137+ </class>
138+
139 <!-- IBugTask -->
140
141 <class
142@@ -190,8 +194,12 @@
143 date_fix_released
144 date_left_closed
145 date_closed
146+ distributionID
147+ distroseriesID
148 milestoneID
149+ productID
150 productseriesID
151+ sourcepackagenameID
152 task_age
153 bug_subscribers
154 is_complete
155
156=== modified file 'lib/lp/bugs/interfaces/bugtarget.py'
157--- lib/lp/bugs/interfaces/bugtarget.py 2011-03-23 13:51:39 +0000
158+++ lib/lp/bugs/interfaces/bugtarget.py 2011-03-28 00:03:43 +0000
159@@ -20,6 +20,7 @@
160 'IOfficialBugTagTargetRestricted',
161 ]
162
163+
164 from lazr.enum import DBEnumeratedType
165 from lazr.restful.declarations import (
166 call_with,
167@@ -279,6 +280,16 @@
168 None, all statuses will be included.
169 """
170
171+ def getBugTaskWeightFunction():
172+ """Return a function that is used to weight the bug tasks.
173+
174+ The function should take a bug task as a parameter and return
175+ an OrderedBugTask.
176+
177+ The ordered bug tasks are used to choose the most relevant bug task
178+ for any particular context.
179+ """
180+
181
182 class IBugTarget(IHasBugs):
183 """An entity on which a bug can be reported.
184
185=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
186--- lib/lp/bugs/interfaces/bugtask.py 2011-03-23 16:28:51 +0000
187+++ lib/lp/bugs/interfaces/bugtask.py 2011-03-28 00:03:43 +0000
188@@ -462,17 +462,21 @@
189 BugField(title=_("Bug"), readonly=True))
190 product = Choice(
191 title=_('Project'), required=False, vocabulary='Product')
192+ productID = Attribute('The product ID')
193 productseries = Choice(
194 title=_('Series'), required=False, vocabulary='ProductSeries')
195 productseriesID = Attribute('The product series ID')
196 sourcepackagename = Choice(
197 title=_("Package"), required=False,
198 vocabulary='SourcePackageName')
199+ sourcepackagenameID = Attribute('The sourcepackagename ID')
200 distribution = Choice(
201 title=_("Distribution"), required=False, vocabulary='Distribution')
202+ distributionID = Attribute('The distribution ID')
203 distroseries = Choice(
204 title=_("Series"), required=False,
205 vocabulary='DistroSeries')
206+ distroseriesID = Attribute('The distroseries ID')
207 milestone = exported(ReferenceChoice(
208 title=_('Milestone'),
209 required=False,
210
211=== added file 'lib/lp/bugs/interfaces/bugtaskfilter.py'
212--- lib/lp/bugs/interfaces/bugtaskfilter.py 1970-01-01 00:00:00 +0000
213+++ lib/lp/bugs/interfaces/bugtaskfilter.py 2011-03-28 00:03:43 +0000
214@@ -0,0 +1,68 @@
215+# Copyright 2011 Canonical Ltd. This software is licensed under the
216+# GNU Affero General Public License version 3 (see the file LICENSE).
217+
218+"""Fiter bugtasks based on context."""
219+
220+__metaclass__ = type
221+__all__ = [
222+ 'filter_bugtasks_by_context',
223+ 'OrderedBugTask',
224+ 'simple_weight_calculator',
225+ ]
226+
227+
228+from collections import defaultdict, namedtuple
229+from operator import attrgetter
230+
231+from lp.bugs.interfaces.bugtarget import IHasBugs
232+
233+
234+OrderedBugTask = namedtuple('OrderedBugTask', 'rank id task')
235+
236+
237+def simple_weight_calculator(bugtask):
238+ """All tasks have the same weighting."""
239+ return OrderedBugTask(1, bugtask.id, bugtask)
240+
241+
242+def filter_bugtasks_by_context(context, bugtasks):
243+ """Return the bugtasks filtered so there is only one bug task per bug.
244+
245+ The context is used to return the most relevent bugtask for that context.
246+
247+ An initial constraint is to not require any database queries from this
248+ method.
249+
250+ Current contexts that impact selection:
251+ IProduct
252+ IProductSeries
253+ IDistribution
254+ IDistroSeries
255+ ISourcePackage
256+ Others:
257+ get the first bugtask for any particular bug
258+
259+ If the context is a Product, then return the product bug task if there is
260+ one. If the context is a ProductSeries, then return the productseries
261+ task if there is one, and if there isn't, look for the product task. A
262+ similar approach is taked for Distribution and distroseries.
263+
264+ For source packages, we look for the source package task, followed by the
265+ distro source package, then the distroseries task, and lastly the distro
266+ task.
267+
268+ If there is no specific matching task, we return the first task (the one
269+ with the smallest database id).
270+ """
271+ has_bugs = IHasBugs(context, None)
272+ if has_bugs is None:
273+ weight_calculator = simple_weight_calculator
274+ else:
275+ weight_calculator = has_bugs.getBugTaskWeightFunction()
276+
277+ bug_mapping = defaultdict(list)
278+ for task in bugtasks:
279+ bug_mapping[task.bugID].append(weight_calculator(task))
280+
281+ filtered = [sorted(tasks)[0].task for tasks in bug_mapping.itervalues()]
282+ return sorted(filtered, key=attrgetter('bugID'))
283
284=== modified file 'lib/lp/bugs/model/bugtarget.py'
285--- lib/lp/bugs/model/bugtarget.py 2011-02-17 04:00:06 +0000
286+++ lib/lp/bugs/model/bugtarget.py 2011-03-28 00:03:43 +0000
287@@ -51,6 +51,7 @@
288 RESOLVED_BUGTASK_STATUSES,
289 UNRESOLVED_BUGTASK_STATUSES,
290 )
291+from lp.bugs.interfaces.bugtaskfilter import simple_weight_calculator
292 from lp.bugs.model.bugtask import (
293 BugTaskSet,
294 get_bug_privacy_filter,
295@@ -233,6 +234,10 @@
296 counts = cur.fetchone()
297 return dict(zip(statuses, counts))
298
299+ def getBugTaskWeightFunction(self):
300+ """Default weight function is the simple one."""
301+ return simple_weight_calculator
302+
303
304 class BugTargetBase(HasBugsBase):
305 """Standard functionality for IBugTargets.
306
307=== modified file 'lib/lp/bugs/model/bugtask.py'
308--- lib/lp/bugs/model/bugtask.py 2011-03-28 00:03:41 +0000
309+++ lib/lp/bugs/model/bugtask.py 2011-03-28 00:03:43 +0000
310@@ -2323,16 +2323,25 @@
311 def _buildBlueprintRelatedClause(self, params):
312 """Find bugs related to Blueprints, or not."""
313 linked_blueprints = params.linked_blueprints
314- if linked_blueprints == BugBlueprintSearch.BUGS_WITH_BLUEPRINTS:
315- return "EXISTS (%s)" % (
316- "SELECT 1 FROM SpecificationBug"
317- " WHERE SpecificationBug.bug = Bug.id")
318- elif linked_blueprints == BugBlueprintSearch.BUGS_WITHOUT_BLUEPRINTS:
319- return "NOT EXISTS (%s)" % (
320- "SELECT 1 FROM SpecificationBug"
321- " WHERE SpecificationBug.bug = Bug.id")
322+ if linked_blueprints is None:
323+ return None
324+ elif zope_isinstance(linked_blueprints, BaseItem):
325+ if linked_blueprints == BugBlueprintSearch.BUGS_WITH_BLUEPRINTS:
326+ return "EXISTS (%s)" % (
327+ "SELECT 1 FROM SpecificationBug"
328+ " WHERE SpecificationBug.bug = Bug.id")
329+ elif (linked_blueprints ==
330+ BugBlueprintSearch.BUGS_WITHOUT_BLUEPRINTS):
331+ return "NOT EXISTS (%s)" % (
332+ "SELECT 1 FROM SpecificationBug"
333+ " WHERE SpecificationBug.bug = Bug.id")
334 else:
335- return None
336+ # A specific search term has been supplied.
337+ return """EXISTS (
338+ SELECT TRUE FROM SpecificationBug
339+ WHERE SpecificationBug.bug=Bug.id AND
340+ SpecificationBug.specification %s)
341+ """ % search_value_to_where_condition(linked_blueprints)
342
343 def buildOrigin(self, join_tables, prejoin_tables, clauseTables):
344 """Build the parameter list for Store.using().
345
346=== modified file 'lib/lp/bugs/model/tests/test_bugtask.py'
347--- lib/lp/bugs/model/tests/test_bugtask.py 2011-03-22 01:33:16 +0000
348+++ lib/lp/bugs/model/tests/test_bugtask.py 2011-03-28 00:03:43 +0000
349@@ -58,6 +58,7 @@
350 login_person,
351 logout,
352 normalize_whitespace,
353+ person_logged_in,
354 StormStatementRecorder,
355 TestCase,
356 TestCaseWithFactory,
357@@ -1010,6 +1011,27 @@
358 self.assertEqual([task2], list(result))
359
360
361+class BugTaskSetSearchTest(TestCaseWithFactory):
362+
363+ layer = DatabaseFunctionalLayer
364+
365+ def test_explicit_blueprint_specified(self):
366+ # If the linked_blueprints is an integer id, then only bugtasks for
367+ # bugs that are linked to that blueprint are returned.
368+ bug1 = self.factory.makeBug()
369+ blueprint1 = self.factory.makeBlueprint()
370+ with person_logged_in(blueprint1.owner):
371+ blueprint1.linkBug(bug1)
372+ bug2 = self.factory.makeBug()
373+ blueprint2 = self.factory.makeBlueprint()
374+ with person_logged_in(blueprint2.owner):
375+ blueprint2.linkBug(bug2)
376+ self.factory.makeBug()
377+ params = BugTaskSearchParams(user=None, linked_blueprints=blueprint1.id)
378+ tasks = set(getUtility(IBugTaskSet).search(params))
379+ self.assertThat(set(bug1.bugtasks), Equals(tasks))
380+
381+
382 class BugTaskSearchBugsElsewhereTest(unittest.TestCase):
383 """Tests for searching bugs filtering on related bug tasks.
384
385
386=== added file 'lib/lp/bugs/tests/test_bugtaskfilter.py'
387--- lib/lp/bugs/tests/test_bugtaskfilter.py 1970-01-01 00:00:00 +0000
388+++ lib/lp/bugs/tests/test_bugtaskfilter.py 2011-03-28 00:03:43 +0000
389@@ -0,0 +1,196 @@
390+# Copyright 2011 Canonical Ltd. This software is licensed under the
391+# GNU Affero General Public License version 3 (see the file LICENSE).
392+
393+"""Tests for lp.bugs.interfaces.bugtaskfilter."""
394+
395+__metaclass__ = type
396+
397+from testtools.matchers import Equals
398+
399+from canonical.testing.layers import DatabaseFunctionalLayer
400+from lp.bugs.interfaces.bugtaskfilter import filter_bugtasks_by_context
401+from lp.testing import (
402+ StormStatementRecorder,
403+ TestCaseWithFactory,
404+ )
405+from lp.testing.matchers import HasQueryCount
406+
407+
408+class TestFilterBugTasksByContext(TestCaseWithFactory):
409+
410+ layer = DatabaseFunctionalLayer
411+
412+ def test_simple_case(self):
413+ bug = self.factory.makeBug()
414+ tasks = list(bug.bugtasks)
415+ self.assertThat(
416+ filter_bugtasks_by_context(None, tasks),
417+ Equals(tasks))
418+
419+ def test_multiple_bugs(self):
420+ bug1 = self.factory.makeBug()
421+ bug2 = self.factory.makeBug()
422+ bug3 = self.factory.makeBug()
423+ tasks = list(bug1.bugtasks)
424+ tasks.extend(bug2.bugtasks)
425+ tasks.extend(bug3.bugtasks)
426+ with StormStatementRecorder() as recorder:
427+ filtered = filter_bugtasks_by_context(None, tasks)
428+ self.assertThat(recorder, HasQueryCount(Equals(0)))
429+ self.assertThat(len(filtered), Equals(3))
430+ self.assertThat(filtered, Equals(tasks))
431+
432+ def test_two_product_tasks_case_no_context(self):
433+ widget = self.factory.makeProduct()
434+ bug = self.factory.makeBug(product=widget)
435+ cogs = self.factory.makeProduct()
436+ self.factory.makeBugTask(bug=bug, target=cogs)
437+ tasks = list(bug.bugtasks)
438+ with StormStatementRecorder() as recorder:
439+ filtered = filter_bugtasks_by_context(None, tasks)
440+ self.assertThat(recorder, HasQueryCount(Equals(0)))
441+ self.assertThat(filtered, Equals([bug.getBugTask(widget)]))
442+
443+ def test_two_product_tasks_case(self):
444+ widget = self.factory.makeProduct()
445+ bug = self.factory.makeBug(product=widget)
446+ cogs = self.factory.makeProduct()
447+ task = self.factory.makeBugTask(bug=bug, target=cogs)
448+ tasks = list(bug.bugtasks)
449+ with StormStatementRecorder() as recorder:
450+ filtered = filter_bugtasks_by_context(cogs, tasks)
451+ self.assertThat(recorder, HasQueryCount(Equals(0)))
452+ self.assertThat(filtered, Equals([task]))
453+
454+ def test_product_context_with_series_task(self):
455+ bug = self.factory.makeBug()
456+ widget = self.factory.makeProduct()
457+ task = self.factory.makeBugTask(bug=bug, target=widget)
458+ self.factory.makeBugTask(bug=bug, target=widget.development_focus)
459+ tasks = list(bug.bugtasks)
460+ with StormStatementRecorder() as recorder:
461+ filtered = filter_bugtasks_by_context(widget, tasks)
462+ self.assertThat(recorder, HasQueryCount(Equals(0)))
463+ self.assertThat(filtered, Equals([task]))
464+
465+ def test_productseries_context_with_series_task(self):
466+ bug = self.factory.makeBug()
467+ widget = self.factory.makeProduct()
468+ self.factory.makeBugTask(bug=bug, target=widget)
469+ series = widget.development_focus
470+ task = self.factory.makeBugTask(bug=bug, target=series)
471+ tasks = list(bug.bugtasks)
472+ with StormStatementRecorder() as recorder:
473+ filtered = filter_bugtasks_by_context(series, tasks)
474+ self.assertThat(recorder, HasQueryCount(Equals(0)))
475+ self.assertThat(filtered, Equals([task]))
476+
477+ def test_productseries_context_with_only_product_task(self):
478+ bug = self.factory.makeBug()
479+ widget = self.factory.makeProduct()
480+ task = self.factory.makeBugTask(bug=bug, target=widget)
481+ series = widget.development_focus
482+ tasks = list(bug.bugtasks)
483+ with StormStatementRecorder() as recorder:
484+ filtered = filter_bugtasks_by_context(series, tasks)
485+ self.assertThat(recorder, HasQueryCount(Equals(0)))
486+ self.assertThat(filtered, Equals([task]))
487+
488+ def test_distro_context(self):
489+ bug = self.factory.makeBug()
490+ mint = self.factory.makeDistribution()
491+ task = self.factory.makeBugTask(bug=bug, target=mint)
492+ tasks = list(bug.bugtasks)
493+ with StormStatementRecorder() as recorder:
494+ filtered = filter_bugtasks_by_context(mint, tasks)
495+ self.assertThat(recorder, HasQueryCount(Equals(0)))
496+ self.assertThat(filtered, Equals([task]))
497+
498+ def test_distro_context_with_series_task(self):
499+ bug = self.factory.makeBug()
500+ mint = self.factory.makeDistribution()
501+ task = self.factory.makeBugTask(bug=bug, target=mint)
502+ devel = self.factory.makeDistroSeries(mint)
503+ self.factory.makeBugTask(bug=bug, target=devel)
504+ tasks = list(bug.bugtasks)
505+ with StormStatementRecorder() as recorder:
506+ filtered = filter_bugtasks_by_context(mint, tasks)
507+ self.assertThat(recorder, HasQueryCount(Equals(0)))
508+ self.assertThat(filtered, Equals([task]))
509+
510+ def test_distroseries_context_with_series_task(self):
511+ bug = self.factory.makeBug()
512+ mint = self.factory.makeDistribution()
513+ self.factory.makeBugTask(bug=bug, target=mint)
514+ devel = self.factory.makeDistroSeries(mint)
515+ task = self.factory.makeBugTask(bug=bug, target=devel)
516+ tasks = list(bug.bugtasks)
517+ with StormStatementRecorder() as recorder:
518+ filtered = filter_bugtasks_by_context(devel, tasks)
519+ self.assertThat(recorder, HasQueryCount(Equals(0)))
520+ self.assertThat(filtered, Equals([task]))
521+
522+ def test_distroseries_context_with_no_series_task(self):
523+ bug = self.factory.makeBug()
524+ mint = self.factory.makeDistribution()
525+ task = self.factory.makeBugTask(bug=bug, target=mint)
526+ devel = self.factory.makeDistroSeries(mint)
527+ tasks = list(bug.bugtasks)
528+ with StormStatementRecorder() as recorder:
529+ filtered = filter_bugtasks_by_context(devel, tasks)
530+ self.assertThat(recorder, HasQueryCount(Equals(0)))
531+ self.assertThat(filtered, Equals([task]))
532+
533+ def test_sourcepackage_context_with_sourcepackage_task(self):
534+ bug = self.factory.makeBug()
535+ sp = self.factory.makeSourcePackage()
536+ task = self.factory.makeBugTask(bug=bug, target=sp)
537+ tasks = list(bug.bugtasks)
538+ with StormStatementRecorder() as recorder:
539+ filtered = filter_bugtasks_by_context(sp, tasks)
540+ self.assertThat(recorder, HasQueryCount(Equals(0)))
541+ self.assertThat(filtered, Equals([task]))
542+
543+ def test_sourcepackage_context_with_distrosourcepackage_task(self):
544+ bug = self.factory.makeBug()
545+ sp = self.factory.makeSourcePackage()
546+ dsp = sp.distribution_sourcepackage
547+ task = self.factory.makeBugTask(bug=bug, target=dsp)
548+ tasks = list(bug.bugtasks)
549+ with StormStatementRecorder() as recorder:
550+ filtered = filter_bugtasks_by_context(sp, tasks)
551+ self.assertThat(recorder, HasQueryCount(Equals(0)))
552+ self.assertThat(filtered, Equals([task]))
553+
554+ def test_sourcepackage_context_series_task(self):
555+ bug = self.factory.makeBug()
556+ sp = self.factory.makeSourcePackage()
557+ task = self.factory.makeBugTask(bug=bug, target=sp.distroseries)
558+ tasks = list(bug.bugtasks)
559+ with StormStatementRecorder() as recorder:
560+ filtered = filter_bugtasks_by_context(sp, tasks)
561+ self.assertThat(recorder, HasQueryCount(Equals(0)))
562+ self.assertThat(filtered, Equals([task]))
563+
564+ def test_sourcepackage_context_distro_task(self):
565+ bug = self.factory.makeBug()
566+ sp = self.factory.makeSourcePackage()
567+ task = self.factory.makeBugTask(bug=bug, target=sp.distribution)
568+ tasks = list(bug.bugtasks)
569+ with StormStatementRecorder() as recorder:
570+ filtered = filter_bugtasks_by_context(sp, tasks)
571+ self.assertThat(recorder, HasQueryCount(Equals(0)))
572+ self.assertThat(filtered, Equals([task]))
573+
574+ def test_sourcepackage_context_distro_task_with_other_distro_package(self):
575+ bug = self.factory.makeBug()
576+ sp = self.factory.makeSourcePackage()
577+ task = self.factory.makeBugTask(bug=bug, target=sp.distribution)
578+ other_sp = self.factory.makeSourcePackage(
579+ sourcepackagename=sp.sourcepackagename)
580+ self.factory.makeBugTask(bug=bug, target=other_sp)
581+ tasks = list(bug.bugtasks)
582+ with StormStatementRecorder() as recorder:
583+ filtered = filter_bugtasks_by_context(sp, tasks)
584+ self.assertThat(recorder, HasQueryCount(Equals(0)))
585+ self.assertThat(filtered, Equals([task]))
586
587=== modified file 'lib/lp/code/interfaces/branch.py'
588--- lib/lp/code/interfaces/branch.py 2011-03-24 12:03:02 +0000
589+++ lib/lp/code/interfaces/branch.py 2011-03-28 00:03:43 +0000
590@@ -418,7 +418,7 @@
591 When multiple tasks are on a bug, if one of the tasks is for the
592 branch.target, then only that task is returned. Otherwise the default
593 bug task is returned.
594-
595+
596 :param user: The user doing the search.
597 :param status_filter: Passed onto the bug search as a constraint.
598 """
599
600=== modified file 'lib/lp/code/model/branch.py'
601--- lib/lp/code/model/branch.py 2011-03-23 16:28:51 +0000
602+++ lib/lp/code/model/branch.py 2011-03-28 00:03:43 +0000
603@@ -7,7 +7,6 @@
604 __all__ = [
605 'Branch',
606 'BranchSet',
607- 'filter_one_task_per_bug',
608 ]
609
610 from datetime import datetime
611@@ -75,6 +74,7 @@
612 BugTaskSearchParams,
613 IBugTaskSet,
614 )
615+from lp.bugs.interfaces.bugtaskfilter import filter_bugtasks_by_context
616 from lp.buildmaster.model.buildqueue import BuildQueue
617 from lp.code.bzr import (
618 BranchFormat,
619@@ -316,7 +316,7 @@
620 tasks = shortlist(getUtility(IBugTaskSet).search(params), 1000)
621 # Post process to discard irrelevant tasks: we only return one task per
622 # bug, and cannot easily express this in sql (yet).
623- return filter_one_task_per_bug(self, tasks)
624+ return filter_bugtasks_by_context(self.target.context, tasks)
625
626 def linkBug(self, bug, registrant):
627 """See `IBranch`."""
628@@ -1390,27 +1390,3 @@
629 """
630 update_trigger_modified_fields(branch)
631 send_branch_modified_notifications(branch, event)
632-
633-
634-def filter_one_task_per_bug(branch, tasks):
635- """Given bug tasks for a branch, discard irrelevant ones.
636-
637- Cannot easily be expressed in SQL yet, so we need this helper method.
638- """
639- order = {}
640- bugtarget = branch.target.context
641- # First pass calculates the order and selects the bugtasks that match
642- # our target.
643- # Second pass selects the earliest bugtask where the bug has no task on
644- # our target.
645- for pos, task in enumerate(tasks):
646- bug = task.bug
647- if bug not in order:
648- order[bug] = [pos, None]
649- if task.target == bugtarget:
650- order[bug][1] = task
651- for task in tasks:
652- index = order[task.bug]
653- if index[1] is None:
654- index[1] = task
655- return [task for pos, task in sorted(order.values())]
656
657=== modified file 'lib/lp/code/model/branchcollection.py'
658--- lib/lp/code/model/branchcollection.py 2011-03-26 19:33:54 +0000
659+++ lib/lp/code/model/branchcollection.py 2011-03-28 00:03:43 +0000
660@@ -41,6 +41,7 @@
661 IBugTaskSet,
662 BugTaskSearchParams,
663 )
664+from lp.bugs.interfaces.bugtaskfilter import filter_bugtasks_by_context
665 from lp.bugs.model.bugbranch import BugBranch
666 from lp.bugs.model.bugtask import BugTask
667 from lp.code.interfaces.branch import user_has_special_branch_access
668@@ -54,10 +55,7 @@
669 from lp.code.enums import BranchMergeProposalStatus
670 from lp.code.interfaces.branchlookup import IBranchLookup
671 from lp.code.interfaces.codehosting import LAUNCHPAD_SERVICES
672-from lp.code.model.branch import (
673- Branch,
674- filter_one_task_per_bug,
675- )
676+from lp.code.model.branch import Branch
677 from lp.code.model.branchmergeproposal import BranchMergeProposal
678 from lp.code.model.branchsubscription import BranchSubscription
679 from lp.code.model.codeimport import CodeImport
680@@ -342,7 +340,7 @@
681 # Now filter those down to one bugtask per branch
682 for branch, tasks in bugtasks_for_branch.iteritems():
683 linked_bugtasks[branch.id].extend(
684- filter_one_task_per_bug(branch, tasks))
685+ filter_bugtasks_by_context(branch.target.context, tasks))
686
687 return [make_rev_info(
688 rev, merge_proposal_revs, linked_bugtasks)
689
690=== modified file 'lib/lp/registry/interfaces/distroseries.py'
691--- lib/lp/registry/interfaces/distroseries.py 2011-03-10 14:05:51 +0000
692+++ lib/lp/registry/interfaces/distroseries.py 2011-03-28 00:03:43 +0000
693@@ -223,6 +223,7 @@
694 Interface, # Really IDistribution, see circular import fix below.
695 title=_("Distribution"), required=True,
696 description=_("The distribution for which this is a series.")))
697+ distributionID = Attribute('The distribution ID.')
698 named_version = Attribute('The combined display name and version.')
699 parent = Attribute('The structural parent of this series - the distro')
700 components = Attribute("The series components.")
701
702=== modified file 'lib/lp/registry/interfaces/productseries.py'
703--- lib/lp/registry/interfaces/productseries.py 2011-03-24 14:13:45 +0000
704+++ lib/lp/registry/interfaces/productseries.py 2011-03-28 00:03:43 +0000
705@@ -137,6 +137,7 @@
706 ReferenceChoice(title=_('Project'), required=True,
707 vocabulary='Product', schema=Interface), # really IProduct
708 exported_as='project')
709+ productID = Attribute('The product ID.')
710
711 status = exported(
712 Choice(
713
714=== modified file 'lib/lp/registry/model/distribution.py'
715--- lib/lp/registry/model/distribution.py 2011-03-24 11:21:19 +0000
716+++ lib/lp/registry/model/distribution.py 2011-03-28 00:03:43 +0000
717@@ -107,6 +107,7 @@
718 BugTaskStatus,
719 UNRESOLVED_BUGTASK_STATUSES,
720 )
721+from lp.bugs.interfaces.bugtaskfilter import OrderedBugTask
722 from lp.bugs.model.bug import (
723 BugSet,
724 get_bug_tags,
725@@ -1840,6 +1841,22 @@
726 productseries = sourcepackage.productseries
727 return productseries.product.invitesTranslationEdits(person, language)
728
729+ def getBugTaskWeightFunction(self):
730+ """Provide a weight function to determine optimal bug task.
731+
732+ Full weight is given to tasks for this distribution.
733+
734+ Given that there must be a distribution task for a series of that
735+ distribution to have a task, we give no more weighting to a
736+ distroseries task than any other.
737+ """
738+ distributionID = self.id
739+ def weight_function(bugtask):
740+ if bugtask.distributionID == distributionID:
741+ return OrderedBugTask(1, bugtask.id, bugtask)
742+ return OrderedBugTask(2, bugtask.id, bugtask)
743+ return weight_function
744+
745
746 class DistributionSet:
747 """This class is to deal with Distribution related stuff"""
748
749=== modified file 'lib/lp/registry/model/distroseries.py'
750--- lib/lp/registry/model/distroseries.py 2011-03-24 11:46:45 +0000
751+++ lib/lp/registry/model/distroseries.py 2011-03-28 00:03:43 +0000
752@@ -83,6 +83,7 @@
753 Specification,
754 )
755 from lp.bugs.interfaces.bugtarget import IHasBugHeat
756+from lp.bugs.interfaces.bugtaskfilter import OrderedBugTask
757 from lp.bugs.model.bug import (
758 get_bug_tags,
759 get_bug_tags_open_count,
760@@ -1982,6 +1983,25 @@
761 return Store.of(self).find(
762 DistroSeries, DistroSeries.parent_series == self)
763
764+ def getBugTaskWeightFunction(self):
765+ """Provide a weight function to determine optimal bug task.
766+
767+ Full weight is given to tasks for this distro series.
768+
769+ If the series isn't found, the distribution task is better than
770+ others.
771+ """
772+ seriesID = self.id
773+ distributionID = self.distributionID
774+ def weight_function(bugtask):
775+ if bugtask.distroseriesID == seriesID:
776+ return OrderedBugTask(1, bugtask.id, bugtask)
777+ elif bugtask.distributionID == distributionID:
778+ return OrderedBugTask(2, bugtask.id, bugtask)
779+ else:
780+ return OrderedBugTask(3, bugtask.id, bugtask)
781+ return weight_function
782+
783
784 class DistroSeriesSet:
785 implements(IDistroSeriesSet)
786
787=== modified file 'lib/lp/registry/model/product.py'
788--- lib/lp/registry/model/product.py 2011-03-24 20:48:35 +0000
789+++ lib/lp/registry/model/product.py 2011-03-28 00:03:43 +0000
790@@ -113,6 +113,7 @@
791 from lp.blueprints.model.sprint import HasSprintsMixin
792 from lp.bugs.interfaces.bugsupervisor import IHasBugSupervisor
793 from lp.bugs.interfaces.bugtarget import IHasBugHeat
794+from lp.bugs.interfaces.bugtaskfilter import OrderedBugTask
795 from lp.bugs.model.bug import (
796 BugSet,
797 get_bug_tags,
798@@ -1331,6 +1332,22 @@
799 SourcePackageRecipeData.base_branch == Branch.id,
800 Branch.product == self)
801
802+ def getBugTaskWeightFunction(self):
803+ """Provide a weight function to determine optimal bug task.
804+
805+ Full weight is given to tasks for this product.
806+
807+ Given that there must be a product task for a series of that product
808+ to have a task, we give no more weighting to a productseries task than
809+ any other.
810+ """
811+ productID = self.id
812+ def weight_function(bugtask):
813+ if bugtask.productID == productID:
814+ return OrderedBugTask(1, bugtask.id, bugtask)
815+ return OrderedBugTask(2, bugtask.id, bugtask)
816+ return weight_function
817+
818
819 class ProductSet:
820 implements(IProductSet)
821
822=== modified file 'lib/lp/registry/model/productseries.py'
823--- lib/lp/registry/model/productseries.py 2011-03-24 11:01:45 +0000
824+++ lib/lp/registry/model/productseries.py 2011-03-28 00:03:43 +0000
825@@ -59,6 +59,7 @@
826 Specification,
827 )
828 from lp.bugs.interfaces.bugtarget import IHasBugHeat
829+from lp.bugs.interfaces.bugtaskfilter import OrderedBugTask
830 from lp.bugs.model.bug import (
831 get_bug_tags,
832 get_bug_tags_open_count,
833@@ -661,6 +662,24 @@
834 landmarks=landmarks,
835 product=self.product)
836
837+ def getBugTaskWeightFunction(self):
838+ """Provide a weight function to determine optimal bug task.
839+
840+ Full weight is given to tasks for this product series.
841+
842+ If the series isn't found, the product task is better than others.
843+ """
844+ seriesID = self.id
845+ productID = self.productID
846+ def weight_function(bugtask):
847+ if bugtask.productseriesID == seriesID:
848+ return OrderedBugTask(1, bugtask.id, bugtask)
849+ elif bugtask.productID == productID:
850+ return OrderedBugTask(2, bugtask.id, bugtask)
851+ else:
852+ return OrderedBugTask(3, bugtask.id, bugtask)
853+ return weight_function
854+
855
856 class TimelineProductSeries:
857 """See `ITimelineProductSeries`."""
858
859=== modified file 'lib/lp/registry/model/sourcepackage.py'
860--- lib/lp/registry/model/sourcepackage.py 2011-03-24 11:01:45 +0000
861+++ lib/lp/registry/model/sourcepackage.py 2011-03-28 00:03:43 +0000
862@@ -41,6 +41,7 @@
863 QuestionTargetSearch,
864 )
865 from lp.bugs.interfaces.bugtarget import IHasBugHeat
866+from lp.bugs.interfaces.bugtaskfilter import OrderedBugTask
867 from lp.bugs.model.bug import get_bug_tags_open_count
868 from lp.bugs.model.bugtarget import (
869 BugTargetBase,
870@@ -756,3 +757,27 @@
871 def linkedBranches(self):
872 """See `ISourcePackage`."""
873 return dict((p.name, b) for (p, b) in self.linked_branches)
874+
875+ def getBugTaskWeightFunction(self):
876+ """Provide a weight function to determine optimal bug task.
877+
878+ We look for the source package task, followed by the distro source
879+ package, then the distroseries task, and lastly the distro task.
880+ """
881+ sourcepackagenameID = self.sourcepackagename.id
882+ seriesID = self.distroseries.id
883+ distributionID = self.distroseries.distributionID
884+ def weight_function(bugtask):
885+ if bugtask.sourcepackagenameID == sourcepackagenameID:
886+ if bugtask.distroseriesID == seriesID:
887+ return OrderedBugTask(1, bugtask.id, bugtask)
888+ elif bugtask.distributionID == distributionID:
889+ return OrderedBugTask(2, bugtask.id, bugtask)
890+ elif bugtask.distroseriesID == seriesID:
891+ return OrderedBugTask(3, bugtask.id, bugtask)
892+ elif bugtask.distributionID == distributionID:
893+ return OrderedBugTask(4, bugtask.id, bugtask)
894+ # Catch the default case, and where there is a task for the same
895+ # sourcepackage on a different distro.
896+ return OrderedBugTask(5, bugtask.id, bugtask)
897+ return weight_function