← Back to team overview

launchpad-reviewers team mailing list archive

[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