← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Enrique Sánchez has proposed merging ~enriqueesanchz/launchpad:add-external-package-url into launchpad:master with ~enriqueesanchz/launchpad:add-external-package as a prerequisite.

Commit message:
Add ExternalPackageURL and BugTaskURL classes

We use `+external` for ExternalPackages and `+bugtask` to identify BugTasks inside an ExternalPackage's bug. SourcePackages and DistributionSourcePackages remain as before.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~enriqueesanchz/launchpad/+git/launchpad/+merge/489379
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~enriqueesanchz/launchpad:add-external-package-url into launchpad:master.
diff --git a/lib/lp/bugs/browser/buglisting.py b/lib/lp/bugs/browser/buglisting.py
index 641e350..764c473 100644
--- a/lib/lp/bugs/browser/buglisting.py
+++ b/lib/lp/bugs/browser/buglisting.py
@@ -93,6 +93,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.ociproject import IOCIProject
 from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.product import IProduct
@@ -1149,6 +1150,7 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
         distribution_context = self._distributionContext()
         distroseries_context = self._distroSeriesContext()
         distrosourcepackage_context = self._distroSourcePackageContext()
+        externalpackage_context = self._externalPackageContext()
         sourcepackage_context = self._sourcePackageContext()
         ociproject_context = self._ociprojectContext()
 
@@ -1157,6 +1159,7 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
             or productseries_context
             or distrosourcepackage_context
             or sourcepackage_context
+            or externalpackage_context
         ):
             return ["id", "summary", "importance", "status", "heat"]
         elif distribution_context or distroseries_context:
@@ -1764,6 +1767,13 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
         """
         return IDistributionSourcePackage(self.context, None)
 
+    def _externalPackageContext(self):
+        """Is this page being viewed in an external package context?
+
+        Return the IExternalPackage if yes, otherwise return None.
+        """
+        return IExternalPackage(self.context, None)
+
     def _ociprojectContext(self):
         """Is this page being viewed in an OCI project context?
 
