launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #32715
[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
-
Re: [Merge] ~enriqueesanchz/launchpad:add-external-package into launchpad:master
From: Enrique Sánchez, 2025-07-16
-
Re: [Merge] ~enriqueesanchz/launchpad:add-external-package into launchpad:master
From: Enrique Sánchez, 2025-07-16
-
Re: [Merge] ~enriqueesanchz/launchpad:add-external-package into launchpad:master
From: Ines Almeida, 2025-07-16
-
Re: [Merge] ~enriqueesanchz/launchpad:add-external-package into launchpad:master
From: Ines Almeida, 2025-07-16
-
Re: [Merge] ~enriqueesanchz/launchpad:add-external-package into launchpad:master
From: Enrique Sánchez, 2025-07-16
-
Re: [Merge] ~enriqueesanchz/launchpad:add-external-package into launchpad:master
From: Enrique Sánchez, 2025-07-16
-
Re: [Merge] ~enriqueesanchz/launchpad:add-external-package into launchpad:master
From: Enrique Sánchez, 2025-07-16
-
Re: [Merge] ~enriqueesanchz/launchpad:add-external-package into launchpad:master
From: Enrique Sánchez, 2025-07-15
-
Re: [Merge] ~enriqueesanchz/launchpad:add-external-package into launchpad:master
From: Ines Almeida, 2025-07-14
-
Re: [Merge] ~enriqueesanchz/launchpad:add-external-package into launchpad:master
From: Ines Almeida, 2025-07-14
-
Re: [Merge] ~enriqueesanchz/launchpad:add-external-package into launchpad:master
From: Enrique Sánchez, 2025-07-11