← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~andrey-fedoseev/launchpad:bug-task-channel into launchpad:master

 

Andrey Fedoseev has proposed merging ~andrey-fedoseev/launchpad:bug-task-channel into launchpad:master.

Commit message:
WIP: Add `channel` field to `BugTask` and `SourcePackage`

This should also make a `SourcePackage` with a channel a valid target for a `BugTask`, and some work has been done in that direction, but it may not be 100% complete.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~andrey-fedoseev/launchpad/+git/launchpad/+merge/434693

Currently, it is confirmed to pass all tests in `lp.{bugs,soyuz,registry}` (other packages hasn't been checked)
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~andrey-fedoseev/launchpad:bug-task-channel into launchpad:master.
diff --git a/lib/lp/bugs/configure.zcml b/lib/lp/bugs/configure.zcml
index 6eaa58c..12bfdd7 100644
--- a/lib/lp/bugs/configure.zcml
+++ b/lib/lp/bugs/configure.zcml
@@ -211,6 +211,8 @@
                     distribution
                     distroseries
                     milestone
+                    _channel
+                    channel
                     _status
                     status
                     status_explanation
diff --git a/lib/lp/bugs/interfaces/bugsummary.py b/lib/lp/bugs/interfaces/bugsummary.py
index 7cb55ef..be83775 100644
--- a/lib/lp/bugs/interfaces/bugsummary.py
+++ b/lib/lp/bugs/interfaces/bugsummary.py
@@ -9,7 +9,7 @@ __all__ = [
 ]
 
 from zope.interface import Interface
-from zope.schema import Bool, Choice, Int, Object, Text
+from zope.schema import Bool, Choice, Int, Object, Text, TextLine
 
 from lp import _
 from lp.bugs.interfaces.bugtask import BugTaskImportance, BugTaskStatusSearch
@@ -51,6 +51,8 @@ class IBugSummary(Interface):
     ociproject_id = Int(readonly=True)
     ociproject = Object(IOCIProject, readonly=True)
 
+    channel = TextLine(readonly=True)
+
     milestone_id = Int(readonly=True)
     milestone = Object(IMilestone, readonly=True)
 
diff --git a/lib/lp/bugs/interfaces/bugtask.py b/lib/lp/bugs/interfaces/bugtask.py
index a8c4e45..d2586f7 100644
--- a/lib/lp/bugs/interfaces/bugtask.py
+++ b/lib/lp/bugs/interfaces/bugtask.py
@@ -475,6 +475,7 @@ class IBugTask(IHasBug, IBugTaskDelete):
         title=_("Series"), required=False, vocabulary="DistroSeries"
     )
     distroseries_id = Attribute("The distroseries ID")
