← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~enriqueesanchz/launchpad:add-external-package into launchpad:master

 

Enrique Sánchez has proposed merging ~enriqueesanchz/launchpad:add-external-package into launchpad:master.

Commit message:
Add ExternalPackage model

ExternalPackage is a valid target to report bugs against it.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~enriqueesanchz/launchpad/+git/launchpad/+merge/488673
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~enriqueesanchz/launchpad:add-external-package into launchpad:master.
diff --git a/lib/lp/app/browser/tales.py b/lib/lp/app/browser/tales.py
index 1714f52..da41350 100644
--- a/lib/lp/app/browser/tales.py
+++ b/lib/lp/app/browser/tales.py
@@ -52,6 +52,7 @@ from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
 )
+from lp.registry.interfaces.externalpackage import IExternalPackage
 from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.projectgroup import IProjectGroup
@@ -733,6 +734,9 @@ class ObjectImageDisplayAPI:
             sprite_string = "distribution"
         elif IDistributionSourcePackage.providedBy(context):
             sprite_string = "package-source"
+        elif IExternalPackage.providedBy(context):
+            # TODO: create a new sprite for ExternalPackages?
+            sprite_string = "package-source"
         elif ISprint.providedBy(context):
             sprite_string = "meeting"
         elif IBug.providedBy(context):
diff --git a/lib/lp/app/widgets/launchpadtarget.py b/lib/lp/app/widgets/launchpadtarget.py
index d296589..ff3dae1 100644
--- a/lib/lp/app/widgets/launchpadtarget.py
+++ b/lib/lp/app/widgets/launchpadtarget.py
@@ -28,6 +28,7 @@ from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
 )
+from lp.registry.interfaces.externalpackage import IExternalPackage
 from lp.registry.interfaces.product import IProduct
 from lp.services.features import getFeatureFlag
 from lp.services.webapp.interfaces import (
@@ -218,6 +219,10 @@ class LaunchpadTargetWidget(BrowserWidget, InputWidget):
             self.default_option = "package"
             self.distribution_widget.setRenderedValue(value.distribution)
             self.package_widget.setRenderedValue(value.sourcepackagename)
+        elif IExternalPackage.providedBy(value):
+            self.default_option = "package"
+            self.distribution_widget.setRenderedValue(value.distribution)
+            self.package_widget.setRenderedValue(value.sourcepackagename)
         else:
             raise AssertionError("Not a valid value: %r" % value)
 
diff --git a/lib/lp/app/widgets/tests/test_launchpadtarget.py b/lib/lp/app/widgets/tests/test_launchpadtarget.py
index 40350b8..16f5f3e 100644
--- a/lib/lp/app/widgets/tests/test_launchpadtarget.py
+++ b/lib/lp/app/widgets/tests/test_launchpadtarget.py
@@ -61,6 +61,9 @@ class LaunchpadTargetWidgetTestCase(TestCaseWithFactory):
         self.package = self.factory.makeDSPCache(
             distroseries=distroseries, sourcepackagename="snarf"
         )
+        self.externalpackage = self.factory.makeExternalPackage(
+            distribution=self.distribution, sourcepackagename="snarf"
+        )
         self.project = self.factory.makeProduct("pting")
         field = Reference(__name__="target", schema=Interface, title="target")
         field = field.bind(Thing())
@@ -313,6 +316,21 @@ class LaunchpadTargetWidgetTestCase(TestCaseWithFactory):
             self.widget.package_widget._getCurrentValue(),
         )
 
+    def test_setRenderedValue_externalpackage(self):
+        # Passing an external package will set the widget's render state to
+        # 'externalpackage'.
+        self.widget.setUpSubWidgets()
+        self.widget.setRenderedValue(self.externalpackage)
+        self.assertEqual("package", self.widget.default_option)
+        self.assertEqual(
+            self.distribution,
+            self.widget.distribution_widget._getCurrentValue(),
+        )
+        self.assertEqual(
+            self.externalpackage.sourcepackagename,
+            self.widget.package_widget._getCurrentValue(),
+        )
+
     def test_call(self):
         # The __call__ method setups the widgets and the options.
         markup = self.widget()
diff --git a/lib/lp/bugs/browser/bugtask.py b/lib/lp/bugs/browser/bugtask.py
index 8fcb135..69f7f0c 100644
--- a/lib/lp/bugs/browser/bugtask.py
+++ b/lib/lp/bugs/browser/bugtask.py
@@ -126,6 +126,7 @@ from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
 )
 from lp.registry.interfaces.distroseries import IDistroSeries, IDistroSeriesSet
+from lp.registry.interfaces.externalpackage import IExternalPackage
 from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.product import IProduct
@@ -320,7 +321,12 @@ class BugTargetTraversalMixin:
         # rather than making it look as though this task was "not found",
         # because it was filtered out by privacy-aware code.
         for bugtask in bug.bugtasks:
-            if bugtask.target == context:
+            if bugtask.target == context or IExternalPackage.providedBy(
+                bugtask.target
+            ):
+                # TODO: set +external urls for ExternalPackages
+                # actually we select the first ExternalPackage that appears
+
                 # Security proxy this object on the way out.
                 return getUtility(IBugTaskSet).get(bugtask.id)
 
@@ -1820,6 +1826,16 @@ def bugtask_sort_key(bugtask):
             None,
             None,
         )
