← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Tim Penhey has proposed merging lp:~thumper/launchpad/blueprint-linked-bug-tasks into lp:launchpad with lp:~thumper/launchpad/add-publishing-for-factory-distro-sourcepackage-bug-tasks as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~thumper/launchpad/blueprint-linked-bug-tasks/+merge/53734

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.
-- 
https://code.launchpad.net/~thumper/launchpad/blueprint-linked-bug-tasks/+merge/53734
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~thumper/launchpad/blueprint-linked-bug-tasks into lp:launchpad.
=== modified file 'lib/lp/blueprints/browser/specification.py'
--- lib/lp/blueprints/browser/specification.py	2011-03-10 01:25:13 +0000
+++ lib/lp/blueprints/browser/specification.py	2011-03-17 03:13:32 +0000
@@ -557,8 +557,7 @@
 
     @cachedproperty
     def bug_links(self):
-        return [bug_link for bug_link in self.context.bug_links
-                if check_permission('launchpad.View', bug_link.bug)]
+        return self.context.getLinkedBugTasks(self.user)
 
 
 class SpecificationView(SpecificationSimpleView):

=== modified file 'lib/lp/blueprints/interfaces/specification.py'
--- lib/lp/blueprints/interfaces/specification.py	2011-03-08 22:45:14 +0000
+++ lib/lp/blueprints/interfaces/specification.py	2011-03-17 03:13:32 +0000
@@ -506,6 +506,16 @@
     def getBranchLink(branch):
         """Return the SpecificationBranch link for the branch, or None."""
 
+    def getLinkedBugTasks(user):
+        """Return the bug tasks that are relevant to this blueprint.
+
+        When multiple tasks are on a bug, if one of the tasks is for the
+        target, then only that task is returned. Otherwise the default
+        bug task is returned.
+
+        :param user: The user doing the search.
+        """
+
 
 class ISpecificationEditRestricted(Interface):
     """Specification's attributes and methods protected with launchpad.Edit.

=== modified file 'lib/lp/blueprints/model/specification.py'
--- lib/lp/blueprints/model/specification.py	2011-03-14 22:14:13 +0000
+++ lib/lp/blueprints/model/specification.py	2011-03-17 03:13:32 +0000
@@ -30,6 +30,7 @@
     SQL,
     )
 from storm.store import Store
+from zope.component import getUtility
 from zope.event import notify
 from zope.interface import implements
 
@@ -77,6 +78,11 @@
     SpecificationSubscription,
     )
 from lp.bugs.interfaces.buglink import IBugLinkTarget
+from lp.bugs.interfaces.bugtask import (
+    BugTaskSearchParams,
+    IBugTaskSet,
+    )
+from lp.bugs.interfaces.bugtaskfilter import filter_bugtasks_by_context
 from lp.bugs.model.buglinktarget import BugLinkTargetMixin
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.distroseries import IDistroSeries
@@ -669,11 +675,26 @@
         spec_branch = self.getBranchLink(branch)
         spec_branch.destroySelf()
 
+    def getLinkedBugTasks(self, user):
+        """See `ISpecification`."""
+        params = BugTaskSearchParams(user=user, linked_blueprints=self.id)
+        tasks = getUtility(IBugTaskSet).search(params)
+        if self.distroseries is not None:
+            context = self.distroseries
+        elif self.distribution is not None:
+            context = self.distribution
+        elif self.productseries is not None:
+            context = self.productseries
+        else:
+            context = self.product
+        return filter_bugtasks_by_context(context, tasks)
+
     def __repr__(self):
         return '<Specification %s %r for %r>' % (
             self.id, self.name, self.target.name)
 
 
+
 class HasSpecificationsMixin:
     """A mixin class that implements many of the common shortcut properties
     for other classes that have specifications.

=== modified file 'lib/lp/blueprints/stories/blueprints/xx-buglinks.txt'
--- lib/lp/blueprints/stories/blueprints/xx-buglinks.txt	2010-08-26 02:30:06 +0000
+++ lib/lp/blueprints/stories/blueprints/xx-buglinks.txt	2011-03-17 03:13:32 +0000
@@ -13,7 +13,7 @@
     ...     'http://launchpad.dev/firefox/+spec/svg-support')
     >>> print extract_text(find_tag_by_id(anon_browser.contents, 'bug_links'))
     Related bugs