+    channel = TextLine(title=_("Channel"), required=False)
     milestone = exported(
         ReferenceChoice(
             title=_("Milestone"),
diff --git a/lib/lp/bugs/model/bugsummary.py b/lib/lp/bugs/model/bugsummary.py
index 9084e0e..2caabf6 100644
--- a/lib/lp/bugs/model/bugsummary.py
+++ b/lib/lp/bugs/model/bugsummary.py
@@ -9,6 +9,8 @@ __all__ = [
     "get_bugsummary_filter_for_user",
 ]
 
+from typing import Optional
+
 from storm.base import Storm
 from storm.expr import SQL, And, Or, Select
 from storm.properties import Bool, Int, Unicode
@@ -32,9 +34,10 @@ from lp.registry.model.product import Product
 from lp.registry.model.productseries import ProductSeries
 from lp.registry.model.sourcepackagename import SourcePackageName
 from lp.registry.model.teammembership import TeamParticipation
+from lp.services.channels import channel_list_to_string
 from lp.services.database.enumcol import DBEnum
 from lp.services.database.interfaces import IStore
-from lp.services.database.stormexpr import WithMaterialized
+from lp.services.database.stormexpr import ImmutablePgJSON, WithMaterialized
 
 
 @implementer(IBugSummary)
@@ -64,6 +67,8 @@ class BugSummary(Storm):
     ociproject_id = Int(name="ociproject")
     ociproject = Reference(ociproject_id, "OCIProject.id")
 
+    _channel = ImmutablePgJSON(name="channel")
+
     milestone_id = Int(name="milestone")
     milestone = Reference(milestone_id, Milestone.id)
 
@@ -80,6 +85,12 @@ class BugSummary(Storm):
 
     has_patch = Bool()
 
+    @property
+    def channel(self) -> Optional[str]:
+        if self._channel is None:
+            return None
+        return channel_list_to_string(*self._channel)
+
 
 @implementer(IBugSummaryDimension)
 class CombineBugSummaryConstraint:
diff --git a/lib/lp/bugs/model/bugtask.py b/lib/lp/bugs/model/bugtask.py
index c006618..8955720 100644
--- a/lib/lp/bugs/model/bugtask.py
+++ b/lib/lp/bugs/model/bugtask.py
@@ -21,6 +21,7 @@ import re
 from collections import defaultdict
 from itertools import chain, repeat
 from operator import attrgetter, itemgetter
+from typing import Optional
 
 import pytz
 from lazr.lifecycle.event import ObjectDeletedEvent
@@ -98,6 +99,7 @@ from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
 from lp.registry.model.pillar import pillar_sort_key
 from lp.registry.model.sourcepackagename import SourcePackageName
 from lp.services import features
+from lp.services.channels import channel_list_to_string, channel_string_to_list
 from lp.services.database.bulk import create, load, load_related
 from lp.services.database.constants import UTC_NOW
 from lp.services.database.decoratedresultset import DecoratedResultSet
@@ -111,6 +113,7 @@ from lp.services.database.sqlbase import (
     sqlvalues,
 )
 from lp.services.database.stormbase import StormBase
+from lp.services.database.stormexpr import ImmutablePgJSON
 from lp.services.helpers import shortlist
 from lp.services.propertycache import get_property_cache
 from lp.services.searchbuilder import any
@@ -171,6 +174,7 @@ def bug_target_from_key(
     distroseries,
     sourcepackagename,
     ociproject,
+    channel,
 ):
     """Returns the IBugTarget defined by the given DB column values."""
     if ociproject:
@@ -189,7 +193,7 @@ def bug_target_from_key(
             return distribution
     elif distroseries:
         if sourcepackagename:
-            return distroseries.getSourcePackage(sourcepackagename)
+            return distroseries.getSourcePackage(sourcepackagename, channel)
         else:
             return distroseries
     else:
@@ -205,6 +209,7 @@ def bug_target_to_key(target):
         distroseries=None,
         sourcepackagename=None,
         ociproject=None,
+        channel=None,
     )
     if IProduct.providedBy(target):
         values["product"] = target
@@ -220,6 +225,7 @@ def bug_target_to_key(target):
     elif ISourcePackage.providedBy(target):
         values["distroseries"] = target.distroseries
         values["sourcepackagename"] = target.sourcepackagename
+        values["channel"] = target.channel
     elif IOCIProject.providedBy(target):
         # De-normalize the ociproject, including also the ociproject's
         # pillar (distribution or product).
@@ -499,6 +505,8 @@ class BugTask(StormBase):
     distroseries_id = Int(name="distroseries", allow_none=True)
     distroseries = Reference(distroseries_id, "DistroSeries.id")
 
+    _channel = ImmutablePgJSON(name="channel", allow_none=True)
+
     milestone_id = Int(
         name="milestone",
         allow_none=True,
@@ -613,6 +621,19 @@ class BugTask(StormBase):
     )
 
     @property
+    def channel(self) -> Optional[str]:
+        if self._channel is None:
+            return None
+        return channel_list_to_string(*self._channel)
+
+    @channel.setter
+    def channel(self, value: str) -> None:
+        if value is None:
+            self._channel = None
+        else:
+            self._channel = channel_string_to_list(value)
+
+    @property
     def status(self):
         if self._status in DB_INCOMPLETE_BUGTASK_STATUSES:
             return BugTaskStatus.INCOMPLETE
@@ -652,6 +673,7 @@ class BugTask(StormBase):
             self.distroseries,
             self.sourcepackagename,
             self.ociproject,
+            self.channel,
         )
 
     @property
@@ -1863,6 +1885,9 @@ class BugTaskSet:
                 key["distroseries"],
                 key["sourcepackagename"],
                 key["ociproject"],
+                channel_string_to_list(key["channel"])
+                if key["channel"]
+                else None,
                 status,
                 importance,
                 assignee,
@@ -1880,6 +1905,7 @@ class BugTaskSet:
                 BugTask.distroseries,
                 BugTask.sourcepackagename,
                 BugTask.ociproject,
+                BugTask._channel,
                 BugTask._status,
                 BugTask.importance,
                 BugTask.assignee,
diff --git a/lib/lp/bugs/model/bugtaskflat.py b/lib/lp/bugs/model/bugtaskflat.py
index b25a440..b636ecf 100644
--- a/lib/lp/bugs/model/bugtaskflat.py
+++ b/lib/lp/bugs/model/bugtaskflat.py
@@ -1,6 +1,5 @@
 # Copyright 2012-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
-
 from storm.locals import Bool, DateTime, Int, List, Reference, Storm
 
 from lp.app.enums import InformationType
@@ -10,6 +9,7 @@ from lp.bugs.interfaces.bugtask import (
     BugTaskStatusSearch,
 )
 from lp.services.database.enumcol import DBEnum
+from lp.services.database.stormexpr import ImmutablePgJSON
 
 
 class BugTaskFlat(Storm):
@@ -42,6 +42,7 @@ class BugTaskFlat(Storm):
     sourcepackagename = Reference(sourcepackagename_id, "SourcePackageName.id")
     ociproject_id = Int(name="ociproject")
     ociproject = Reference(ociproject_id, "OCIProject.id")
+    channel = ImmutablePgJSON()
     status = DBEnum(enum=(BugTaskStatus, BugTaskStatusSearch))
     importance = DBEnum(enum=BugTaskImportance)
     assignee_id = Int(name="assignee")
diff --git a/lib/lp/bugs/model/tests/test_bugtask.py b/lib/lp/bugs/model/tests/test_bugtask.py
index c3eb248..ecb258f 100644
--- a/lib/lp/bugs/model/tests/test_bugtask.py
+++ b/lib/lp/bugs/model/tests/test_bugtask.py
@@ -3145,6 +3145,7 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distroseries=None,
                 sourcepackagename=None,
                 ociproject=None,
+                channel=None,
             ),
         )
 