+    elif IExternalPackage.providedBy(bugtask.target):
+        key = (
+            bugtask.target.sourcepackagename.name,
+            bugtask.target.distribution.displayname,
+            bugtask.target.packagetype,
+            bugtask.target.channel,
+            None,
+            None,
+            None,
+        )
     elif ISourcePackage.providedBy(bugtask.target):
         key = (
             bugtask.target.sourcepackagename.name,
diff --git a/lib/lp/bugs/browser/configure.zcml b/lib/lp/bugs/browser/configure.zcml
index beeef02..3b1c7d8 100644
--- a/lib/lp/bugs/browser/configure.zcml
+++ b/lib/lp/bugs/browser/configure.zcml
@@ -1180,6 +1180,10 @@
         layer="lp.bugs.publisher.BugsLayer"
         name="+bugs"/>
     <browser:defaultView
+        for="lp.registry.interfaces.externalpackage.IExternalPackage"
+        layer="lp.bugs.publisher.BugsLayer"
+        name="+bugs"/>
+    <browser:defaultView
         for="lp.registry.interfaces.person.IPerson"
         layer="lp.bugs.publisher.BugsLayer"
         name="+bugs"/>
diff --git a/lib/lp/bugs/browser/structuralsubscription.py b/lib/lp/bugs/browser/structuralsubscription.py
index ec278f7..96f2687 100644
--- a/lib/lp/bugs/browser/structuralsubscription.py
+++ b/lib/lp/bugs/browser/structuralsubscription.py
@@ -35,6 +35,7 @@ from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
 )
+from lp.registry.interfaces.externalpackage import IExternalPackage
 from lp.registry.interfaces.milestone import IProjectGroupMilestone
 from lp.registry.interfaces.person import IPerson, IPersonSet
 from lp.services.propertycache import cachedproperty
@@ -284,9 +285,11 @@ class StructuralSubscriptionView(LaunchpadFormView):
     def userIsDriver(self):
         """Has the current user driver permissions?"""
         # We only want to look at this if the target is a
-        # distribution source package, in order to maintain
+        # distribution or external package, in order to maintain
         # compatibility with the obsolete bug contacts feature.
-        if IDistributionSourcePackage.providedBy(self.context):
+        if IDistributionSourcePackage.providedBy(
+            self.context
+        ) or IExternalPackage.providedBy(self.context):
             return check_permission(
                 "launchpad.Driver", self.context.distribution
             )
diff --git a/lib/lp/bugs/browser/tests/test_bugtask.py b/lib/lp/bugs/browser/tests/test_bugtask.py
index 8d665f0..9e23fb2 100644
--- a/lib/lp/bugs/browser/tests/test_bugtask.py
+++ b/lib/lp/bugs/browser/tests/test_bugtask.py
@@ -873,6 +873,7 @@ class TestBugTasksTableView(TestCaseWithFactory):
         foo_ociproject = self.factory.makeOCIProject(pillar=foo)
         barix_ociproject = self.factory.makeOCIProject(pillar=barix)
 
+        # TODO: test when +bugtasks-and-nominations-table implemented
         expected_targets = [
             bar,
             bar.getSeries("0.0"),
diff --git a/lib/lp/bugs/interfaces/bugtask.py b/lib/lp/bugs/interfaces/bugtask.py
index e22d8d0..9d8a36c 100644
--- a/lib/lp/bugs/interfaces/bugtask.py
+++ b/lib/lp/bugs/interfaces/bugtask.py
@@ -478,6 +478,17 @@ class IBugTask(IHasBug, IBugTaskDelete):
         title=_("Package"), required=False, vocabulary="SourcePackageName"
     )
     sourcepackagename_id = Attribute("The sourcepackagename ID")
+
+    packagetype = Int(
+        title=_("Package type"),
+        default=None,
+        readonly=True,
+    )
+
+    channel = Attribute("The package channel")
+
+    metadata = Attribute("Bugtask metadata")
+
     distribution = Choice(
         title=_("Distribution"), required=False, vocabulary="Distribution"
     )
diff --git a/lib/lp/bugs/model/bugtask.py b/lib/lp/bugs/model/bugtask.py
index d6116ce..5e9af48 100644
--- a/lib/lp/bugs/model/bugtask.py
+++ b/lib/lp/bugs/model/bugtask.py
@@ -23,6 +23,7 @@ from itertools import chain, repeat
 from operator import attrgetter, itemgetter
 
 from lazr.lifecycle.event import ObjectDeletedEvent
+from storm.databases.postgres import JSON
 from storm.expr import (
     SQL,
     And,
@@ -78,6 +79,10 @@ from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
 )
 from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.externalpackage import (
+    ExternalPackageType,
+    IExternalPackage,
+)
 from lp.registry.interfaces.milestone import IMilestoneSet
 from lp.registry.interfaces.milestonetag import IProjectGroupMilestoneTag
 from lp.registry.interfaces.ociproject import IOCIProject
@@ -170,6 +175,8 @@ def bug_target_from_key(
     distroseries,
     sourcepackagename,
     ociproject,
+    packagetype,
+    channel,
 ):
     """Returns the IBugTarget defined by the given DB column values."""
     if ociproject:
@@ -182,7 +189,11 @@ def bug_target_from_key(
     elif productseries:
         return productseries
     elif distribution:
-        if sourcepackagename:
+        if sourcepackagename and packagetype:
+            return distribution.getExternalPackage(
+                sourcepackagename, packagetype, removeSecurityProxy(channel)
+            )
+        elif sourcepackagename:
             return distribution.getSourcePackage(sourcepackagename)
         else:
             return distribution
@@ -204,6 +215,8 @@ def bug_target_to_key(target):
         distroseries=None,
         sourcepackagename=None,
         ociproject=None,
+        packagetype=None,
+        channel=None,
     )
     if IProduct.providedBy(target):
         values["product"] = target
@@ -219,6 +232,11 @@ def bug_target_to_key(target):
     elif ISourcePackage.providedBy(target):
         values["distroseries"] = target.distroseries
         values["sourcepackagename"] = target.sourcepackagename
+    elif IExternalPackage.providedBy(target):
+        values["distribution"] = target.distribution
+        values["sourcepackagename"] = target.sourcepackagename
+        values["packagetype"] = target.packagetype
+        values["channel"] = removeSecurityProxy(target).channel
     elif IOCIProject.providedBy(target):
         # De-normalize the ociproject, including also the ociproject's
         # pillar (distribution or product).
@@ -371,6 +389,9 @@ def validate_target(
                 )
             except NotFoundError as e:
                 raise IllegalTarget(e.args[0])