diff --git a/lib/lp/bugs/browser/bugtask.py b/lib/lp/bugs/browser/bugtask.py
index 9ec5f1a..68d90f1 100644
--- a/lib/lp/bugs/browser/bugtask.py
+++ b/lib/lp/bugs/browser/bugtask.py
@@ -19,6 +19,7 @@ __all__ = [
     "BugTaskTableRowView",
     "BugTaskTextView",
     "BugTaskView",
+    "BugTaskURL",
     "can_add_package_task_to_bug",
     "can_add_project_task_to_bug",
     "get_comments_for_bugtask",
@@ -153,7 +154,7 @@ from lp.services.webapp.authorization import (
 )
 from lp.services.webapp.breadcrumb import Breadcrumb
 from lp.services.webapp.escaping import html_escape, structured
-from lp.services.webapp.interfaces import ILaunchBag
+from lp.services.webapp.interfaces import ICanonicalUrlData, ILaunchBag
 
 vocabulary_registry = getVocabularyRegistry()
 
@@ -287,6 +288,27 @@ def get_visible_comments(comments, user=None):
     return visible_comments
 
 
+@implementer(ICanonicalUrlData)
+class BugTaskURL:
+    """External package URL creation rules."""
+
+    rootsite = "bugs"
+
+    def __init__(self, context):
+        self.context = context
+
+    @property
+    def inside(self):
+        return self.context.target
+
+    @property
+    def path(self):
+        if IExternalPackage.providedBy(self.context.target):
+            return f"+bug/{self.context.bug.id}/+bugtask/{self.context.id}"
+
+        return "+bug/%s" % self.context.bug.id
+
+
 class BugTargetTraversalMixin:
     """Mix-in in class that provides .../+bug/NNN traversal."""
 
@@ -320,29 +342,24 @@ class BugTargetTraversalMixin:
         # anonymous user is presented with a login screen at the correct URL,
         # rather than making it look as though this task was "not found",
         # because it was filtered out by privacy-aware code.
-
-        externalpackage_bugtask = None
+        is_external_package = IExternalPackage.providedBy(context)
 
         for bugtask in bug.bugtasks:
-            if bugtask.target == context:
-                # Security proxy this object on the way out.
-                return getUtility(IBugTaskSet).get(bugtask.id)
-
+            target = bugtask.target
             if (
-                externalpackage_bugtask is None
-                and IExternalPackage.providedBy(bugtask.target)
+                target == context
+                or is_external_package
+                and IExternalPackage.providedBy(target)
+                and target.sourcepackagename == context.sourcepackagename
             ):
-                # enriqueesanchz 2025-07-15 TODO: set +external urls for
-                # ExternalPackages. Currently we select the first
-                # ExternalPackage that appears if we don't have other match
-                externalpackage_bugtask = getUtility(IBugTaskSet).get(
-                    bugtask.id
-                )
+                # for ExternalPackage we select the first package whose name is
+                # equal to the one from the context and later we will target
+                # other bugtask if required
 
-        # If we've come this far, there's no task for the requested context.
-        if externalpackage_bugtask:
-            return externalpackage_bugtask
+                # Security proxy this object on the way out.
+                return getUtility(IBugTaskSet).get(bugtask.id)
 
+        # If we've come this far, there's no task for the requested context.
         # If we are attempting to navigate past the non-existent bugtask,
         # we raise NotFound error. eg +delete or +edit etc.
         # Otherwise we are simply navigating to a non-existent task and so we
@@ -365,6 +382,18 @@ class BugTaskNavigation(Navigation):
 
     usedfor = IBugTask
 
+    @stepthrough("+bugtask")
+    def traverse_bugtask(self, id):
+        bugtask = getUtility(IBugTaskSet).get(int(id))
+        # Jumping to another bug is not allowed
+        if bugtask.bug.id != self.context.bug.id:
+            return
+        # Jumping to another sourcepackagename is not allowed
+        if bugtask.sourcepackagename != self.context.sourcepackagename:
+            return
+
+        return bugtask
+
     @stepthrough("attachments")
     def traverse_attachments(self, name):
         """traverse to an attachment by id."""
diff --git a/lib/lp/bugs/browser/configure.zcml b/lib/lp/bugs/browser/configure.zcml
index 3b1c7d8..c585354 100644
--- a/lib/lp/bugs/browser/configure.zcml
+++ b/lib/lp/bugs/browser/configure.zcml
@@ -458,9 +458,7 @@
             name="+index"/>
         <lp:url
             for="lp.bugs.interfaces.bugtask.IBugTask"
-            path_expression="string:+bug/${bug/id}"
-            attribute_to_parent="target"
-            rootsite="bugs"/>
+            urldata="lp.bugs.browser.bugtask.BugTaskURL"/>
         <lp:navigation
             module="lp.bugs.browser.bugtask"
             classes="BugTaskNavigation"/>
diff --git a/lib/lp/bugs/browser/tests/test_buglisting.py b/lib/lp/bugs/browser/tests/test_buglisting.py
index f11f586..8f0d5fb 100644
--- a/lib/lp/bugs/browser/tests/test_buglisting.py
+++ b/lib/lp/bugs/browser/tests/test_buglisting.py
@@ -33,6 +33,10 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
         distro = self.factory.makeDistribution("test-distro")
         return self.factory.makeDistributionSourcePackage("test-dsp", distro)
 
+    def _makeExternalPackage(self):
+        distro = self.factory.makeDistribution("ep-distro")
+        return self.factory.makeExternalPackage("ep", distribution=distro)
+
     def test_distributionsourcepackage_unknown_bugtracker_message(self):
         # A DistributionSourcePackage whose Distro does not use
         # Launchpad for bug tracking should explain that.
@@ -50,6 +54,23 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
             extract_text(top_portlet[0]),
         )
 
+    def test_externalpackage_unknown_bugtracker_message(self):
+        ep = self._makeExternalPackage()
+        url = canonical_url(ep, rootsite="bugs")
+        browser = self.getUserBrowser(url)
+        top_portlet = find_tags_by_class(browser.contents, "top-portlet")
+        self.assertTrue(
+            len(top_portlet) > 0, "Tag with class=top-portlet not found"
+        )
+        # An external package from url will use unknown type
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            """
+            ep - Unknown in Ep-distro
+            does not use Launchpad for bug tracking.
+            Getting started with bug tracking in Launchpad.""",
+            extract_text(top_portlet[0]),
+        )
+
     def test_distributionsourcepackage_unknown_bugtracker_no_button(self):
         # A DistributionSourcePackage whose Distro does not use
         # Launchpad for bug tracking should not show the "Report a bug"