-    Bug #1: Firefox does not support SVG
+    Bug #1: Firefox does not support SVG   New
 
 
 == Adding Links ==

=== modified file 'lib/lp/blueprints/templates/specification-index.pt'
--- lib/lp/blueprints/templates/specification-index.pt	2011-03-10 01:44:17 +0000
+++ lib/lp/blueprints/templates/specification-index.pt	2011-03-17 03:13:32 +0000
@@ -208,11 +208,18 @@
           <div id="bug_links">
             <h3>Related bugs</h3>
 
-            <ul tal:condition="view/bug_links">
-              <li tal:repeat="link view/bug_links">
-                <tal:link replace="structure link/bug/fmt:link" />
-              </li>
-            </ul>
+            <table tal:condition="view/bug_links">
+              <tr tal:repeat="bugtask view/bug_links">
+                <td>
+                  <tal:link replace="structure bugtask/fmt:link" />
+                </td>
+                <td>
+                  <span tal:content="bugtask/status/title"
+                        tal:attributes="class string:status${bugtask/status/name}"
+                        >Triaged</span>
+                </td>
+              </tr>
+            </table>
 
             <ul class="horizontal">
               <li tal:define="link context_menu/linkbug"

=== modified file 'lib/lp/bugs/configure.zcml'
--- lib/lp/bugs/configure.zcml	2011-03-15 04:15:45 +0000
+++ lib/lp/bugs/configure.zcml	2011-03-17 03:13:32 +0000
@@ -190,8 +190,12 @@
                     date_fix_released
                     date_left_closed
                     date_closed
+                    distributionID
+                    distroseriesID
                     milestoneID
+                    productID
                     productseriesID
+                    sourcepackagenameID
                     task_age
                     bug_subscribers
                     is_complete

=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
--- lib/lp/bugs/interfaces/bugtask.py	2011-03-15 22:58:07 +0000
+++ lib/lp/bugs/interfaces/bugtask.py	2011-03-17 03:13:32 +0000
@@ -462,17 +462,21 @@
         BugField(title=_("Bug"), readonly=True))
     product = Choice(
         title=_('Project'), required=False, vocabulary='Product')
+    productID = Attribute('The product ID')
     productseries = Choice(
         title=_('Series'), required=False, vocabulary='ProductSeries')
     productseriesID = Attribute('The product series ID')
     sourcepackagename = Choice(
         title=_("Package"), required=False,
         vocabulary='SourcePackageName')
+    sourcepackagenameID = Attribute('The sourcepackagename ID')
     distribution = Choice(
         title=_("Distribution"), required=False, vocabulary='Distribution')
+    distributionID = Attribute('The distribution ID')
     distroseries = Choice(
         title=_("Series"), required=False,
         vocabulary='DistroSeries')
