launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #33167
[Merge] ~vaishnavi-asawale/launchpad:source-upload-webhooks into launchpad:master
Vaishnavi Asawale has proposed merging ~vaishnavi-asawale/launchpad:source-upload-webhooks into launchpad:master.
Commit message:
Add webhooks for source package uploads
In this MP:
1) Add subscriber and handler functions to trigger webhooks when the
source package upload status changes to APPROVED/UNAPPROVED/REJECTED.
2) Add a function setPackageUploadStatus() which is called every time
a package status is set.
3) Add a feature flag to enable/disable the functionality of triggering
webhooks on archives.
4) Add tests to ensure webhooks are triggered/ not triggered based on whether
the package upload status changes.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~vaishnavi-asawale/launchpad/+git/launchpad/+merge/494750
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~vaishnavi-asawale/launchpad:source-upload-webhooks into launchpad:master.
diff --git a/lib/lp/archiveuploader/nascentupload.py b/lib/lp/archiveuploader/nascentupload.py
index 9bee114..2a6eb11 100644
--- a/lib/lp/archiveuploader/nascentupload.py
+++ b/lib/lp/archiveuploader/nascentupload.py
@@ -826,6 +826,7 @@ class NascentUpload:
changes_file_object=changes_file_object,
logger=self.logger,
)
+ self.queue_root.setRejected()
def _createQueueEntry(self):
"""Return a PackageUpload object."""
diff --git a/lib/lp/services/features/flags.py b/lib/lp/services/features/flags.py
index 85a37d0..57f5f2b 100644
--- a/lib/lp/services/features/flags.py
+++ b/lib/lp/services/features/flags.py
@@ -349,6 +349,14 @@ flag_info = sorted(
"",
"",
),
+ (
+ "archive.webhooks.enabled",
+ "boolean",
+ "If true, allow webhooks to be configured on archives",
+ "enabled",
+ "",
+ "",
+ ),
]
)
diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
index d9c6f67..0f3792e 100644
--- a/lib/lp/services/webhooks/interfaces.py
+++ b/lib/lp/services/webhooks/interfaces.py
@@ -68,6 +68,12 @@ WEBHOOK_EVENT_TYPES = {
"snap:build:0.1": "Snap build",
"craft-recipe:build:0.1": "Craft recipe build",
"archive:source-package-upload:0.1": "Source package upload",
+ "archive:source-package-upload:0.1::accepted": "Source package"
+ "upload accepted",
+ "archive:source-package-upload:0.1::rejected": "Source package"
+ "upload rejected",
+ "archive:source-package-upload:0.1::unapproved": "Source package"
+ "upload unapproved",
}
diff --git a/lib/lp/soyuz/configure.zcml b/lib/lp/soyuz/configure.zcml
index 9d38192..668c770 100644
--- a/lib/lp/soyuz/configure.zcml
+++ b/lib/lp/soyuz/configure.zcml
@@ -43,6 +43,13 @@
set_schema="lp.soyuz.interfaces.publishing.IBinaryPackagePublishingHistory"/>
</class>
+ <!-- PackageUploadStatusChange -->
+ <subscriber
+ for="lp.soyuz.interfaces.queue.IPackageUpload
+ lazr.lifecycle.interfaces.IObjectModifiedEvent"
+ handler="lp.soyuz.subscribers.archive.package_status_change_webhook"
+ />
+
<!-- DistroSeriesPackageCache -->
<class
class="lp.soyuz.model.distroseriespackagecache.DistroSeriesPackageCache">
diff --git a/lib/lp/soyuz/interfaces/archive.py b/lib/lp/soyuz/interfaces/archive.py
index 656ea34..68b67d6 100644
--- a/lib/lp/soyuz/interfaces/archive.py
+++ b/lib/lp/soyuz/interfaces/archive.py
@@ -10,6 +10,7 @@ __all__ = [
"ArchiveDependencyError",
"ArchiveDisabled",
"ArchiveNotPrivate",
+ "ARCHIVE_WEBHOOKS_FEATURE_FLAG",
"CannotCopy",
"CannotSwitchPrivacy",
"ComponentNotFound",
@@ -121,6 +122,7 @@ from lp.soyuz.interfaces.buildrecords import IHasBuildRecords
from lp.soyuz.interfaces.component import IComponent
NAMED_AUTH_TOKEN_FEATURE_FLAG = "soyuz.named_auth_token.allow_new"
+ARCHIVE_WEBHOOKS_FEATURE_FLAG = "archive.webhooks.enabled"
@error_status(http.client.BAD_REQUEST)
diff --git a/lib/lp/soyuz/model/archive.py b/lib/lp/soyuz/model/archive.py
index 78c4d54..cf06b21 100644
--- a/lib/lp/soyuz/model/archive.py
+++ b/lib/lp/soyuz/model/archive.py
@@ -232,7 +232,12 @@ class Archive(StormBase, WebhookTargetMixin):
@property
def valid_webhook_event_types(self):
- return ["archive:source-package-upload:0.1"]
+ return [
+ "archive:source-package-upload:0.1",
+ "archive:source-package-upload:0.1::accepted",
+ "archive:source-package-upload:0.1::rejected",
+ "archive:source-package-upload:0.1::unapproved",
+ ]
def _validate_archive_name(self, attr, value):
"""Only allow renaming of COPY archives.
diff --git a/lib/lp/soyuz/model/queue.py b/lib/lp/soyuz/model/queue.py
index 7c7fda2..f2c2f26 100644
--- a/lib/lp/soyuz/model/queue.py
+++ b/lib/lp/soyuz/model/queue.py
@@ -51,6 +51,7 @@ from lp.services.librarian.browser import ProxiedLibraryFileAlias
from lp.services.librarian.model import LibraryFileAlias, LibraryFileContent
from lp.services.mail.signedmessage import strip_pgp_signature
from lp.services.propertycache import cachedproperty, get_property_cache
+from lp.services.webapp.snapshot import notify_modified
from lp.soyuz.enums import PackageUploadCustomFormat, PackageUploadStatus
from lp.soyuz.interfaces.archive import (
ComponentNotFound,
@@ -356,13 +357,22 @@ class PackageUpload(StormBase):
raise NotFoundError(filename)
+ def setPackageUploadStatus(self, status):
+ edited_fields = set()
+ with notify_modified(
+ self, edited_fields, snapshot_names=("status",)
+ ) as previous_obj:
+ self.status = PassthroughStatusValue(status)
+ if self.status != previous_obj.status:
+ edited_fields.add("status")
+
def setUnapproved(self):
"""See `IPackageUpload`."""
if self.status != PackageUploadStatus.NEW:
raise QueueInconsistentStateError(
"Can not set modified queue items to UNAPPROVED."
)
- self.status = PassthroughStatusValue(PackageUploadStatus.UNAPPROVED)
+ self.setPackageUploadStatus(PackageUploadStatus.UNAPPROVED)
def setAccepted(self):
"""See `IPackageUpload`."""
@@ -403,7 +413,7 @@ class PackageUpload(StormBase):
raise QueueInconsistentStateError(info)
# if the previous checks applied and pass we do set the value
- self.status = PassthroughStatusValue(PackageUploadStatus.ACCEPTED)
+ self.setPackageUploadStatus(PackageUploadStatus.ACCEPTED)
def _checkForBinariesinDestinationArchive(self, builds):
"""
@@ -475,7 +485,7 @@ class PackageUpload(StormBase):
"""See `IPackageUpload`."""
if self.status == PackageUploadStatus.DONE:
raise QueueInconsistentStateError("Queue item already done")
- self.status = PassthroughStatusValue(PackageUploadStatus.DONE)
+ self.setPackageUploadStatus(PackageUploadStatus.DONE)
def setRejected(self):
"""See `IPackageUpload`."""
@@ -487,7 +497,7 @@ class PackageUpload(StormBase):
raise QueueInconsistentStateError(
"Unable to reject queue item due to status."
)
- self.status = PassthroughStatusValue(PackageUploadStatus.REJECTED)
+ self.setPackageUploadStatus(PackageUploadStatus.REJECTED)
def _validateBuildsForSource(self, sourcepackagerelease, builds):
"""Check if the sourcepackagerelease generates at least one build.
diff --git a/lib/lp/soyuz/subscribers/archive.py b/lib/lp/soyuz/subscribers/archive.py
new file mode 100644
index 0000000..a14afb1
--- /dev/null
+++ b/lib/lp/soyuz/subscribers/archive.py
@@ -0,0 +1,54 @@
+# Copyright 2025 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Event subscribers for source and binary package uploads"""
+
+from zope.component import getUtility
+
+from lp.services.features import getFeatureFlag
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webhooks.interfaces import IWebhookSet
+from lp.soyuz.enums import PackageUploadStatus
+from lp.soyuz.interfaces.archive import ARCHIVE_WEBHOOKS_FEATURE_FLAG
+
+
+def _trigger_source_package_status_change_webhook(upload, event_type, action):
+ if getFeatureFlag(ARCHIVE_WEBHOOKS_FEATURE_FLAG):
+ payload = {
+ "package_upload": canonical_url(upload, force_local_path=True),
+ "action": action,
+ "status": upload.status.name,
+ "archive": canonical_url(upload.archive, force_local_path=True),
+ }
+
+ # Source package information
+ if upload.sources[0] and upload.sources[0].sourcepackagerelease:
+ payload["package_name"] = str(
+ upload.sources[0].sourcepackagerelease.sourcepackagename
+ )
+ payload["package_version"] = str(
+ upload.sources[0].sourcepackagerelease.version
+ )
+
+ getUtility(IWebhookSet).trigger(upload.archive, event_type, payload)
+
+
+def package_status_change_webhook(upload, event):
+ """Webhook for source and binary package uploads."""
+
+ # For source packages
+ # Instead of checking upload.sources, we check upload.builds, because
+ # there are instances of rejected source package uploads which do not have
+ # any sources
+ if not upload.builds:
+ if "status" in event.edited_fields and (
+ upload.status == PackageUploadStatus.ACCEPTED
+ or upload.status == PackageUploadStatus.REJECTED
+ or upload.status == PackageUploadStatus.UNAPPROVED
+ ):
+ status = upload.status.name.lower()
+ _trigger_source_package_status_change_webhook(
+ upload,
+ f"archive:source-package-upload:0.1::{status}",
+ f"source-package-upload-{status}",
+ )
diff --git a/lib/lp/soyuz/tests/test_archive.py b/lib/lp/soyuz/tests/test_archive.py
index bffac3e..d57c39b 100644
--- a/lib/lp/soyuz/tests/test_archive.py
+++ b/lib/lp/soyuz/tests/test_archive.py
@@ -70,6 +70,7 @@ from lp.services.signing.interfaces.signingkey import IArchiveSigningKeySet
from lp.services.timeout import default_timeout
from lp.services.webapp.authorization import check_permission
from lp.services.webapp.interfaces import OAuthPermission
+from lp.services.webapp.publisher import canonical_url
from lp.services.worlddata.interfaces.country import ICountrySet
from lp.soyuz.adapters.archivedependencies import get_sources_list_for_building
from lp.soyuz.adapters.overrides import BinaryOverride, SourceOverride
@@ -81,8 +82,10 @@ from lp.soyuz.enums import (
ArchiveStatus,
PackageCopyPolicy,
PackagePublishingStatus,
+ PackageUploadStatus,
)
from lp.soyuz.interfaces.archive import (
+ ARCHIVE_WEBHOOKS_FEATURE_FLAG,
NAMED_AUTH_TOKEN_FEATURE_FLAG,
ArchiveDependencyError,
ArchiveDisabled,
@@ -7355,3 +7358,89 @@ class TestArchiveWebhooks(TestCaseWithFactory):
archive.delete(archive.owner)
transaction.commit()
self.assertRaises(LostObjectError, getattr, webhook, "event_types")
+
+
+class TestSourcePackageUploadWebhooks(TestCaseWithFactory):
+ """Tests that source package upload webhooks fire correctly."""
+
+ layer = LaunchpadZopelessLayer
+
+ def test_source_package_upload_status_change_triggers_webhook(self):
+ self.useFixture(
+ FeatureFixture(
+ {
+ ARCHIVE_WEBHOOKS_FEATURE_FLAG: "on",
+ }
+ )
+ )
+ archive = self.factory.makeArchive(name="test-archive")
+ hook = self.factory.makeWebhook(
+ target=archive,
+ delivery_url="http://localhost/test-webhook",
+ event_types=["archive:source-package-upload:0.1"],
+ )
+
+ upload = self.factory.makePackageUpload(
+ archive=archive,
+ status=PackageUploadStatus.NEW,
+ )
+ upload.addSource(
+ self.factory.makeSourcePackageRelease(
+ sourcepackagename=self.factory.makeSourcePackageName(
+ name="mypkg"
+ ),
+ version="1.0.0",
+ )
+ )
+
+ upload_unwrapped = removeSecurityProxy(upload)
+ upload_unwrapped.setAccepted()
+
+ job = hook.deliveries.one()
+ self.assertEqual(job.event_type, "archive:source-package-upload:0.1")
+
+ payload = job.payload
+ self.assertEqual(
+ payload["package_upload"],
+ canonical_url(upload, force_local_path=True),
+ )
+ self.assertEqual(payload["action"], "source-package-upload-accepted")
+ self.assertEqual(payload["status"], "ACCEPTED")
+ self.assertEqual(
+ payload["archive"],
+ canonical_url(archive, force_local_path=True),
+ )
+ self.assertEqual(payload["package_name"], "mypkg")
+ self.assertEqual(payload["package_version"], "1.0.0")
+
+ def test_source_package_upload_no_status_change_does_not_trigger_webhook(
+ self,
+ ):
+ self.useFixture(
+ FeatureFixture(
+ {
+ ARCHIVE_WEBHOOKS_FEATURE_FLAG: "on",
+ }
+ )
+ )
+ archive = self.factory.makeArchive(name="test-archive")
+ hook = self.factory.makeWebhook(
+ target=archive,
+ delivery_url="http://localhost/test-webhook",
+ event_types=["archive:source-package-upload:0.1"],
+ )
+
+ upload = self.factory.makePackageUpload(
+ archive=archive,
+ status=PackageUploadStatus.NEW,
+ )
+ upload.addSource(
+ self.factory.makeSourcePackageRelease(
+ sourcepackagename=self.factory.makeSourcePackageName(
+ name="mypkg"
+ ),
+ version="1.0.0",
+ )
+ )
+
+ self.assertEqual(hook.deliveries.count(), 0)
Follow ups