@@ -64,6 +85,17 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
             "not be shown",
         )
 
+    def test_externalpackage_unknown_bugtracker_no_button(self):
+        ep = self._makeExternalPackage()
+        url = canonical_url(ep, rootsite="bugs")
+        browser = self.getUserBrowser(url)
+        self.assertIs(
+            None,
+            find_tag_by_id(browser.contents, "involvement"),
+            "Involvement portlet with Report-a-bug button should "
+            "not be shown",
+        )
+
     def test_distributionsourcepackage_unknown_bugtracker_no_filters(self):
         # A DistributionSourcePackage whose Distro does not use
         # Launchpad for bug tracking should not show links to "New
@@ -77,6 +109,16 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
             "portlet-bugfilters should not be shown.",
         )
 
+    def test_externalpackage_unknown_bugtracker_no_filters(self):
+        dsp = self._makeExternalPackage()
+        url = canonical_url(dsp, rootsite="bugs")
+        browser = self.getUserBrowser(url)
+        self.assertIs(
+            None,
+            find_tag_by_id(browser.contents, "portlet-bugfilters"),
+            "portlet-bugfilters should not be shown.",
+        )
+
     def test_distributionsourcepackage_unknown_bugtracker_no_tags(self):
         # A DistributionSourcePackage whose Distro does not use
         # Launchpad for bug tracking should not show links to search by
@@ -90,6 +132,19 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
             "portlet-tags should not be shown.",
         )
 
+    def test_externalpackage_unknown_bugtracker_no_tags(self):
+        # A DistributionSourcePackage whose Distro does not use
+        # Launchpad for bug tracking should not show links to search by
+        # bug tags.
+        ep = self._makeExternalPackage()
+        url = canonical_url(ep, rootsite="bugs")
+        browser = self.getUserBrowser(url)
+        self.assertIs(
+            None,
+            find_tag_by_id(browser.contents, "portlet-tags"),
+            "portlet-tags should not be shown.",
+        )
+
     def _makeSourcePackage(self):
         distro = self.factory.makeDistribution("test-distro")
         self.factory.makeDistroSeries(distribution=distro, name="test-series")
diff --git a/lib/lp/bugs/browser/tests/test_bugtask.py b/lib/lp/bugs/browser/tests/test_bugtask.py
index 8d665f0..6a3ed4b 100644
--- a/lib/lp/bugs/browser/tests/test_bugtask.py
+++ b/lib/lp/bugs/browser/tests/test_bugtask.py
@@ -44,6 +44,7 @@ from lp.registry.enums import (
     BugSharingPolicy,
     DistributionDefaultTraversalPolicy,
 )
+from lp.registry.interfaces.externalpackage import ExternalPackageType
 from lp.registry.interfaces.person import IPersonSet, PersonVisibility
 from lp.services.beautifulsoup import BeautifulSoup
 from lp.services.config import config
@@ -848,6 +849,7 @@ class TestBugTasksTableView(TestCaseWithFactory):
         # Distro tasks follow, sorted by package, distribution, then
         # series (by version in the case of distribution series).
         # OCI projects comes after their pillars.
+        # ExternalPackages comes last, ordered by packagetype and channel
         foo = self.factory.makeProduct(displayname="Foo")
         self.factory.makeProductSeries(product=foo, name="2.0")
         self.factory.makeProductSeries(product=foo, name="1.0")
@@ -873,6 +875,25 @@ class TestBugTasksTableView(TestCaseWithFactory):
         foo_ociproject = self.factory.makeOCIProject(pillar=foo)
         barix_ociproject = self.factory.makeOCIProject(pillar=barix)
 