@@ -3159,6 +3160,7 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distroseries=None,
                 sourcepackagename=None,
                 ociproject=None,
+                channel=None,
             ),
         )
 
@@ -3173,6 +3175,7 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distroseries=None,
                 sourcepackagename=None,
                 ociproject=None,
+                channel=None,
             ),
         )
 
@@ -3187,6 +3190,7 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distroseries=distroseries,
                 sourcepackagename=None,
                 ociproject=None,
+                channel=None,
             ),
         )
 
@@ -3201,6 +3205,7 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distroseries=None,
                 sourcepackagename=dsp.sourcepackagename,
                 ociproject=None,
+                channel=None,
             ),
         )
 
@@ -3215,6 +3220,22 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distroseries=sp.distroseries,
                 sourcepackagename=sp.sourcepackagename,
                 ociproject=None,
+                channel=None,
+            ),
+        )
+
+    def test_sourcepackage_with_channel(self):
+        sp = self.factory.makeSourcePackage(channel="stable")
+        self.assertTargetKeyWorks(
+            sp,
+            dict(
+                product=None,
+                productseries=None,
+                distribution=None,
+                distroseries=sp.distroseries,
+                sourcepackagename=sp.sourcepackagename,
+                ociproject=None,
+                channel="stable",
             ),
         )
 
@@ -3230,6 +3251,7 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distroseries=None,
                 sourcepackagename=None,
                 ociproject=ociproject,
+                channel=None,
             ),
         )
 
@@ -3245,6 +3267,7 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distroseries=None,
                 sourcepackagename=None,
                 ociproject=ociproject,
+                channel=None,
             ),
         )
 
@@ -3263,6 +3286,7 @@ class TestBugTargetKeys(TestCaseWithFactory):
             None,
             None,
             None,
+            None,
         )
 
 
diff --git a/lib/lp/bugs/scripts/bugsummaryrebuild.py b/lib/lp/bugs/scripts/bugsummaryrebuild.py
index 21003b4..cf9ae8a 100644
--- a/lib/lp/bugs/scripts/bugsummaryrebuild.py
+++ b/lib/lp/bugs/scripts/bugsummaryrebuild.py
@@ -24,6 +24,7 @@ from lp.registry.model.ociproject import OCIProject
 from lp.registry.model.product import Product
 from lp.registry.model.productseries import ProductSeries
 from lp.registry.model.sourcepackagename import SourcePackageName
+from lp.services.channels import channel_string_to_list
 from lp.services.database.bulk import create
 from lp.services.database.interfaces import IStore
 from lp.services.database.stormexpr import Unnest
@@ -109,7 +110,7 @@ def load_target(pid, psid, did, dsid, spnid, ociproject_id):
             (pid, psid, did, dsid, spnid, ociproject_id),
         ),
     )
-    return bug_target_from_key(p, ps, d, ds, spn, ociproject)
+    return bug_target_from_key(p, ps, d, ds, spn, ociproject, None)
 
 
 def format_target(target):
@@ -130,7 +131,15 @@ def format_target(target):
 def _get_bugsummary_constraint_bits(target):
     raw_key = bug_target_to_key(target)
     # Map to ID columns to work around Storm bug #682989.
-    return {"%s_id" % k: v.id if v else None for (k, v) in raw_key.items()}
+    constraint_bits = {}
+    for name, value in raw_key.items():
+        if name == "channel":
+            constraint_bits["_channel"] = (
+                channel_string_to_list(value) if value else None
+            )
+        else:
+            constraint_bits["{}_id".format(name)] = value.id if value else None
+    return constraint_bits
 
 
 def get_bugsummary_constraint(target, cls=RawBugSummary):
@@ -154,10 +163,19 @@ def get_bugtaskflat_constraint(target):
     if IProduct.providedBy(target):
         del raw_key["ociproject"]
     # Map to ID columns to work around Storm bug #682989.
-    return [
-        getattr(BugTaskFlat, "%s_id" % k) == (v.id if v else None)
-        for (k, v) in raw_key.items()
-    ]
+    constraint_bits = []
+    for name, value in raw_key.items():
+        if name == "channel":
+            constraint_bits.append(
+                getattr(BugTaskFlat, name)
+                == (channel_string_to_list(value) if value else None)
+            )
+        else:
+            constraint_bits.append(
+                getattr(BugTaskFlat, "{}_id".format(name))
+                == (value.id if value else None)
+            )
+    return constraint_bits
 
 
 def get_bugsummary_rows(target):