+    elif IExternalPackage.providedBy(target):
+        # TODO: Check with store/soss that package exists
+        pass
 
     legal_types = target.pillar.getAllowedBugInformationTypes()
     new_pillar = target.pillar not in bug.affected_pillars
@@ -422,7 +443,9 @@ def validate_new_target(bug, target, check_source_package=True):
                 "affected package in which the bug has not yet "
                 "been reported." % target.displayname
             )
-    elif IDistributionSourcePackage.providedBy(target):
+    elif IDistributionSourcePackage.providedBy(
+        target
+    ) or IExternalPackage.providedBy(target):
         # Ensure that there isn't already a generic task open on the
         # distribution for this bug, because if there were, that task
         # should be reassigned to the sourcepackage, rather than a new
@@ -493,6 +516,17 @@ class BugTask(StormBase):
     sourcepackagename_id = Int(name="sourcepackagename", allow_none=True)
     sourcepackagename = Reference(sourcepackagename_id, "SourcePackageName.id")
 
+    packagetype = DBEnum(
+        name="packagetype",
+        allow_none=True,
+        enum=ExternalPackageType,
+        default=None,
+    )
+
+    channel = JSON(name="channel", allow_none=True, default=None)
+
+    metadata = JSON(name="metadata", allow_none=True, default=None)
+
     distribution_id = Int(name="distribution", allow_none=True)
     distribution = Reference(distribution_id, "Distribution.id")
 
@@ -627,6 +661,17 @@ class BugTask(StormBase):
         return self._status
 
     @property
+    def display_channel(self):
+        if self.channel is None:
+            return None
+
+        channel_list = [self.channel.get("track"), self.channel.get("risk")]
+        if branch := self.channel.get("branch", "") != "":
+            channel_list.append(branch)
+
+        return "/".join(channel_list)
+
+    @property
     def title(self):
         """See `IBugTask`."""
         return 'Bug #%s in %s: "%s"' % (
@@ -660,6 +705,8 @@ class BugTask(StormBase):
             self.distroseries,
             self.sourcepackagename,
             self.ociproject,
+            self.packagetype,
+            self.channel,
         )
 
     @property
@@ -1883,6 +1930,8 @@ class BugTaskSet:
                 key["distribution"],
                 key["distroseries"],
                 key["sourcepackagename"],
+                key["packagetype"],
+                key["channel"],
                 key["ociproject"],
                 status,
                 importance,
@@ -1900,6 +1949,8 @@ class BugTaskSet:
                 BugTask.distribution,
                 BugTask.distroseries,
                 BugTask.sourcepackagename,
+                BugTask.packagetype,
+                BugTask.channel,
                 BugTask.ociproject,
                 BugTask._status,
                 BugTask.importance,
diff --git a/lib/lp/bugs/model/structuralsubscription.py b/lib/lp/bugs/model/structuralsubscription.py
index 37e8be2..debb06e 100644
--- a/lib/lp/bugs/model/structuralsubscription.py
+++ b/lib/lp/bugs/model/structuralsubscription.py
@@ -58,6 +58,7 @@ from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
 )
 from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.externalpackage import IExternalPackage
 from lp.registry.interfaces.milestone import IMilestone
 from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.person import (
@@ -607,7 +608,12 @@ def get_structural_subscriptions_for_bug(bug, person=None):
     # This is here because of a circular import.
     from lp.registry.model.person import Person
 
-    bugtasks = bug.bugtasks
+    bugtasks = []
+    # TODO: support bug subscriptions
+    for bugtask in bug.bugtasks:
+        if not IExternalPackage.providedBy(bugtask.target):
+            bugtasks.append(bugtask)
+
     if not bugtasks:
         return EmptyResultSet()
     conditions = []
diff --git a/lib/lp/bugs/model/tests/test_bugtask.py b/lib/lp/bugs/model/tests/test_bugtask.py
index 4549f0a..09e704f 100644
--- a/lib/lp/bugs/model/tests/test_bugtask.py
+++ b/lib/lp/bugs/model/tests/test_bugtask.py
@@ -3174,6 +3174,8 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distroseries=None,
                 sourcepackagename=None,
                 ociproject=None,
+                packagetype=None,
+                channel=None,
             ),
         )
 
@@ -3187,6 +3189,8 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distribution=None,
                 distroseries=None,
                 sourcepackagename=None,
+                packagetype=None,
+                channel=None,
                 ociproject=None,
             ),
         )
@@ -3201,6 +3205,8 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distribution=distro,
                 distroseries=None,
                 sourcepackagename=None,
+                packagetype=None,
+                channel=None,
                 ociproject=None,
             ),
         )
@@ -3215,6 +3221,8 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distribution=None,
                 distroseries=distroseries,
                 sourcepackagename=None,
+                packagetype=None,
+                channel=None,
                 ociproject=None,
             ),
         )
@@ -3229,6 +3237,24 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distribution=dsp.distribution,
                 distroseries=None,
                 sourcepackagename=dsp.sourcepackagename,
+                packagetype=None,
+                channel=None,
+                ociproject=None,
+            ),
+        )
+
+    def test_externalpackage(self):
+        externalpackage = self.factory.makeExternalPackage()
+        self.assertTargetKeyWorks(
+            externalpackage,
+            dict(
+                product=None,
+                productseries=None,
+                distribution=externalpackage.distribution,
+                distroseries=None,
+                sourcepackagename=externalpackage.sourcepackagename,
+                packagetype=externalpackage.packagetype,
+                channel=externalpackage.channel,
                 ociproject=None,
             ),
         )
@@ -3243,6 +3269,8 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distribution=None,
                 distroseries=sp.distroseries,
                 sourcepackagename=sp.sourcepackagename,
+                packagetype=None,
+                channel=None,
                 ociproject=None,
             ),
         )
@@ -3258,6 +3286,8 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distribution=pillar,
                 distroseries=None,
                 sourcepackagename=None,
+                packagetype=None,
+                channel=None,
                 ociproject=ociproject,
             ),
         )