+        foo_ep_snap = self.factory.makeExternalPackage(foo_spn)
+        foo_ep_charm = self.factory.makeExternalPackage(
+            foo_spn, packagetype=ExternalPackageType.CHARM
+        )
+        foo_ep_charm_candidate = self.factory.makeExternalPackage(
+            foo_spn,
+            packagetype=ExternalPackageType.CHARM,
+            channel=("12.1", "candidate"),
+        )
+        bar_ep_snap = self.factory.makeExternalPackage(bar_spn)
+        bar_ep_rock = self.factory.makeExternalPackage(
+            bar_spn, packagetype=ExternalPackageType.ROCK
+        )
+        bar_ep_rock_candidate = self.factory.makeExternalPackage(
+            bar_spn,
+            packagetype=ExternalPackageType.ROCK,
+            channel=("12.1", "candidate"),
+        )
+
         expected_targets = [
             bar,
             bar.getSeries("0.0"),
@@ -884,10 +905,16 @@ class TestBugTasksTableView(TestCaseWithFactory):
             barix.getSourcePackage(bar_spn),
             barix.getSeries("beta").getSourcePackage(bar_spn),
             barix.getSeries("aaa-release").getSourcePackage(bar_spn),
+            bar_ep_snap,
+            bar_ep_rock,
+            bar_ep_rock_candidate,
             fooix.getSourcePackage(bar_spn),
             fooix.getSeries("beta").getSourcePackage(bar_spn),
             barix.getSourcePackage(foo_spn),
             barix.getSeries("alpha").getSourcePackage(foo_spn),
+            foo_ep_snap,
+            foo_ep_charm,
+            foo_ep_charm_candidate,
         ]
 
         bug = self.factory.makeBug(target=expected_targets[0])
diff --git a/lib/lp/bugs/browser/tests/test_bugtask_navigation.py b/lib/lp/bugs/browser/tests/test_bugtask_navigation.py
index 356f05d..7eb1f6b 100644
--- a/lib/lp/bugs/browser/tests/test_bugtask_navigation.py
+++ b/lib/lp/bugs/browser/tests/test_bugtask_navigation.py
@@ -58,3 +58,87 @@ class TestBugTaskTraversal(TestCaseWithFactory):
             "http://api.launchpad.test/1.0/%s/+bug/%d";
             % (bug.default_bugtask.target.name, bug.default_bugtask.bug.id),
         )
+
+    def test_traversal_to_external_package_bugtask(self):
+        # Test that traversal using +bugtask/id works
+        bug = self.factory.makeBug()
+        ep = self.factory.makeExternalPackage()
+        bugtask = self.factory.makeBugTask(bug=bug, target=ep)
+        bugtask_url = canonical_url(bugtask)
+        ep_2 = self.factory.makeExternalPackage()
+        bugtask_2 = self.factory.makeBugTask(bug=bug, target=ep_2)
+        bugtask_url_2 = canonical_url(bugtask_2)
+        self.assertEqual(
+            bugtask_url,
+            "http://bugs.launchpad.test/%s/+external/%s/+bug/%d/+bugtask/%s";
+            % (
+                removeSecurityProxy(bugtask).distribution.name,
+                removeSecurityProxy(bugtask).target.name,
+                removeSecurityProxy(bugtask).bug.id,
+                removeSecurityProxy(bugtask).id,
+            ),
+        )
+        self.assertEqual(
+            bugtask_url_2,
+            "http://bugs.launchpad.test/%s/+external/%s/+bug/%d/+bugtask/%s";
+            % (
+                removeSecurityProxy(bugtask_2).distribution.name,
+                removeSecurityProxy(bugtask_2).target.name,
+                removeSecurityProxy(bugtask_2).bug.id,
+                removeSecurityProxy(bugtask_2).id,
+            ),
+        )
+        obj, _, _ = test_traverse(bugtask_url)
+        obj_2, _, _ = test_traverse(bugtask_url_2)
+        self.assertEqual(bugtask, obj)
+        self.assertEqual(bugtask_2, obj_2)
+        self.assertEqual(ep, obj.target)
+        self.assertEqual(ep_2, obj_2.target)
+
+    def test_traversal_to_default_external_package_bugtask(self):
+        # Test that a traversing to a bug with an external package as default
+        # bugtask redirects to the bug's default bugtask using +bugtask/id.
+        ep = self.factory.makeExternalPackage()
+        bug = self.factory.makeBug(target=ep)
+        bug_url = canonical_url(bug, rootsite="bugs")
+        obj, view, request = test_traverse(bug_url)
+        view()
+        naked_view = removeSecurityProxy(view)
+        self.assertEqual(303, request.response.getStatus())
+        self.assertEqual(
+            naked_view.target,
+            canonical_url(bug.default_bugtask, rootsite="bugs"),
+        )
+        self.assertEqual(
+            removeSecurityProxy(view).target,
+            "http://bugs.launchpad.test/%s/+external/%s/+bug/%d/+bugtask/%s";
+            % (
+                bug.default_bugtask.distribution.name,
+                bug.default_bugtask.target.name,
+                bug.default_bugtask.bug.id,
+                bug.default_bugtask.id,
+            ),
+        )
+
+    def test_traversal_to_default_external_package_bugtask_on_api(self):
+        # Traversing to a bug with an external package as default task
+        # redirects to the +bugtask/id also in the API.
+        ep = self.factory.makeExternalPackage()
+        bug = self.factory.makeBug(target=ep)
+        obj, view, request = test_traverse(
+            "http://api.launchpad.test/1.0/%s/+bug/%d";
+            % (
+                removeSecurityProxy(ep).distribution.name,
+                bug.default_bugtask.bug.id,
+            )
+        )
+        self.assertEqual(
+            removeSecurityProxy(view).target,
+            "http://api.launchpad.test/1.0/%s/+external/%s/+bug/%d/+bugtask/%s";
+            % (
+                bug.default_bugtask.distribution.name,
+                bug.default_bugtask.target.name,
+                bug.default_bugtask.bug.id,
+                bug.default_bugtask.id,
+            ),
+        )
diff --git a/lib/lp/bugs/interfaces/bugtasksearch.py b/lib/lp/bugs/interfaces/bugtasksearch.py
index 83253c6..6feb0a7 100644
--- a/lib/lp/bugs/interfaces/bugtasksearch.py
+++ b/lib/lp/bugs/interfaces/bugtasksearch.py
@@ -38,7 +38,7 @@ from lp.bugs.interfaces.bugtask import (
     IBugTask,
 )
 from lp.services.fields import SearchTag