@@ -167,6 +185,7 @@ def get_bugsummary_rows(target):
     with BugSummary which is actually combinedbugsummary, a view over
     bugsummary and bugsummaryjournal.
     """
+    constraint = get_bugsummary_constraint(target)
     return IStore(RawBugSummary).find(
         (
             RawBugSummary.status,
@@ -178,7 +197,7 @@ def get_bugsummary_rows(target):
             RawBugSummary.access_policy_id,
             RawBugSummary.count,
         ),
-        *get_bugsummary_constraint(target),
+        *constraint,
     )
 
 
@@ -191,7 +210,7 @@ def get_bugsummaryjournal_rows(target):
 
 
 def calculate_bugsummary_changes(old, new):
-    """Calculate the changes between between the new and old dicts.
+    """Calculate the changes between the new and old dicts.
 
     Takes {key: int} dicts, returns items from the new dict that differ
     from the old one.
@@ -219,18 +238,14 @@ def calculate_bugsummary_changes(old, new):
 def apply_bugsummary_changes(target, added, updated, removed):
     """Apply a set of BugSummary changes to the DB."""
     bits = _get_bugsummary_constraint_bits(target)
-    target_key = tuple(
-        map(
-            bits.get,
-            (
-                "product_id",
-                "productseries_id",
-                "distribution_id",
-                "distroseries_id",
-                "sourcepackagename_id",
-                "ociproject_id",
-            ),
-        )
+    target_key = (
+        bits["product_id"],
+        bits["productseries_id"],
+        bits["distribution_id"],
+        bits["distroseries_id"],
+        bits["sourcepackagename_id"],
+        bits["ociproject_id"],
+        bits["_channel"],
     )
     target_cols = (
         RawBugSummary.product_id,
@@ -239,6 +254,7 @@ def apply_bugsummary_changes(target, added, updated, removed):
         RawBugSummary.distroseries_id,
         RawBugSummary.sourcepackagename_id,
         RawBugSummary.ociproject_id,
+        RawBugSummary._channel,
     )
     key_cols = (
         RawBugSummary.status,
diff --git a/lib/lp/bugs/scripts/bugtasktargetnamecaches.py b/lib/lp/bugs/scripts/bugtasktargetnamecaches.py
index 70a82d4..dcbf424 100644
--- a/lib/lp/bugs/scripts/bugtasktargetnamecaches.py
+++ b/lib/lp/bugs/scripts/bugtasktargetnamecaches.py
@@ -103,10 +103,10 @@ class BugTaskTargetNameCachesTunableLoop:
             self.offset += 1
             # Resolve the IDs to objects, and get the actual IBugTarget.
             # If the ID is None, don't even try to get an object.
-            target_objects = (
+            target_objects = [
                 (store.get(cls, id) if id is not None else None)
                 for cls, id in zip(target_classes, target_bits)
-            )
+            ] + [None]
             target = bug_target_from_key(*target_objects)
             new_name = target.bugtargetdisplayname
             cached_names.discard(new_name)
diff --git a/lib/lp/registry/interfaces/distroseries.py b/lib/lp/registry/interfaces/distroseries.py
index 03e6d68..5a718c2 100644
--- a/lib/lp/registry/interfaces/distroseries.py
+++ b/lib/lp/registry/interfaces/distroseries.py
@@ -667,7 +667,7 @@ class IDistroSeriesPublic(
     @operation_returns_entry(ISourcePackage)
     @export_read_operation()
     @operation_for_version("beta")
-    def getSourcePackage(name):
+    def getSourcePackage(name, channel=None):
         """Return a source package in this distro series by name.
 
         The name given may be a string or an ISourcePackageName-providing