@@ -3273,6 +3303,8 @@ class TestBugTargetKeys(TestCaseWithFactory):
                 distribution=None,
                 distroseries=None,
                 sourcepackagename=None,
+                packagetype=None,
+                channel=None,
                 ociproject=ociproject,
             ),
         )
@@ -3292,6 +3324,8 @@ class TestBugTargetKeys(TestCaseWithFactory):
             None,
             None,
             None,
+            None,
+            None,
         )
 
 
@@ -3581,6 +3615,35 @@ class TestValidateTarget(TestCaseWithFactory, ValidateTargetMixin):
             dsp,
         )
 
+    def test_externalpackage_task_is_allowed(self):
+        # An External task can coexist with a task for its Distribution.
+        d = self.factory.makeDistribution()
+        task = self.factory.makeBugTask(target=d)
+        externalpackage = self.factory.makeExternalPackage(distribution=d)
+        validate_target(task.bug, externalpackage)
+
+    def test_different_externalpackage_tasks_are_allowed(self):
+        # An ExternalPackage task can also coexist with a task for another one.
+        externalpackage = self.factory.makeExternalPackage()
+        task = self.factory.makeBugTask(target=externalpackage)
+        externalpackage = self.factory.makeExternalPackage(
+            distribution=externalpackage.distribution
+        )
+        validate_target(task.bug, externalpackage)
+
+    def test_same_externalpackage_task_is_forbidden(self):
+        # But an ExternalPackage task cannot coexist with a task for itself.
+        externalpackage = self.factory.makeExternalPackage()
+        task = self.factory.makeBugTask(target=externalpackage)
+        self.assertRaisesWithContent(
+            IllegalTarget,
+            "A fix for this bug has already been requested for %s"
+            % (externalpackage.displayname),
+            validate_target,
+            task.bug,
+            externalpackage,
+        )
+
     def test_illegal_information_type_disallowed(self):
         # The bug's current information_type must be permitted by the
         # new target.
@@ -3679,6 +3742,34 @@ class TestValidateNewTarget(TestCaseWithFactory, ValidateTargetMixin):
             d,
         )
 
+    def test_externalpackage_task_with_distribution_task_forbidden(self):
+        d = self.factory.makeDistribution()
+        externalpackage = self.factory.makeExternalPackage(distribution=d)
+        task = self.factory.makeBugTask(target=d)
+        self.assertRaisesWithContent(
+            IllegalTarget,
+            "This bug is already open on %s with no package specified. "
+            "You should fill in a package name for the existing bug."
+            % d.displayname,
+            validate_new_target,
+            task.bug,
+            externalpackage,
+        )
+
+    def test_distribution_task_with_externalpackage_task_forbidden(self):
+        d = self.factory.makeDistribution()
+        externalpackage = self.factory.makeExternalPackage(distribution=d)
+        task = self.factory.makeBugTask(target=externalpackage)
+        self.assertRaisesWithContent(
+            IllegalTarget,
+            "This bug is already on %s. Please specify an affected "
+            "package in which the bug has not yet been reported."
+            % d.displayname,
+            validate_new_target,
+            task.bug,
+            d,
+        )
+
 
 class TestWebservice(TestCaseWithFactory):
     """Tests for the webservice."""
@@ -3827,6 +3918,10 @@ class TestBugTaskUserHasBugSupervisorPrivilegesContext(TestCaseWithFactory):
         dsp = self.factory.makeDistributionSourcePackage()
         self.assert_userHasBugSupervisorPrivilegesContext(dsp)
 
+    def test_externalpackage(self):
+        externalpackage = self.factory.makeExternalPackage()
+        self.assert_userHasBugSupervisorPrivilegesContext(externalpackage)
+
     def test_product(self):
         product = self.factory.makeProduct()
         self.assert_userHasBugSupervisorPrivilegesContext(product)
diff --git a/lib/lp/bugs/scripts/bugsummaryrebuild.py b/lib/lp/bugs/scripts/bugsummaryrebuild.py
index 088ce97..baa6d8b 100644
--- a/lib/lp/bugs/scripts/bugsummaryrebuild.py
+++ b/lib/lp/bugs/scripts/bugsummaryrebuild.py
@@ -109,7 +109,8 @@ 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)
+    # TODO: modify when BugSummary for ExternalPackage implemented
+    return bug_target_from_key(p, ps, d, ds, spn, ociproject, None, 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 k, v in raw_key.items():
+        # TODO: implement BugSummary for packagetype and channel
+        if k != "packagetype" and k != "channel":
+            key = "%s_id" % k
+            value = v.id if v else None
+            constraint_bits[key] = value
+
+    return constraint_bits
 
 
 def get_bugsummary_constraint(target, cls=RawBugSummary):
@@ -154,10 +163,15 @@ 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 = []
+    for k, v in raw_key.items():
+        # TODO: implement BugSummary for packagetype and channel
+        if k != "packagetype" and k != "channel":
+            key = "%s_id" % k
+            value = v.id if v else None
+            constraint.append(getattr(BugTaskFlat, key) == value)
+
+    return constraint
 
 
 def get_bugsummary_rows(target):
diff --git a/lib/lp/bugs/scripts/bugtasktargetnamecaches.py b/lib/lp/bugs/scripts/bugtasktargetnamecaches.py
index 70a82d4..a1d408e 100644
--- a/lib/lp/bugs/scripts/bugtasktargetnamecaches.py
+++ b/lib/lp/bugs/scripts/bugtasktargetnamecaches.py
@@ -107,7 +107,10 @@ class BugTaskTargetNameCachesTunableLoop:
                 (store.get(cls, id) if id is not None else None)
                 for cls, id in zip(target_classes, target_bits)
             )
-            target = bug_target_from_key(*target_objects)
+
+            # We don't need packagetype and channel to get items from
+            # target_classes
+            target = bug_target_from_key(*target_objects, None, None)
             new_name = target.bugtargetdisplayname
             cached_names.discard(new_name)
             # If there are any outdated names cached, update them all in