-from lp.services.searchbuilder import NULL, all, any
+from lp.services.searchbuilder import NULL, all, any, not_equals
 from lp.soyuz.interfaces.component import IComponent
 
 
@@ -155,6 +155,7 @@ class BugTaskSearchParams:
         milestone_tag=None,
         assignee=None,
         sourcepackagename=None,
+        packagetype=None,
         owner=None,
         attachmenttype=None,
         orderby=None,
@@ -200,6 +201,7 @@ class BugTaskSearchParams:
         self.milestone_tag = milestone_tag
         self.assignee = assignee
         self.sourcepackagename = sourcepackagename
+        self.packagetype = packagetype
         self.owner = owner
         self.attachmenttype = attachmenttype
         self.user = user
@@ -307,8 +309,16 @@ class BugTaskSearchParams:
         else:
             # This is a sourcepackage in a distribution.
             self.distribution = sourcepackage.distribution
+        self.packagetype = NULL
         self.sourcepackagename = sourcepackage.sourcepackagename
 
+    def setExternalPackage(self, externalpackage):
+        """Set the externalpackage context on which to filter the search."""
+        self.distribution = externalpackage.distribution
+        # Currently we are only filtering by having any packagetype
+        self.packagetype = not_equals(None)
+        self.sourcepackagename = externalpackage.sourcepackagename
+
     def setOCIProject(self, ociproject):
         """Set the distribution context on which to filter the search."""
         self.ociproject = ociproject
@@ -331,6 +341,7 @@ class BugTaskSearchParams:
             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.product import IProduct
@@ -359,6 +370,8 @@ class BugTaskSearchParams:
             self.setSourcePackage(target)
         elif IDistributionSourcePackage.providedBy(instance):
             self.setSourcePackage(target)
+        elif IExternalPackage.providedBy(instance):
+            self.setExternalPackage(target)
         elif IProjectGroup.providedBy(instance):
             self.setProjectGroup(target)
         elif IOCIProject.providedBy(instance):
diff --git a/lib/lp/bugs/model/bugtaskflat.py b/lib/lp/bugs/model/bugtaskflat.py
index 833fe01..ea2304b 100644
--- a/lib/lp/bugs/model/bugtaskflat.py
+++ b/lib/lp/bugs/model/bugtaskflat.py
@@ -40,6 +40,7 @@ class BugTaskFlat(StormBase):
     distroseries = Reference(distroseries_id, "DistroSeries.id")
     sourcepackagename_id = Int(name="sourcepackagename")
     sourcepackagename = Reference(sourcepackagename_id, "SourcePackageName.id")