+    distroseriesID = Attribute('The distroseries ID')
     milestone = exported(ReferenceChoice(
         title=_('Milestone'),
         required=False,

=== added file 'lib/lp/bugs/interfaces/bugtaskfilter.py'
--- lib/lp/bugs/interfaces/bugtaskfilter.py	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/interfaces/bugtaskfilter.py	2011-03-17 03:13:32 +0000
@@ -0,0 +1,195 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Fiter bugtasks based on context."""
+
+__metaclass__ = type
+__all__ = [
+    'filter_bugtasks_by_context',
+    ]
+
+from collections import defaultdict, namedtuple
+from operator import attrgetter
+
+from lp.registry.interfaces.distribution import IDistribution
+from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.product import IProduct
+from lp.registry.interfaces.productseries import IProductSeries
+from lp.registry.interfaces.sourcepackage import ISourcePackage
+
+
+OrderedBugTask = namedtuple('OrderedBugTask', 'rank id task')
+
+
+class DistributionWeightCalculator:
+    """Give higher weighing to tasks for the matching distribution."""
+
+    def __init__(self, distribution):
+        self.distributionID = distribution.id
+
+    def __call__(self, bugtask):
+        """Full weight is given to tasks for this distribution.
+
+        Given that there must be a distribution task for a series of that
+        distribution to have a task, we give no more weighting to a
+        distroseries task than any other.
+        """
+        if bugtask.distributionID == self.distributionID:
+            return OrderedBugTask(1, bugtask.id, bugtask)
+        return OrderedBugTask(2, bugtask.id, bugtask)
+
+
+class DistroSeriesWeightCalculator:
+    """Try for the series first, the distro second, everything else third."""
+
+    def __init__(self, distro_series):
+        self.seriesID = distro_series.id
+        self.distributionID = distro_series.distributionID
+
+    def __call__(self, bugtask):
+        """Full weight is given to tasks for this distro series.
+
+        If the series isn't found, the distribution task is better than
+        others.
+        """
+        if bugtask.distroseriesID == self.seriesID:
+            return OrderedBugTask(1, bugtask.id, bugtask)
+        elif bugtask.distributionID == self.distributionID:
+            return OrderedBugTask(2, bugtask.id, bugtask)
+        else:
+            return OrderedBugTask(3, bugtask.id, bugtask)
+
+
+class ProductWeightCalculator:
+    """Give higher weighing to tasks for the matching product."""
+
+    def __init__(self, product):
+        self.productID = product.id
+
+    def __call__(self, bugtask):
+        """Full weight is given to tasks for this product.
+
+        Given that there must be a product task for a series of that product
+        to have a task, we give no more weighting to a productseries task than
+        any other.
+        """
+        if bugtask.productID == self.productID:
+            return OrderedBugTask(1, bugtask.id, bugtask)
+        return OrderedBugTask(2, bugtask.id, bugtask)
+
+
+class ProductSeriesWeightCalculator:
+    """Try for the series first, the product second, everything else third."""
+
+    def __init__(self, product_series):
+        self.seriesID = product_series.id
+        self.productID = product_series.productID
+
+    def __call__(self, bugtask):
+        """Full weight is given to tasks for this product series.
+
+        If the series isn't found, the product task is better than others.
+        """
+        if bugtask.productseriesID == self.seriesID:
+            return OrderedBugTask(1, bugtask.id, bugtask)
+        elif bugtask.productID == self.productID:
+            return OrderedBugTask(2, bugtask.id, bugtask)
+        else:
+            return OrderedBugTask(3, bugtask.id, bugtask)
+
+
+class SourcePackageWeightCalculator:
+    """This is the most complicated weight calculator.
+
+    We look for the source package task, followed by the distro source
+    package, then the distroseries task, and lastly the distro task.
+    """
+
+    def __init__(self, source_package):
+        self.sourcepackagenameID = source_package.sourcepackagename.id
+        self.seriesID = source_package.distroseries.id
+        self.distributionID = source_package.distroseries.distributionID
+
+    def __call__(self, bugtask):
+        """Full weight is given to tasks for this source package.
+
+        Failing that we try for the distro source package.
+        """
+        if bugtask.sourcepackagenameID == self.sourcepackagenameID:
+            if bugtask.distroseriesID == self.seriesID:
+                return OrderedBugTask(1, bugtask.id, bugtask)
+            elif bugtask.distributionID == self.distributionID:
+                return OrderedBugTask(2, bugtask.id, bugtask)
+        elif bugtask.distroseriesID == self.seriesID:
+            return OrderedBugTask(3, bugtask.id, bugtask)
+        elif bugtask.distributionID == self.distributionID:
+            return OrderedBugTask(4, bugtask.id, bugtask)
+        # Catch the default case, and where there is a task for the same
+        # sourcepackage on a different distro.
+        return OrderedBugTask(5, bugtask.id, bugtask)
+
+
+class SimpleWeightCalculator:
+    """All tasks have the same weighting."""
+
+    def __call__(self, bugtask):
+        return OrderedBugTask(1, bugtask.id, bugtask)
+
+
+def get_weight_calculator(context):
+    """Create the appropriate weight calculator for the context.
+
+    I did consider using adapters, but since this method is only called from
+    this module, there didn't seem like any point.
+    """
+    if IProduct.providedBy(context):
+        return ProductWeightCalculator(context)
+    elif IProductSeries.providedBy(context):
+        return ProductSeriesWeightCalculator(context)
+    elif IDistribution.providedBy(context):
+        return DistributionWeightCalculator(context)
+    elif IDistroSeries.providedBy(context):
+        return DistroSeriesWeightCalculator(context)
+    elif ISourcePackage.providedBy(context):
+        return SourcePackageWeightCalculator(context)
+    else:
+        return SimpleWeightCalculator()
+
+
+def filter_bugtasks_by_context(context, bugtasks):
+    """Return the bugtasks filtered so there is only one bug task per bug.
+
+    The context is used to return the most relevent bugtask for that context.
+
+    An initial constraint is to not require any database queries from this
+    method.
+
+    Current contexts that impact selection:
+      IProduct
+      IProductSeries
+      IDistribution
+      IDistroSeries
+      ISourcePackage
+    Others:
+      get the first bugtask for any particular bug
+
+    If the context is a Product, then return the product bug task if there is
+    one.  If the context is a ProductSeries, then return the productseries
+    task if there is one, and if there isn't, look for the product task.  A
+    similar approach is taked for Distribution and distroseries.
+
+    For source packages, we look for the source package task, followed by the
+    distro source package, then the distroseries task, and lastly the distro
+    task.
+
+    If there is no specific matching task, we return the first task (the one
+    with the smallest database id).
+    """
+    weight_calculator = get_weight_calculator(context)
+
+    bug_mapping = defaultdict(list)
+    for task in bugtasks:
+        bug_mapping[task.bugID].append(weight_calculator(task))
+
+    filtered = [sorted(tasks)[0].task for tasks in bug_mapping.itervalues()]
+    return sorted(filtered, key=attrgetter('bugID'))

=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py	2011-03-17 03:13:31 +0000
+++ lib/lp/bugs/model/bugtask.py	2011-03-17 03:13:32 +0000
@@ -2315,16 +2315,25 @@
     def _buildBlueprintRelatedClause(self, params):
         """Find bugs related to Blueprints, or not."""
         linked_blueprints = params.linked_blueprints
-        if linked_blueprints == BugBlueprintSearch.BUGS_WITH_BLUEPRINTS:
-            return "EXISTS (%s)" % (
-                "SELECT 1 FROM SpecificationBug"
-                " WHERE SpecificationBug.bug = Bug.id")
-        elif linked_blueprints == BugBlueprintSearch.BUGS_WITHOUT_BLUEPRINTS:
-            return "NOT EXISTS (%s)" % (
-                "SELECT 1 FROM SpecificationBug"
-                " WHERE SpecificationBug.bug = Bug.id")
+        if linked_blueprints is None:
+            return None
+        elif zope_isinstance(linked_blueprints, BaseItem):
+            if linked_blueprints == BugBlueprintSearch.BUGS_WITH_BLUEPRINTS:
+                return "EXISTS (%s)" % (
+                    "SELECT 1 FROM SpecificationBug"
+                    " WHERE SpecificationBug.bug = Bug.id")
+            elif (linked_blueprints ==
+                  BugBlueprintSearch.BUGS_WITHOUT_BLUEPRINTS):
+                return "NOT EXISTS (%s)" % (
+                    "SELECT 1 FROM SpecificationBug"
+                    " WHERE SpecificationBug.bug = Bug.id")
         else:
-            return None
+            # A specific search term has been supplied.
+            return """EXISTS (
+                    SELECT TRUE FROM SpecificationBug
+                    WHERE SpecificationBug.bug=Bug.id AND
+                    SpecificationBug.specification %s)
+                """ % search_value_to_where_condition(linked_blueprints)
 
     def buildOrigin(self, join_tables, prejoin_tables, clauseTables):
         """Build the parameter list for Store.using().

=== modified file 'lib/lp/bugs/model/tests/test_bugtask.py'
--- lib/lp/bugs/model/tests/test_bugtask.py	2011-03-07 21:21:21 +0000
+++ lib/lp/bugs/model/tests/test_bugtask.py	2011-03-17 03:13:32 +0000
@@ -58,6 +58,7 @@
     login_person,
     logout,
     normalize_whitespace,
+    person_logged_in,
     StormStatementRecorder,
     TestCase,
     TestCaseWithFactory,
@@ -1010,6 +1011,27 @@
         self.assertEqual([task2], list(result))
 
 
+class BugTaskSetSearchTest(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_explicit_blueprint_specified(self):
+        # If the linked_blueprints is an integer id, then only bugtasks for
+        # bugs that are linked to that blueprint are returned.
+        bug1 = self.factory.makeBug()
+        blueprint1 = self.factory.makeBlueprint()
+        with person_logged_in(blueprint1.owner):
+            blueprint1.linkBug(bug1)
+        bug2 = self.factory.makeBug()
+        blueprint2 = self.factory.makeBlueprint()
+        with person_logged_in(blueprint2.owner):
+            blueprint2.linkBug(bug2)
+        self.factory.makeBug()
+        params = BugTaskSearchParams(user=None, linked_blueprints=blueprint1.id)
+        tasks = set(getUtility(IBugTaskSet).search(params))
+        self.assertThat(set(bug1.bugtasks), Equals(tasks))
+
+
 class BugTaskSearchBugsElsewhereTest(unittest.TestCase):
     """Tests for searching bugs filtering on related bug tasks.
 

=== added file 'lib/lp/bugs/tests/test_bugtaskfilter.py'
--- lib/lp/bugs/tests/test_bugtaskfilter.py	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/tests/test_bugtaskfilter.py	2011-03-17 03:13:32 +0000
@@ -0,0 +1,196 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for lp.bugs.interfaces.bugtaskfilter."""
+
+__metaclass__ = type
+
+from testtools.matchers import Equals
+
+from canonical.testing.layers import DatabaseFunctionalLayer
+from lp.bugs.interfaces.bugtaskfilter import filter_bugtasks_by_context
+from lp.testing import (
+    StormStatementRecorder,
+    TestCaseWithFactory,
+    )
+from lp.testing.matchers import HasQueryCount
+
+
+class TestFilterBugTasksByContext(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_simple_case(self):
+        bug = self.factory.makeBug()
+        tasks = list(bug.bugtasks)
+        self.assertThat(
+            filter_bugtasks_by_context(None, tasks),
+            Equals(tasks))
+
+    def test_multiple_bugs(self):
+        bug1 = self.factory.makeBug()
+        bug2 = self.factory.makeBug()
+        bug3 = self.factory.makeBug()
+        tasks = list(bug1.bugtasks)
+        tasks.extend(bug2.bugtasks)
+        tasks.extend(bug3.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(None, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(len(filtered), Equals(3))
+        self.assertThat(filtered, Equals(tasks))
+
+    def test_two_product_tasks_case_no_context(self):
+        widget = self.factory.makeProduct()
+        bug = self.factory.makeBug(product=widget)
+        cogs = self.factory.makeProduct()
+        self.factory.makeBugTask(bug=bug, target=cogs)
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(None, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([bug.getBugTask(widget)]))
+
+    def test_two_product_tasks_case(self):
+        widget = self.factory.makeProduct()
+        bug = self.factory.makeBug(product=widget)
+        cogs = self.factory.makeProduct()
+        task = self.factory.makeBugTask(bug=bug, target=cogs)
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(cogs, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([task]))
+
+    def test_product_context_with_series_task(self):
+        bug = self.factory.makeBug()
+        widget = self.factory.makeProduct()
+        task = self.factory.makeBugTask(bug=bug, target=widget)
+        self.factory.makeBugTask(bug=bug, target=widget.development_focus)
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(widget, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([task]))
+
+    def test_productseries_context_with_series_task(self):
+        bug = self.factory.makeBug()
+        widget = self.factory.makeProduct()
+        self.factory.makeBugTask(bug=bug, target=widget)
+        series = widget.development_focus
+        task = self.factory.makeBugTask(bug=bug, target=series)
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(series, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([task]))
+
+    def test_productseries_context_with_only_product_task(self):
+        bug = self.factory.makeBug()
+        widget = self.factory.makeProduct()
+        task = self.factory.makeBugTask(bug=bug, target=widget)
+        series = widget.development_focus
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(series, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([task]))
+
+    def test_distro_context(self):
+        bug = self.factory.makeBug()
+        mint = self.factory.makeDistribution()
+        task = self.factory.makeBugTask(bug=bug, target=mint)
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(mint, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([task]))
+
+    def test_distro_context_with_series_task(self):
+        bug = self.factory.makeBug()
+        mint = self.factory.makeDistribution()
+        task = self.factory.makeBugTask(bug=bug, target=mint)
+        devel = self.factory.makeDistroSeries(mint)
+        self.factory.makeBugTask(bug=bug, target=devel)
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(mint, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([task]))
+
+    def test_distroseries_context_with_series_task(self):
+        bug = self.factory.makeBug()
+        mint = self.factory.makeDistribution()
+        self.factory.makeBugTask(bug=bug, target=mint)
+        devel = self.factory.makeDistroSeries(mint)
+        task = self.factory.makeBugTask(bug=bug, target=devel)
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(devel, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([task]))
+
+    def test_distroseries_context_with_no_series_task(self):
+        bug = self.factory.makeBug()
+        mint = self.factory.makeDistribution()
+        task = self.factory.makeBugTask(bug=bug, target=mint)
+        devel = self.factory.makeDistroSeries(mint)
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(devel, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([task]))
+
+    def test_sourcepackage_context_with_sourcepackage_task(self):
+        bug = self.factory.makeBug()
+        sp = self.factory.makeSourcePackage()
+        task = self.factory.makeBugTask(bug=bug, target=sp)
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(sp, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([task]))
+
+    def test_sourcepackage_context_with_distrosourcepackage_task(self):
+        bug = self.factory.makeBug()
+        sp = self.factory.makeSourcePackage()
+        dsp = sp.distribution_sourcepackage
+        task = self.factory.makeBugTask(bug=bug, target=dsp)
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(sp, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([task]))
+
+    def test_sourcepackage_context_series_task(self):
+        bug = self.factory.makeBug()
+        sp = self.factory.makeSourcePackage()
+        task = self.factory.makeBugTask(bug=bug, target=sp.distroseries)
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(sp, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([task]))
+
+    def test_sourcepackage_context_distro_task(self):
+        bug = self.factory.makeBug()
+        sp = self.factory.makeSourcePackage()
+        task = self.factory.makeBugTask(bug=bug, target=sp.distribution)
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(sp, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([task]))
+
+    def test_sourcepackage_context_distro_task_with_other_distro_package(self):
+        bug = self.factory.makeBug()
+        sp = self.factory.makeSourcePackage()
+        task = self.factory.makeBugTask(bug=bug, target=sp.distribution)
+        other_sp = self.factory.makeSourcePackage(
+            sourcepackagename=sp.sourcepackagename)
+        self.factory.makeBugTask(bug=bug, target=other_sp)
+        tasks = list(bug.bugtasks)
+        with StormStatementRecorder() as recorder:
+            filtered = filter_bugtasks_by_context(sp, tasks)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertThat(filtered, Equals([task]))

=== modified file 'lib/lp/code/interfaces/branch.py'
--- lib/lp/code/interfaces/branch.py	2011-03-03 01:13:47 +0000
+++ lib/lp/code/interfaces/branch.py	2011-03-17 03:13:32 +0000
@@ -415,7 +415,7 @@
         When multiple tasks are on a bug, if one of the tasks is for the
         branch.target, then only that task is returned. Otherwise the default
         bug task is returned.
-        
+
         :param user: The user doing the search.
         :param status_filter: Passed onto the bug search as a constraint.
         """

=== modified file 'lib/lp/code/model/branch.py'
--- lib/lp/code/model/branch.py	2011-03-15 14:31:00 +0000
+++ lib/lp/code/model/branch.py	2011-03-17 03:13:32 +0000
@@ -7,7 +7,6 @@
 __all__ = [
     'Branch',
     'BranchSet',
-    'filter_one_task_per_bug',
     ]
 
 from datetime import datetime
@@ -75,6 +74,7 @@
     BugTaskSearchParams,
     IBugTaskSet,
     )
+from lp.bugs.interfaces.bugtaskfilter import filter_bugtasks_by_context
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.code.bzr import (
     BranchFormat,
@@ -315,7 +315,7 @@
         tasks = shortlist(getUtility(IBugTaskSet).search(params), 1000)
         # Post process to discard irrelevant tasks: we only return one task per
         # bug, and cannot easily express this in sql (yet).
-        return filter_one_task_per_bug(self, tasks)
+        return filter_bugtasks_by_context(self.target.context, tasks)
 
     def linkBug(self, bug, registrant):
         """See `IBranch`."""
@@ -1387,27 +1387,3 @@
     """
     update_trigger_modified_fields(branch)
     send_branch_modified_notifications(branch, event)
-
-
-def filter_one_task_per_bug(branch, tasks):
-    """Given bug tasks for a branch, discard irrelevant ones.
-
-    Cannot easily be expressed in SQL yet, so we need this helper method.
-    """
-    order = {}
-    bugtarget = branch.target.context
-    # First pass calculates the order and selects the bugtasks that match
-    # our target.
-    # Second pass selects the earliest bugtask where the bug has no task on
-    # our target.
-    for pos, task in enumerate(tasks):
-        bug = task.bug
-        if bug not in order:
-            order[bug] = [pos, None]
-        if task.target == bugtarget:
-            order[bug][1] = task
-    for task in tasks:
-        index = order[task.bug]
-        if index[1] is None:
-            index[1] = task
-    return [task for pos, task in sorted(order.values())]

=== modified file 'lib/lp/code/model/branchcollection.py'
--- lib/lp/code/model/branchcollection.py	2011-03-07 12:43:40 +0000
+++ lib/lp/code/model/branchcollection.py	2011-03-17 03:13:32 +0000
@@ -40,6 +40,7 @@
     IBugTaskSet,
     BugTaskSearchParams,
     )
+from lp.bugs.interfaces.bugtaskfilter import filter_bugtasks_by_context
 from lp.bugs.model.bugbranch import BugBranch
 from lp.bugs.model.bugtask import BugTask
 from lp.code.interfaces.branch import user_has_special_branch_access
@@ -53,10 +54,7 @@
 from lp.code.enums import BranchMergeProposalStatus
 from lp.code.interfaces.branchlookup import IBranchLookup
 from lp.code.interfaces.codehosting import LAUNCHPAD_SERVICES
-from lp.code.model.branch import (
-    Branch,
-    filter_one_task_per_bug,
-    )
+from lp.code.model.branch import Branch
 from lp.code.model.branchmergeproposal import BranchMergeProposal
 from lp.code.model.branchsubscription import BranchSubscription
 from lp.code.model.codereviewcomment import CodeReviewComment
@@ -323,7 +321,7 @@
             # Now filter those down to one bugtask per branch
             for branch, tasks in bugtasks_for_branch.iteritems():
                 linked_bugtasks[branch.id].extend(
-                    filter_one_task_per_bug(branch, tasks))
+                    filter_bugtasks_by_context(branch.target.context, tasks))
 
         return [make_rev_info(
                 rev, merge_proposal_revs, linked_bugtasks)

=== modified file 'lib/lp/registry/interfaces/distroseries.py'
--- lib/lp/registry/interfaces/distroseries.py	2011-03-10 14:05:51 +0000
+++ lib/lp/registry/interfaces/distroseries.py	2011-03-17 03:13:32 +0000
@@ -223,6 +223,7 @@
             Interface, # Really IDistribution, see circular import fix below.
             title=_("Distribution"), required=True,
             description=_("The distribution for which this is a series.")))
+    distributionID = Attribute('The distribution ID.')
     named_version = Attribute('The combined display name and version.')
     parent = Attribute('The structural parent of this series - the distro')
     components = Attribute("The series components.")

=== modified file 'lib/lp/registry/interfaces/productseries.py'
--- lib/lp/registry/interfaces/productseries.py	2011-03-08 15:28:40 +0000
+++ lib/lp/registry/interfaces/productseries.py	2011-03-17 03:13:32 +0000
@@ -137,6 +137,7 @@
         ReferenceChoice(title=_('Project'), required=True,
             vocabulary='Product', schema=Interface), # really IProduct
         exported_as='project')
+    productID = Attribute('The product ID.')
 
     status = exported(
         Choice(