diff --git a/lib/lp/bugs/scripts/soss/__init__.py b/lib/lp/bugs/scripts/soss/__init__.py
deleted file mode 100644
index db91701..0000000
--- a/lib/lp/bugs/scripts/soss/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-#  Copyright 2025 Canonical Ltd.  This software is licensed under the
-#  GNU Affero General Public License version 3 (see the file LICENSE).
-
-from lp.bugs.scripts.soss.models import SOSSRecord  # noqa: F401
diff --git a/lib/lp/bugs/tests/test_structuralsubscription.py b/lib/lp/bugs/tests/test_structuralsubscription.py
index ce1f712..a22e0d6 100644
--- a/lib/lp/bugs/tests/test_structuralsubscription.py
+++ b/lib/lp/bugs/tests/test_structuralsubscription.py
@@ -548,6 +548,22 @@ class TestGetStructuralSubscriptionTargets(TestCaseWithFactory):
             },
         )
 
+    def test_externalpackage_target(self):
+        actor = self.factory.makePerson()
+        login_person(actor)
+        externalpackage = self.factory.makeExternalPackage()
+        product = self.factory.makeProduct()
+        bug = self.factory.makeBug(target=product)
+        bug.addTask(actor, externalpackage)
+        product_bugtask = bug.bugtasks[0]
+        result = get_structural_subscription_targets(bug.bugtasks)
+        self.assertEqual(
+            set(result),
+            {
+                (product_bugtask, product),
+            },
+        )
+
     def test_product_with_project_group(self):
         # get_structural_subscription_targets() will yield both a
         # product and its parent project group if it has one.
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index 970fd02..f60b871 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -554,6 +554,10 @@
         for="lp.registry.interfaces.distributionsourcepackage.IDistributionSourcePackage"
         urldata="lp.registry.browser.distributionsourcepackage.DistributionSourcePackageURL"
         />
+    <lp:url
+        for="lp.registry.interfaces.externalpackage.IExternalPackage"
+        urldata="lp.registry.browser.distributionsourcepackage.DistributionSourcePackageURL"
+        />
     <lp:navigation
         module="lp.registry.browser.distributionsourcepackage"
         classes="
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index ac416ec..22e60a9 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -608,6 +608,21 @@
         provides="lp.services.webapp.interfaces.IBreadcrumb"
         factory="lp.registry.browser.distributionsourcepackage.DistributionSourcePackageBreadcrumb"/>
 
+    <!-- ExternalPackage -->
+    <class
+        class="lp.registry.model.externalpackage.ExternalPackage">
+        <allow
+            interface="lp.registry.interfaces.externalpackage.IExternalPackageView"/>
+        <require
+            permission="launchpad.BugSupervisor"
+            set_attributes="
+                bug_reported_acknowledgement
+                bug_reporting_guidelines
+                content_templates
+                enable_bugfiling_duplicate_search
+                "/>
+    </class>
+
     <!-- CommercialSubscription -->
 
     <class
diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
index ef966c7..336c843 100644
--- a/lib/lp/registry/interfaces/distribution.py
+++ b/lib/lp/registry/interfaces/distribution.py
@@ -878,6 +878,11 @@ class IDistributionView(
         distribution, or None.
         """
 
+    def getExternalPackage(name, packagetype, channel):
+        """Return an ExternalPackage with the given name, packagetype and
+        channel for this distribution.
+        """
+
     def getSourcePackageRelease(sourcepackagerelease):
         """Returns an IDistributionSourcePackageRelease
 
diff --git a/lib/lp/registry/interfaces/externalpackage.py b/lib/lp/registry/interfaces/externalpackage.py
new file mode 100644
index 0000000..f2dccf9
--- /dev/null
+++ b/lib/lp/registry/interfaces/externalpackage.py
@@ -0,0 +1,148 @@
+# Copyright 2009, 2025 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""External package interfaces."""
+
+__all__ = [
+    "IExternalPackage",
+    "ExternalPackageType",
+]
+
+from lazr.enum import DBEnumeratedType, DBItem
+from lazr.restful.declarations import exported, exported_as_webservice_entry
+from lazr.restful.fields import Reference
+from zope.interface import Attribute
+from zope.schema import TextLine
+
+from lp import _
+from lp.app.interfaces.launchpad import IHeadingContext
+from lp.bugs.interfaces.bugtarget import IBugTarget, IHasOfficialBugTags
+from lp.registry.interfaces.distribution import IDistribution
+from lp.registry.interfaces.role import IHasDrivers
+
+
+@exported_as_webservice_entry(as_of="beta")
+class IExternalPackageView(
+    IHeadingContext,
+    IBugTarget,
+    IHasOfficialBugTags,
+    IHasDrivers,
+):
+    """`IExternalPackage` attributes that require launchpad.View."""
+
+    packagetype = Attribute("The package type")
+
+    channel = Attribute("The package channel")
+
+    display_channel = TextLine(title=_("Display channel name"), readonly=True)
+
+    distribution = exported(
+        Reference(IDistribution, title=_("The distribution."))
+    )
+    sourcepackagename = Attribute("The source package name.")
+
+    name = exported(
+        TextLine(title=_("The source package name as text"), readonly=True)
+    )
+    display_name = exported(
+        TextLine(title=_("Display name for this package."), readonly=True)
+    )
+    displayname = Attribute("Display name (deprecated)")
+    title = exported(
+        TextLine(title=_("Title for this package."), readonly=True)
+    )
+
+    drivers = Attribute("The drivers for the distribution.")
+
+    def __eq__(other):
+        """IExternalPackage comparison method.
+
+        Distro sourcepackages compare equal only if their fields compare equal.
+        """
+
+    def __ne__(other):
+        """IExternalPackage comparison method.
+
+        External packages compare not equal if either of their
+        fields compare not equal.
+        """
+
+
+@exported_as_webservice_entry(as_of="beta")
+class IExternalPackage(
+    IExternalPackageView,
+):
+    """Represents an ExternalPackage in a distribution.
+
+    Create IExternalPackage by invoking `IDistribution.getExternalPackage()`.
+    """
+
+
+class ExternalPackageType(DBEnumeratedType):
+    """Bug Task Status
+
+    The various possible states for a bugfix in a specific place.
+    """
+
+    SNAP = DBItem(
+        1,
+        """
+        Snap
+
+        Snap external package
+        """,
+    )
+
+    CHARM = DBItem(
+        2,
+        """
+        Charm
+
+        Charm external package
+        """,
+    )
+
+    ROCK = DBItem(
+        3,
+        """
+        Rock
+
+        Rock external package
+        """,
+    )
+
+    PYTHON = DBItem(
+        4,
+        """
+        Python
+
+        Python external package
+        """,
+    )
+
+    CONDA = DBItem(
+        5,
+        """
+        Conda
+
+        Conda external package
+        """,
+    )
+
+    CARGO = DBItem(
+        6,
+        """
+        Cargo
+
+        Cargo external package
+        """,
+    )
+
+    MAVEN = DBItem(
+        7,
+        """
+        Maven
+
+        Maven external package
+        """,
+    )
diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
index 310381a..646475d 100644
--- a/lib/lp/registry/model/distribution.py
+++ b/lib/lp/registry/model/distribution.py
@@ -145,6 +145,7 @@ from lp.registry.model.distributionsourcepackage import (
 )
 from lp.registry.model.distroseries import DistroSeries
 from lp.registry.model.distroseriesparent import DistroSeriesParent
+from lp.registry.model.externalpackage import ExternalPackage
 from lp.registry.model.hasdrivers import HasDriversMixin
 from lp.registry.model.karma import KarmaContextMixin
 from lp.registry.model.milestone import HasMilestonesMixin, Milestone
@@ -1359,6 +1360,18 @@ class Distribution(
                 return None
         return DistributionSourcePackage(self, sourcepackagename)
 
+    def getExternalPackage(self, name, packagetype, channel):
+        """See `IDistribution`."""
+        if ISourcePackageName.providedBy(name):
+            sourcepackagename = name
+        else:
+            sourcepackagename = getUtility(ISourcePackageNameSet).queryByName(
+                name
+            )
+            if sourcepackagename is None:
+                return None
+        return ExternalPackage(self, sourcepackagename, packagetype, channel)
+
     def getSourcePackageRelease(self, sourcepackagerelease):
         """See `IDistribution`."""
         return DistributionSourcePackageRelease(self, sourcepackagerelease)
diff --git a/lib/lp/registry/model/externalpackage.py b/lib/lp/registry/model/externalpackage.py
new file mode 100644
index 0000000..2058462
--- /dev/null
+++ b/lib/lp/registry/model/externalpackage.py
@@ -0,0 +1,158 @@
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Classes to represent external packages in a distribution."""
+
+__all__ = [
+    "ExternalPackage",
+]
+
+from zope.interface import implementer
+
+from lp.bugs.model.bugtarget import BugTargetBase
+from lp.bugs.model.structuralsubscription import (
+    StructuralSubscriptionTargetMixin,
+)
+from lp.registry.interfaces.externalpackage import IExternalPackage
+from lp.registry.model.hasdrivers import HasDriversMixin
+from lp.services.propertycache import cachedproperty
+
+CHANNEL_FIELDS = ("track", "risk", "branch")
+
+
+class ChannelFieldException(Exception):
+    """Channel fields are strings.
+    Track and Risk are required, Branch is optional.
+    """
+
+
+@implementer(IExternalPackage)
+class ExternalPackage(
+    BugTargetBase,
+    HasDriversMixin,
+    StructuralSubscriptionTargetMixin,
+):
+    """This is a "Magic External Package". It is not a Storm model, but instead
+    it represents a package with a particular name, type and channel in a
+    particular distribution.
+    """
+
+    def __init__(self, distribution, sourcepackagename, packagetype, channel):
+        self.distribution = distribution
+        self.sourcepackagename = sourcepackagename
+        self.packagetype = packagetype
+
+        self.channel = self.validate_channel(channel)
+
+    def __repr__(self) -> str:
+        return f"<{self.__class__.__name__} '{self.display_name}'>"
+
+    def validate_channel(self, channel: dict) -> str:
+        if channel is None:
+            return None
+        if not isinstance(channel, dict):
+            raise ChannelFieldException("Channel should be a dict")
+        if "track" not in channel:
+            raise ChannelFieldException("Track is a required field in channel")
+        if "risk" not in channel:
+            raise ChannelFieldException("Risk is a required field in channel")
+
+        for k, v in channel.items():
+            if k not in CHANNEL_FIELDS:
+                raise ChannelFieldException(
+                    f"{k} is not part of {CHANNEL_FIELDS}"
+                )
+            if not isinstance(v, str):
+                raise ChannelFieldException(
+                    "All channel fields should be a string"
+                )
+        return channel
+
+    @property
+    def name(self):
+        """See `IExternalPackage`."""
+        return self.sourcepackagename.name
+
+    @property
+    def display_channel(self):
+        """See `IExternalPackage`."""
+        if not self.channel:
+            return None
+
+        channel_list = [self.channel.get("track"), self.channel.get("risk")]
+        if (branch := self.channel.get("branch", "")) != "":
+            channel_list.append(branch)
+
+        return "/".join(channel_list)
+
+    @cachedproperty
+    def display_name(self):
+        """See `IExternalPackage`."""
+        if self.channel:
+            return "%s - %s @%s in %s" % (
+                self.sourcepackagename.name,
+                self.packagetype,
+                self.display_channel,
+                self.distribution.display_name,
+            )
+
+        return "%s - %s in %s" % (
+            self.sourcepackagename.name,
+            self.packagetype,
+            self.distribution.display_name,
+        )
+
+    @property
+    def bugtargetdisplayname(self):
+        """See `IExternalPackage`."""
+        return self.display_name
+
+    @property
+    def bugtargetname(self):
+        """See `IExternalPackage`."""
+        return self.display_name
+
+    @property
+    def title(self):
+        """See `IExternalPackage`."""
+        return self.display_name
+
+    def __eq__(self, other):
+        """See `IExternalPackage`."""
+        return (
+            (IExternalPackage.providedBy(other))
+            and (self.distribution.id == other.distribution.id)
+            and (self.sourcepackagename.id == other.sourcepackagename.id)
+            and (self.packagetype == other.packagetype)
+            and (self.channel == other.channel)
+        )
+
+    def __hash__(self):
+        """Return the combined attributes hash."""
+        return hash(
+            (
+                self.distribution,
+                self.sourcepackagename,
+                self.packagetype,
+                self.display_channel,
+            )
+        )
+
+    @property
+    def drivers(self):
+        """See `IHasDrivers`."""
+        return self.distribution.drivers
+
+    @property
+    def official_bug_tags(self):
+        """See `IHasBugs`."""
+        return self.distribution.official_bug_tags
+
+    @property
+    def pillar(self):
+        """See `IBugTarget`."""
+        return self.distribution
+
+    def _getOfficialTagClause(self):
+        """See `IBugTarget`."""
+        return self.distribution._getOfficialTagClause()
diff --git a/lib/lp/registry/tests/test_distribution.py b/lib/lp/registry/tests/test_distribution.py
index c1fafdd..c9fdb6d 100644
--- a/lib/lp/registry/tests/test_distribution.py
+++ b/lib/lp/registry/tests/test_distribution.py
@@ -67,6 +67,7 @@ from lp.registry.interfaces.accesspolicy import (
 )
 from lp.registry.interfaces.distribution import IDistribution, IDistributionSet
 from lp.registry.interfaces.distributionmirror import MirrorContent
+from lp.registry.interfaces.externalpackage import ExternalPackageType
 from lp.registry.interfaces.oopsreferences import IHasOOPSReferences
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.series import SeriesStatus
@@ -379,6 +380,33 @@ class TestDistribution(TestCaseWithFactory):
             distro.getDefaultSpecificationInformationType(),
         )
 
+    def test_getExternalPackage(self):
+        distro = self.factory.makeDistribution()
+        sourcepackagename = self.factory.getOrMakeSourcePackageName(
+            "my-package"
+        )
+        channel = {"track": "22.04", "risk": "candidate", "branch": "staging"}
+        externalpackage = distro.getExternalPackage(
+            name=sourcepackagename,
+            packagetype=ExternalPackageType.ROCK,
+            channel=channel,
+        )
+        self.assertEqual(externalpackage.distribution, distro)
+        self.assertEqual(externalpackage.name, "my-package")
+        self.assertEqual(externalpackage.packagetype, ExternalPackageType.ROCK)
+        self.assertEqual(externalpackage.channel, channel)
+
+        # We can have external packages without channel
+        externalpackage = distro.getExternalPackage(
+            name=sourcepackagename,
+            packagetype=ExternalPackageType.SNAP,
+            channel=None,
+        )
+        self.assertEqual(externalpackage.distribution, distro)
+        self.assertEqual(externalpackage.name, "my-package")
+        self.assertEqual(externalpackage.packagetype, ExternalPackageType.SNAP)
+        self.assertEqual(externalpackage.channel, None)
+
     def test_getOCIProject(self):
         distro = self.factory.makeDistribution()
         first_project = self.factory.makeOCIProject(pillar=distro)
diff --git a/lib/lp/registry/tests/test_externalpackage.py b/lib/lp/registry/tests/test_externalpackage.py
new file mode 100644
index 0000000..30be4a2
--- /dev/null
+++ b/lib/lp/registry/tests/test_externalpackage.py
@@ -0,0 +1,218 @@
+# Copyright 2009-2025 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for ExternalPackage."""
+
+from zope.security.proxy import removeSecurityProxy
+
+from lp.registry.interfaces.externalpackage import ExternalPackageType
+from lp.registry.model.externalpackage import (
+    ChannelFieldException,
+    ExternalPackage,
+)
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestExternalPackage(TestCaseWithFactory):
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+
+        self.sourcepackagename = self.factory.getOrMakeSourcePackageName(
+            "mypackage"
+        )
+        self.channel = {"track": "12.81", "risk": "edge", "branch": "myfix"}
+        self.distribution = self.factory.makeDistribution(name="mydistro")
+
+        self.externalpackage = self.distribution.getExternalPackage(
+            name=self.sourcepackagename,
+            packagetype=ExternalPackageType.SNAP,
+            channel=self.channel,
+        )
+        self.externalpackage_maven = self.distribution.getExternalPackage(
+            name=self.sourcepackagename,
+            packagetype=ExternalPackageType.MAVEN,
+            channel=None,
+        )
+        self.externalpackage_copy = ExternalPackage(
+            self.distribution,
+            sourcepackagename=self.sourcepackagename,
+            packagetype=ExternalPackageType.SNAP,
+            channel=self.channel,
+        )
+
+    def test_repr(self):
+        """Test __repr__ function"""
+        self.assertEqual(
+            "<ExternalPackage 'mypackage - Snap @12.81/edge/myfix in "
+            "Mydistro'>",
+            self.externalpackage.__repr__(),
+        )
+        self.assertEqual(
+            "<ExternalPackage 'mypackage - Maven in Mydistro'>",
+            self.externalpackage_maven.__repr__(),
+        )
+
+    def test_name(self):
+        """Test name property"""
+        self.assertEqual("mypackage", self.externalpackage.name)
+        self.assertEqual("mypackage", self.externalpackage_maven.name)
+
+    def test_display_channel(self):
+        """Test display name property"""
+        self.assertEqual(
+            self.externalpackage.display_channel, "12.81/edge/myfix"
+        )
+        self.assertEqual(self.externalpackage_maven.display_channel, None)
+
+        removeSecurityProxy(self.externalpackage).channel = {
+            "track": "12.81",
+            "risk": "candidate",
+        }
+        self.assertEqual(
+            "12.81/candidate", self.externalpackage.display_channel
+        )
+
+    def test_channel_fields(self):
+        """Test invalid channel fields when creating an ExternalPackage"""
+        self.assertRaises(
+            ChannelFieldException,
+            ExternalPackage,
+            self.distribution,
+            self.sourcepackagename,
+            ExternalPackageType.SNAP,
+            {},
+        )
+        self.assertRaises(
+            ChannelFieldException,
+            ExternalPackage,
+            self.distribution,
+            self.sourcepackagename,
+            ExternalPackageType.CHARM,
+            {"track": 16},
+        )
+        self.assertRaises(
+            ChannelFieldException,
+            ExternalPackage,
+            self.distribution,
+            self.sourcepackagename,
+            ExternalPackageType.CHARM,
+            {"track": "16"},
+        )
+        self.assertRaises(
+            ChannelFieldException,
+            ExternalPackage,
+            self.distribution,
+            self.sourcepackagename,
+            ExternalPackageType.ROCK,
+            {"risk": "beta"},
+        )
+        self.assertRaises(
+            ChannelFieldException,
+            ExternalPackage,
+            self.distribution,
+            self.sourcepackagename,
+            ExternalPackageType.PYTHON,
+            {"track": "16", "risk": "beta", "foo": "bar"},
+        )
+        self.assertRaises(
+            ChannelFieldException,
+            ExternalPackage,
+            self.distribution,
+            self.sourcepackagename,
+            ExternalPackageType.CONDA,
+            1,
+        )
+
+    def test_display_name(self):
+        """Test display_name property"""
+        self.assertEqual(
+            "mypackage - Snap @12.81/edge/myfix in Mydistro",
+            self.externalpackage.display_name,
+        )
+        self.assertEqual(
+            "mypackage - Maven in Mydistro",
+            self.externalpackage_maven.display_name,
+        )
+
+    def test_bugtargetdisplayname(self):
+        """Test bugtargetdisplayname property"""
+        self.assertEqual(
+            "mypackage - Snap @12.81/edge/myfix in Mydistro",
+            self.externalpackage.bugtargetdisplayname,
+        )
+        self.assertEqual(
+            "mypackage - Maven in Mydistro",
+            self.externalpackage_maven.bugtargetdisplayname,
+        )
+
+    def test_bugtargetname(self):
+        """Test bugtargetname property"""
+        self.assertEqual(
+            "mypackage - Snap @12.81/edge/myfix in Mydistro",
+            self.externalpackage.bugtargetname,
+        )
+        self.assertEqual(
+            "mypackage - Maven in Mydistro",
+            self.externalpackage_maven.bugtargetname,
+        )
+
+    def test_title(self):
+        """Test title property"""
+        self.assertEqual(
+            "mypackage - Snap @12.81/edge/myfix package in Mydistro",
+            self.externalpackage.title,
+        )
+        self.assertEqual(
+            "mypackage - Maven package in Mydistro",
+            self.externalpackage_maven.title,
+        )
+
+    def test_compare(self):
+        """Test __eq__ and __neq__"""
+        self.assertEqual(self.externalpackage, self.externalpackage_copy)
+        self.assertNotEqual(self.externalpackage, self.externalpackage_maven)
+
+    def test_hash(self):
+        """Test __hash__"""
+        self.assertEqual(
+            removeSecurityProxy(self.externalpackage).__hash__(),
+            removeSecurityProxy(self.externalpackage_copy).__hash__(),
+        )
+        self.assertNotEqual(
+            removeSecurityProxy(self.externalpackage).__hash__(),
+            removeSecurityProxy(self.externalpackage_maven).__hash__(),
+        )
+
+    def test_pillar(self):
+        """Test pillar property"""
+        self.assertEqual(self.externalpackage.pillar, self.distribution)
+
+    def test_official_bug_tags(self):
+        """Test official_bug_tags property"""
+        self.assertEqual(
+            self.externalpackage.official_bug_tags,
+            self.distribution.official_bug_tags,
+        )
+
+    def test__getOfficialTagClause(self):
+        """Test _getOfficialTagClause"""
+        self.assertEqual(
+            self.distribution._getOfficialTagClause(),
+            self.externalpackage._getOfficialTagClause(),
+        )
+
+    def test_drivers_are_distributions(self):
+        """Drivers property returns the drivers for the distribution."""
+        self.assertNotEqual([], self.distribution.drivers)
+        self.assertEqual(
+            self.externalpackage.drivers, self.distribution.drivers
+        )
+
+    def test_personHasDriverRights(self):
+        """A distribution driver has driver permissions on an
+        externalpackage."""
+        driver = self.distribution.drivers[0]
+        self.assertTrue(self.externalpackage.personHasDriverRights(driver))
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index d23c481..8fcafbb 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -178,6 +178,7 @@ from lp.registry.interfaces.distroseriesdifferencecomment import (
     IDistroSeriesDifferenceCommentSource,
 )
 from lp.registry.interfaces.distroseriesparent import IDistroSeriesParentSet
+from lp.registry.interfaces.externalpackage import ExternalPackageType
 from lp.registry.interfaces.gpg import IGPGKeySet
 from lp.registry.interfaces.mailinglist import (
     IMailingListSet,
@@ -5585,6 +5586,28 @@ class LaunchpadObjectFactory(ObjectFactory):
             )
         return dsp
 
+    def makeExternalPackage(
+        self,
+        sourcepackagename=None,
+        packagetype=None,
+        channel=None,
+        distribution=None,
+    ):
+        if sourcepackagename is None or isinstance(sourcepackagename, str):
+            sourcepackagename = self.getOrMakeSourcePackageName(
+                sourcepackagename
+            )
+        if distribution is None:
+            distribution = self.makeDistribution()
+        if packagetype is None:
+            packagetype = ExternalPackageType.SNAP
+        if channel is None:
+            channel = {"track": "12.1", "risk": "stable", "branch": ""}
+
+        return distribution.getExternalPackage(
+            sourcepackagename, packagetype, channel
+        )
+
     def makeEmailMessage(
         self,
         body=None,

Follow ups