+    packagetype = Int(name="packagetype")
     ociproject_id = Int(name="ociproject")
     ociproject = Reference(ociproject_id, "OCIProject.id")
     status = DBEnum(enum=(BugTaskStatus, BugTaskStatusSearch))
diff --git a/lib/lp/bugs/model/bugtasksearch.py b/lib/lp/bugs/model/bugtasksearch.py
index e2a8600..2af1ee7 100644
--- a/lib/lp/bugs/model/bugtasksearch.py
+++ b/lib/lp/bugs/model/bugtasksearch.py
@@ -323,6 +323,7 @@ def _build_query(params):
         BugTaskFlat.productseries: params.productseries,
         BugTaskFlat.assignee: params.assignee,
         BugTaskFlat.sourcepackagename: params.sourcepackagename,
+        BugTaskFlat.packagetype: params.packagetype,
         BugTaskFlat.owner: params.owner,
         BugTaskFlat.date_closed: params.date_closed,
     }
diff --git a/lib/lp/bugs/tests/test_bugtaskflat_triggers.py b/lib/lp/bugs/tests/test_bugtaskflat_triggers.py
index 7ecbfca..bb54d56 100644
--- a/lib/lp/bugs/tests/test_bugtaskflat_triggers.py
+++ b/lib/lp/bugs/tests/test_bugtaskflat_triggers.py
@@ -40,6 +40,7 @@ class BugTaskFlat(NamedTuple):
     distribution: Any
     distroseries: Any
     sourcepackagename: Any
+    packagetype: Any
     status: Any
     importance: Any
     assignee: Any
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index f60b871..ad96af8 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -554,10 +554,6 @@
         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="
@@ -607,6 +603,18 @@
         provides="zope.traversing.interfaces.IPathAdapter"
         for="lp.registry.interfaces.distributionsourcepackage.IDistributionSourcePackage"
         />
+    <lp:url
+        for="lp.registry.interfaces.externalpackage.IExternalPackage"
+        urldata="lp.registry.browser.externalpackage.ExternalPackageURL"
+        />
+    <lp:navigation
+        module="lp.registry.browser.externalpackage"
+        classes="ExternalPackageNavigation"
+        />
+    <lp:menus
+        module="lp.registry.browser.externalpackage"
+        classes="ExternalPackageFacets"
+        />
     <adapter
         name="fmt"
         factory="lp.registry.browser.sourcepackage.SourcePackageFormatterAPI"
diff --git a/lib/lp/registry/browser/distribution.py b/lib/lp/registry/browser/distribution.py
index aac1c14..d8d40c2 100644
--- a/lib/lp/registry/browser/distribution.py
+++ b/lib/lp/registry/browser/distribution.py
@@ -114,6 +114,7 @@ from lp.registry.interfaces.distributionmirror import (
     MirrorContent,
     MirrorSpeed,
 )