diff --git a/lib/lp/registry/interfaces/sourcepackage.py b/lib/lp/registry/interfaces/sourcepackage.py
index d8f776e..a4505c0 100644
--- a/lib/lp/registry/interfaces/sourcepackage.py
+++ b/lib/lp/registry/interfaces/sourcepackage.py
@@ -130,6 +130,15 @@ class ISourcePackagePublic(
 
     sourcepackagename = Attribute("SourcePackageName")
 
+    channel = exported(
+        TextLine(
+            title=_("Channel"),
+            required=False,
+            readonly=True,
+            description=_("The channel for this source package."),
+        ),
+    )
+
     # This is really a reference to an IProductSeries.
     productseries = exported(
         ReferenceChoice(
@@ -362,11 +371,12 @@ class ISourcePackage(ISourcePackagePublic, ISourcePackageEdit):
 class ISourcePackageFactory(Interface):
     """A creator of source packages."""
 
-    def new(sourcepackagename, distroseries):
+    def new(sourcepackagename, distroseries, channel=None):
         """Create a new `ISourcePackage`.
 
         :param sourcepackagename: An `ISourcePackageName`.
         :param distroseries: An `IDistroSeries`.
+        :param channel: A channel name or None.
         :return: `ISourcePackage`.
         """
 
diff --git a/lib/lp/registry/model/distroseries.py b/lib/lp/registry/model/distroseries.py
index 7844249..a128ed5 100644
--- a/lib/lp/registry/model/distroseries.py
+++ b/lib/lp/registry/model/distroseries.py
@@ -13,6 +13,7 @@ __all__ = [
 import collections
 from io import BytesIO
 from operator import itemgetter
+from typing import Optional
 
 import apt_pkg
 from lazr.delegates import delegate_to
@@ -1036,7 +1037,7 @@ class DistroSeries(
         self.messagecount = messagecount
         ztm.commit()
 
-    def getSourcePackage(self, name):
+    def getSourcePackage(self, name, channel: Optional[str] = None):
         """See `IDistroSeries`."""
         if not ISourcePackageName.providedBy(name):
             try:
@@ -1044,7 +1045,7 @@ class DistroSeries(
             except SQLObjectNotFound:
                 return None
         return getUtility(ISourcePackageFactory).new(
-            sourcepackagename=name, distroseries=self
+            sourcepackagename=name, distroseries=self, channel=channel
         )
 
     def getBinaryPackage(self, name):
diff --git a/lib/lp/registry/model/sourcepackage.py b/lib/lp/registry/model/sourcepackage.py
index 8a0280b..5aff937 100644
--- a/lib/lp/registry/model/sourcepackage.py
+++ b/lib/lp/registry/model/sourcepackage.py
@@ -8,7 +8,9 @@ __all__ = [
     "SourcePackageQuestionTargetMixin",
 ]
 
+import json
 from operator import attrgetter, itemgetter
+from typing import Optional
 
 from storm.locals import And, Desc, Join, Store
 from zope.component import getUtility
@@ -42,6 +44,7 @@ from lp.registry.interfaces.sourcepackage import (
 from lp.registry.model.hasdrivers import HasDriversMixin
 from lp.registry.model.packaging import Packaging
 from lp.registry.model.suitesourcepackage import SuiteSourcePackage
+from lp.services.channels import channel_string_to_list
 from lp.services.database.decoratedresultset import DecoratedResultSet
 from lp.services.database.interfaces import IStore
 from lp.services.database.sqlbase import flush_database_updates, sqlvalues
@@ -204,7 +207,9 @@ class SourcePackage(
     to the relevant database objects.
     """
 
-    def __init__(self, sourcepackagename, distroseries):
+    def __init__(
+        self, sourcepackagename, distroseries, channel: Optional[str] = None
+    ):
         # We store the ID of the sourcepackagename and distroseries
         # simply because Storm can break when accessing them
         # with implicit flush is blocked (like in a permission check when
@@ -213,11 +218,14 @@ class SourcePackage(
         self.sourcepackagename = sourcepackagename
         self.distroseries = distroseries
         self.distroseriesID = distroseries.id
+        self.channel = channel
 
     @classmethod
-    def new(cls, sourcepackagename, distroseries):
+    def new(
+        cls, sourcepackagename, distroseries, channel: Optional[str] = None
+    ):
         """See `ISourcePackageFactory`."""
-        return cls(sourcepackagename, distroseries)
+        return cls(sourcepackagename, distroseries, channel=channel)
 
     def __repr__(self):
         return "<%s %r %r %r>" % (
@@ -242,6 +250,12 @@ class SourcePackage(
                 == self.sourcepackagename,
                 SourcePackagePublishingHistory.distroseries
                 == self.distroseries,
+                SourcePackagePublishingHistory._channel
+                == (
+                    None
+                    if self.channel is None
+                    else channel_string_to_list(self.channel)
+                ),
                 SourcePackagePublishingHistory.archiveID.is_in(
                     self.distribution.all_distro_archive_ids
                 ),
@@ -296,24 +310,27 @@ class SourcePackage(
         )
 
     @property
+    def series_name(self):
+        series_name = self.distroseries.fullseriesname
+        if self.channel is not None:
+            series_name = "%s, %s" % (series_name, self.channel)
+        return series_name
+
+    @property
     def display_name(self):
-        return "%s in %s %s" % (
-            self.sourcepackagename.name,
-            self.distribution.displayname,
-            self.distroseries.displayname,
-        )
+        return "%s in %s" % (self.sourcepackagename.name, self.series_name)
 
     displayname = display_name
 
     @property
     def bugtargetdisplayname(self):
         """See IBugTarget."""
-        return "%s (%s)" % (self.name, self.distroseries.fullseriesname)
+        return "%s (%s)" % (self.name, self.series_name)
 
     @property
     def bugtargetname(self):
         """See `IBugTarget`."""
-        return "%s (%s)" % (self.name, self.distroseries.fullseriesname)
+        return "%s (%s)" % (self.name, self.series_name)
 
     @property
     def bugtarget_parent(self):
@@ -551,6 +568,12 @@ class SourcePackage(
         return And(
             BugSummary.distroseries == self.distroseries,
             BugSummary.sourcepackagename == self.sourcepackagename,
+            BugSummary._channel
+            == (
+                None
+                if self.channel is None
+                else channel_string_to_list(self.channel)
+            ),
         )
 
     def setPackaging(self, productseries, owner):
@@ -616,7 +639,11 @@ class SourcePackage(
 
     def __hash__(self):
         """See `ISourcePackage`."""
-        return hash(self.distroseriesID) ^ hash(self.sourcepackagenameID)
+        return (
+            hash(self.distroseriesID)
+            ^ hash(self.sourcepackagenameID)
+            ^ hash(self.channel)
+        )
 
     def __eq__(self, other):
         """See `ISourcePackage`."""
@@ -624,6 +651,7 @@ class SourcePackage(
             (ISourcePackage.providedBy(other))
             and (self.distroseries.id == other.distroseries.id)
             and (self.sourcepackagename.id == other.sourcepackagename.id)
+            and (self.channel == other.channel)
         )
 
     def __ne__(self, other):
@@ -672,6 +700,16 @@ class SourcePackage(
             )
         ]
 
+        if self.channel is None:
+            condition_clauses.append(
+                "SourcePackagePublishingHistory.channel IS NULL"
+            )
+        else:
+            condition_clauses.append(
+                "SourcePackagePublishingHistory.channel = '%s'::jsonb"
+                % json.dumps(channel_string_to_list(self.channel))
+            )
+
         # We re-use the optional-parameter handling provided by BuildSet
         # here, but pass None for the name argument as we've already
         # matched on exact source package name.
@@ -882,16 +920,26 @@ class SourcePackage(
 
         def weight_function(bugtask):
             if bugtask.sourcepackagename_id == sourcepackagenameID:
-                if bugtask.distroseries_id == seriesID:
+                if (
+                    bugtask.distroseries_id == seriesID
+                    and bugtask.channel == self.channel
+                ):
                     return OrderedBugTask(1, bugtask.id, bugtask)
-                elif bugtask.distribution_id == distributionID:
+                elif bugtask.distroseries_id == seriesID:
                     return OrderedBugTask(2, bugtask.id, bugtask)
+                elif bugtask.distribution_id == distributionID:
+                    return OrderedBugTask(3, bugtask.id, bugtask)
+            elif (
+                bugtask.distroseries_id == seriesID
+                and bugtask.channel == self.channel
+            ):
+                return OrderedBugTask(4, bugtask.id, bugtask)
             elif bugtask.distroseries_id == seriesID:
-                return OrderedBugTask(3, bugtask.id, bugtask)
+                return OrderedBugTask(5, bugtask.id, bugtask)
             elif bugtask.distribution_id == distributionID:
-                return OrderedBugTask(4, bugtask.id, bugtask)
+                return OrderedBugTask(6, 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)
+            return OrderedBugTask(7, bugtask.id, bugtask)
 
         return weight_function
diff --git a/lib/lp/registry/stories/webservice/xx-source-package.rst b/lib/lp/registry/stories/webservice/xx-source-package.rst
index d09785d..b5c17bc 100644
--- a/lib/lp/registry/stories/webservice/xx-source-package.rst
+++ b/lib/lp/registry/stories/webservice/xx-source-package.rst
@@ -32,6 +32,7 @@ distribution series.
     >>> pprint_entry(evolution)
     bug_reported_acknowledgement: None
     bug_reporting_guidelines: None
+    channel: None
     displayname: 'evolution in My-distro My-series'
     distribution_link: 'http://.../my-distro'
     distroseries_link: 'http://.../my-distro/my-series'
diff --git a/lib/lp/registry/tests/test_distributionsourcepackage.py b/lib/lp/registry/tests/test_distributionsourcepackage.py
index d8dffd9..6262cb4 100644
--- a/lib/lp/registry/tests/test_distributionsourcepackage.py
+++ b/lib/lp/registry/tests/test_distributionsourcepackage.py
@@ -49,7 +49,7 @@ class TestDistributionSourcePackage(TestCaseWithFactory):
             registrant=self.factory.makePerson(),
         )
         naked_distribution = removeSecurityProxy(distribution)
-        self.factory.makeSourcePackage(distroseries=distribution)
+        self.factory.makeDistributionSourcePackage(distribution=distribution)
         dsp = naked_distribution.getSourcePackage(name="pmount")
         self.assertEqual(None, dsp.summary)
 
diff --git a/lib/lp/registry/tests/test_distroseries.py b/lib/lp/registry/tests/test_distroseries.py
index ab3c6bf..168386f 100644
--- a/lib/lp/registry/tests/test_distroseries.py
+++ b/lib/lp/registry/tests/test_distroseries.py
@@ -449,6 +449,12 @@ class TestDistroSeries(TestCaseWithFactory):
             ]
         )
 
+    def test_getSourcePackage_channel(self):
+        distroseries = self.factory.makeDistroSeries()
+        spn = self.factory.makeSourcePackageName()
+        source_package = distroseries.getSourcePackage(spn, channel="stable")
+        self.assertEqual("stable", source_package.channel)
+
 
 class TestDistroSeriesPackaging(TestCaseWithFactory):
 
diff --git a/lib/lp/registry/tests/test_sourcepackage.py b/lib/lp/registry/tests/test_sourcepackage.py
index b839a18..a74f23a 100644
--- a/lib/lp/registry/tests/test_sourcepackage.py
+++ b/lib/lp/registry/tests/test_sourcepackage.py
@@ -506,6 +506,66 @@ class TestSourcePackage(TestCaseWithFactory):
             sourcepackage.personHasDriverRights(distroseries.owner)
         )
 
+    def test_channel(self):
+        source_package = self.factory.makeSourcePackage(channel="stable")
+        self.assertEqual("stable", source_package.channel)
+
+    def test_hash(self):
+        spn = self.factory.makeSourcePackageName()
+        distroseries = self.factory.makeDistroSeries()
+        self.assertEqual(
+            hash(
+                self.factory.makeSourcePackage(
+                    sourcepackagename=spn, distroseries=distroseries
+                )
+            ),
+            hash(
+                self.factory.makeSourcePackage(
+                    sourcepackagename=spn, distroseries=distroseries
+                )
+            ),
+        )
+        self.assertNotEqual(
+            hash(
+                self.factory.makeSourcePackage(
+                    sourcepackagename=spn,
+                    distroseries=distroseries,
+                    channel="stable",
+                )
+            ),
+            hash(
+                self.factory.makeSourcePackage(
+                    sourcepackagename=spn,
+                    distroseries=distroseries,
+                    channel="beta",
+                )
+            ),
+        )
+
+    def test_eq(self):
+        spn = self.factory.makeSourcePackageName()
+        distroseries = self.factory.makeDistroSeries()
+        self.assertEqual(
+            self.factory.makeSourcePackage(
+                sourcepackagename=spn, distroseries=distroseries
+            ),
+            self.factory.makeSourcePackage(
+                sourcepackagename=spn, distroseries=distroseries
+            ),
+        )
+        self.assertNotEqual(
+            self.factory.makeSourcePackage(
+                sourcepackagename=spn,
+                distroseries=distroseries,
+                channel="stable",
+            ),
+            self.factory.makeSourcePackage(
+                sourcepackagename=spn,
+                distroseries=distroseries,
+                channel="beta",
+            ),
+        )
+
 
 class TestSourcePackageWebService(WebServiceTestCase):
     def test_setPackaging(self):
diff --git a/lib/lp/soyuz/tests/test_binarypackagebuild.py b/lib/lp/soyuz/tests/test_binarypackagebuild.py
index ef513d3..939e6c1 100644
--- a/lib/lp/soyuz/tests/test_binarypackagebuild.py
+++ b/lib/lp/soyuz/tests/test_binarypackagebuild.py
@@ -433,13 +433,19 @@ class BaseTestCaseWithThreeBuilds(TestCaseWithFactory):
         """Publish some builds for the test archive."""
         super().setUp()
         self.ds = self.factory.makeDistroSeries()
+        self.builds = self.makeBuilds()
+        self.sources = [
+            build.current_source_publication for build in self.builds
+        ]
+
+    def makeBuilds(self):
         i386_das = self.factory.makeDistroArchSeries(
             distroseries=self.ds, architecturetag="i386"
         )
         hppa_das = self.factory.makeDistroArchSeries(
             distroseries=self.ds, architecturetag="hppa"
         )
-        self.builds = [
+        return [
             self.factory.makeBinaryPackageBuild(
                 archive=self.ds.main_archive, distroarchseries=i386_das
             ),
@@ -449,12 +455,10 @@ class BaseTestCaseWithThreeBuilds(TestCaseWithFactory):
                 pocket=PackagePublishingPocket.PROPOSED,
             ),
             self.factory.makeBinaryPackageBuild(
-                archive=self.ds.main_archive, distroarchseries=hppa_das
+                archive=self.ds.main_archive,
+                distroarchseries=hppa_das,
             ),
         ]
-        self.sources = [
-            build.current_source_publication for build in self.builds
-        ]
 
 
 class TestBuildSet(TestCaseWithFactory):
diff --git a/lib/lp/soyuz/tests/test_hasbuildrecords.py b/lib/lp/soyuz/tests/test_hasbuildrecords.py
index b0b8e8c..07c5c26 100644
--- a/lib/lp/soyuz/tests/test_hasbuildrecords.py
+++ b/lib/lp/soyuz/tests/test_hasbuildrecords.py
@@ -2,7 +2,7 @@
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test implementations of the IHasBuildRecords interface."""
-
+from testscenarios import WithScenarios
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
@@ -14,12 +14,11 @@ from lp.buildmaster.interfaces.buildfarmjob import (
 )
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.interfaces.sourcepackage import SourcePackageType
 from lp.registry.model.sourcepackage import SourcePackage
-from lp.services.database.interfaces import IStore
 from lp.soyuz.enums import ArchivePurpose
 from lp.soyuz.interfaces.binarypackagebuild import IBinaryPackageBuild
 from lp.soyuz.interfaces.buildrecords import IHasBuildRecords
-from lp.soyuz.model.publishing import SourcePackagePublishingHistory
 from lp.soyuz.tests.test_binarypackagebuild import BaseTestCaseWithThreeBuilds
 from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
 from lp.testing import TestCaseWithFactory, person_logged_in
@@ -256,30 +255,64 @@ class TestBuilderHasBuildRecords(TestHasBuildRecordsInterface):
         )
 
 
-class TestSourcePackageHasBuildRecords(TestHasBuildRecordsInterface):
+class TestSourcePackageHasBuildRecords(
+    WithScenarios, TestHasBuildRecordsInterface
+):
     """Test the SourcePackage implementation of IHasBuildRecords."""
 
+    scenarios = [
+        (
+            "channel",
+            {"channel": "stable", "format": SourcePackageType.CI_BUILD},
+        ),
+        ("no_channel", {"channel": None, "format": None}),
+    ]
+
     def setUp(self):
         super().setUp()
         gedit_name = self.builds[0].source_package_release.sourcepackagename
         self.context = SourcePackage(
-            gedit_name, self.builds[0].distro_arch_series.distroseries
+            gedit_name,
+            self.builds[0].distro_arch_series.distroseries,
+            channel=self.channel,
         )
 
-        # Convert the other two builds to be builds of
-        # gedit as well so that the one source package (gedit) will have
-        # three builds.
-        for build in self.builds[1:3]:
-            spr = build.source_package_release
-            removeSecurityProxy(spr).sourcepackagename = gedit_name
-            IStore(SourcePackagePublishingHistory).find(
-                SourcePackagePublishingHistory, sourcepackagerelease=spr
-            ).set(sourcepackagenameID=gedit_name.id)
-
+    def makeBuilds(self):
+        i386_das = self.factory.makeDistroArchSeries(
+            distroseries=self.ds, architecturetag="i386"
+        )
+        hppa_das = self.factory.makeDistroArchSeries(
+            distroseries=self.ds, architecturetag="hppa"
+        )
+        spn = self.factory.makeSourcePackageName()
+        builds = [
+            self.factory.makeBinaryPackageBuild(
+                sourcepackagename=spn,
+                archive=self.ds.main_archive,
+                distroarchseries=i386_das,
+                channel=self.channel,
+                format=self.format,
+            ),
+            self.factory.makeBinaryPackageBuild(
+                sourcepackagename=spn,
+                archive=self.ds.main_archive,
+                distroarchseries=i386_das,
+                channel=self.channel,
+                format=self.format,
+            ),
+            self.factory.makeBinaryPackageBuild(
+                sourcepackagename=spn,
+                archive=self.ds.main_archive,
+                distroarchseries=hppa_das,
+                channel=self.channel,
+                format=self.format,
+            ),
+        ]
         # Set them as successfully built
-        for build in self.builds:
+        for build in builds:
             build.updateStatus(BuildStatus.BUILDING)
             build.updateStatus(BuildStatus.FULLYBUILT)
+        return builds
 
     def test_get_build_records(self):
         # We can fetch builds records from a SourcePackage.
@@ -290,7 +323,7 @@ class TestSourcePackageHasBuildRecords(TestHasBuildRecordsInterface):
         builds = self.context.getBuildRecords(
             pocket=PackagePublishingPocket.RELEASE
         ).count()
-        self.assertEqual(2, builds)
+        self.assertEqual(3, builds)
         builds = self.context.getBuildRecords(
             pocket=PackagePublishingPocket.UPDATES
         ).count()
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 40a959c..b83a946 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -31,6 +31,7 @@ from functools import wraps
 from io import BytesIO
 from itertools import count
 from textwrap import dedent
+from typing import Optional
 
 import pytz
 import six
@@ -2385,6 +2386,7 @@ class LaunchpadObjectFactory(ObjectFactory):
                 self.makeSourcePackagePublishingHistory(
                     distroseries=target.distroseries,
                     sourcepackagename=target.sourcepackagename,
+                    channel=target.channel,
                 )
         if IDistributionSourcePackage.providedBy(target):
             if publish:
@@ -4575,6 +4577,7 @@ class LaunchpadObjectFactory(ObjectFactory):
         distroseries=None,
         publish=False,
         owner=None,
+        channel: Optional[str] = None,
     ):
         """Make an `ISourcePackage`.
 
@@ -4590,7 +4593,9 @@ class LaunchpadObjectFactory(ObjectFactory):
             distroseries = self.makeDistroSeries(owner=owner)
         if publish:
             self.makeSourcePackagePublishingHistory(
-                distroseries=distroseries, sourcepackagename=sourcepackagename
+                distroseries=distroseries,
+                sourcepackagename=sourcepackagename,
+                channel=channel,
             )
             with dbuser("statistician"):
                 DistributionSourcePackageCache(
@@ -4599,7 +4604,9 @@ class LaunchpadObjectFactory(ObjectFactory):
                     archive=distroseries.main_archive,
                     name=sourcepackagename.name,
                 )
-        return distroseries.getSourcePackage(sourcepackagename)
+        return distroseries.getSourcePackage(
+            sourcepackagename, channel=channel
+        )
 
     def getAnySourcePackageUrgency(self):
         return SourcePackageUrgency.MEDIUM
@@ -4892,6 +4899,8 @@ class LaunchpadObjectFactory(ObjectFactory):
         processor=None,
         sourcepackagename=None,
         arch_indep=None,
+        channel=None,
+        format=None,
     ):
         """Create a BinaryPackageBuild.
 
@@ -4950,12 +4959,14 @@ class LaunchpadObjectFactory(ObjectFactory):
                 component=multiverse,
                 distroseries=distroarchseries.distroseries,
                 sourcepackagename=sourcepackagename,
+                format=format,
             )
             self.makeSourcePackagePublishingHistory(
                 distroseries=distroarchseries.distroseries,
                 archive=archive,
                 sourcepackagerelease=source_package_release,
                 pocket=pocket,
+                channel=channel,
             )
         if status is None:
             status = BuildStatus.NEEDSBUILD