+from lp.registry.interfaces.externalpackage import ExternalPackageType
 from lp.registry.interfaces.ociproject import (
     OCI_PROJECT_ALLOW_CREATE,
     IOCIProjectSet,
@@ -183,6 +184,12 @@ class DistributionNavigation(
         else:
             return dsp
 
+    @stepthrough("+external")
+    def traverse_external(self, name):
+        return self.context.getExternalPackage(
+            name, ExternalPackageType.UNKNOWN, None
+        )
+
     @stepthrough("+oci")
     def traverse_oci(self, name):
         oci_project = self.context.getOCIProject(name)
diff --git a/lib/lp/registry/browser/externalpackage.py b/lib/lp/registry/browser/externalpackage.py
new file mode 100644
index 0000000..36842ef
--- /dev/null
+++ b/lib/lp/registry/browser/externalpackage.py
@@ -0,0 +1,92 @@
+# Copyright 2009-2025 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+    "ExternalPackageBreadcrumb",
+    "ExternalPackageNavigation",
+    "ExternalPackageURL",
+    "ExternalPackageFacets",
+]
+
+
+from zope.interface import implementer
+
+from lp.app.interfaces.headings import IHeadingBreadcrumb
+from lp.bugs.browser.bugtask import BugTargetTraversalMixin
+from lp.bugs.browser.structuralsubscription import (
+    StructuralSubscriptionTargetTraversalMixin,
+)
+from lp.code.browser.vcslisting import TargetDefaultVCSNavigationMixin
+from lp.registry.interfaces.externalpackage import IExternalPackage
+from lp.services.webapp import Navigation, StandardLaunchpadFacets, redirection
+from lp.services.webapp.breadcrumb import Breadcrumb
+from lp.services.webapp.interfaces import (
+    ICanonicalUrlData,
+    IMultiFacetedBreadcrumb,
+)
+from lp.services.webapp.menu import Link
+from lp.services.webhooks.browser import WebhookTargetNavigationMixin
+from lp.translations.browser.customlanguagecode import (
+    HasCustomLanguageCodesTraversalMixin,
+)
+
+
+@implementer(ICanonicalUrlData)
+class ExternalPackageURL:
+    """External package URL creation rules."""
+
+    rootsite = None
+
+    def __init__(self, context):
+        self.context = context
+
+    @property
+    def inside(self):
+        return self.context.distribution
+
+    @property
+    def path(self):
+        return "+external/%s" % self.context.name
+
+
+@implementer(IHeadingBreadcrumb, IMultiFacetedBreadcrumb)
+class ExternalPackageBreadcrumb(Breadcrumb):
+    """Builds a breadcrumb for an `IExternalPackage`."""
+
+    rootsite = "bugs"
+
+    @property
+    def text(self):
+        return "%s external package" % self.context.sourcepackagename.name
+
+
+class ExternalPackageFacets(StandardLaunchpadFacets):
+    usedfor = IExternalPackage
+    enable_only = [
+        "bugs",
+    ]
+
+
+class ExternalPackageLinksMixin:
+    def new_bugs(self):
+        base_path = "+bugs"
+        get_data = "?field.status:list=NEW"
+        return Link(base_path + get_data, "New bugs", site="bugs")
+
+
+class ExternalPackageNavigation(
+    Navigation,
+    BugTargetTraversalMixin,
+    HasCustomLanguageCodesTraversalMixin,
+    TargetDefaultVCSNavigationMixin,
+    StructuralSubscriptionTargetTraversalMixin,
+    WebhookTargetNavigationMixin,
+):
+    usedfor = IExternalPackage
+
+    @redirection("+editbugcontact")
+    def redirect_editbugcontact(self):
+        return "+subscribe"
+
+    def traverse(self, name):
+        return None
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index 22e60a9..32147fc 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -622,6 +622,20 @@
                 enable_bugfiling_duplicate_search
                 "/>
     </class>
+    <adapter
+        for="lp.registry.interfaces.externalpackage.IExternalPackage"
+        provides="lp.services.webapp.interfaces.ILaunchpadContainer"
+        factory="lp.services.webapp.publisher.LaunchpadContainer"/>
+    <adapter
+        provides="lp.app.interfaces.launchpad.IServiceUsage"
+        for="lp.registry.interfaces.externalpackage.IExternalPackage"
+        factory="lp.registry.adapters.sourcepackage_to_distribution"
+        permission="zope.Public"/>
+    <adapter
+        for="lp.registry.interfaces.externalpackage.IExternalPackage"
+        provides="lp.services.webapp.interfaces.IBreadcrumb"
+        factory="lp.registry.browser.externalpackage.ExternalPackageBreadcrumb"/>
+
 
     <!-- CommercialSubscription -->
 
diff --git a/lib/lp/registry/model/externalpackage.py b/lib/lp/registry/model/externalpackage.py
index d97390d..f7bfaa8 100644
--- a/lib/lp/registry/model/externalpackage.py
+++ b/lib/lp/registry/model/externalpackage.py
@@ -147,6 +147,23 @@ class ExternalPackage(
         """See `IBugTarget`."""
         return self.distribution
 
+    @property
+    def bug_reporting_guidelines(self):
+        return
+
+    @property
+    def content_templates(self):
+        return
+
+    @property
+    def bug_reported_acknowledgement(self):
+        """See `IBugTarget`."""
+        return self.distribution.bug_reported_acknowledgement
+
     def _getOfficialTagClause(self):
         """See `IBugTarget`."""
         return self.distribution._getOfficialTagClause()
+
+    def _customizeSearchParams(self, search_params):
+        """Customize `search_params` for this distribution source package."""
+        search_params.setExternalPackage(self)

Follow ups