← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~vaishnavi-asawale/launchpad:deb-build-webhook into launchpad:master

 

Vaishnavi Asawale has proposed merging ~vaishnavi-asawale/launchpad:deb-build-webhook into launchpad:master.

Commit message:
WIP: Add Launchpad Webhooks for deb builds

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~vaishnavi-asawale/launchpad/+git/launchpad/+merge/491578
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~vaishnavi-asawale/launchpad:deb-build-webhook into launchpad:master.
diff --git a/lib/lp/code/browser/sourcepackagerecipe.py b/lib/lp/code/browser/sourcepackagerecipe.py
index cd5d311..bae8cae 100644
--- a/lib/lp/code/browser/sourcepackagerecipe.py
+++ b/lib/lp/code/browser/sourcepackagerecipe.py
@@ -7,6 +7,7 @@ __all__ = [
     "SourcePackageRecipeAddView",
     "SourcePackageRecipeContextMenu",
     "SourcePackageRecipeEditView",
+    "SourcePackageRecipeNavigation"
     "SourcePackageRecipeNavigationMenu",
     "SourcePackageRecipeRequestBuildsView",
     "SourcePackageRecipeRequestDailyBuildView",
@@ -78,25 +79,49 @@ from lp.code.interfaces.sourcepackagerecipe import (
     IRecipeBranchSource,
     ISourcePackageRecipe,
     ISourcePackageRecipeSource,
+    SOURCEPACKAGE_RECIPE_WEBHOOKS_FEATURE_FLAG
 )
 from lp.code.vocabularies.sourcepackagerecipe import BuildableDistroSeries
+from lp.code.interfaces.sourcepackagerecipebuild import ISourcePackageRecipeBuildSource
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.fields import PersonChoice
+from lp.services.features import getFeatureFlag
 from lp.services.propertycache import cachedproperty
 from lp.services.webapp import (
     ContextMenu,
     LaunchpadView,
     Link,
+    Navigation,
     NavigationMenu,
     canonical_url,
     enabled_with_permission,
     structured,
 )
 from lp.services.webapp.authorization import check_permission
+from lp.services.webhooks.browser import WebhookTargetNavigationMixin
 from lp.services.webapp.breadcrumb import Breadcrumb, NameBreadcrumb
+from lp.soyuz.browser.build import get_build_by_id_str
 from lp.soyuz.interfaces.archive import ArchiveDisabled
 from lp.soyuz.model.archive import validate_ppa
 
+class SourcePackageRecipeNavigation(WebhookTargetNavigationMixin, Navigation):
+    usedfor = ISourcePackageRecipe
+
+    @stepthrough("+build-request")
+    def traverse_build_request(self, name):
+        try:
+            job_id = int(name)
+        except ValueError:
+            return None
+        return self.context.getBuildRequest(job_id)
+
+    @stepthrough("+build")
+    def traverse_build(self, name):
+        build = get_build_by_id_str(ISourcePackageRecipeBuildSource, name)
+        if build is None or build.recipe != self.context:
+            return None
+        return build
+
 
 class SourcePackageRecipeBreadcrumb(NameBreadcrumb):
     @property
@@ -118,7 +143,7 @@ class SourcePackageRecipeNavigationMenu(NavigationMenu):
 
     facet = "branches"
 
-    links = ("edit", "delete")
+    links = ("edit", "delete", "webhooks")
 
     @enabled_with_permission("launchpad.Edit")
     def edit(self):
@@ -128,6 +153,13 @@ class SourcePackageRecipeNavigationMenu(NavigationMenu):
     def delete(self):
         return Link("+delete", "Delete recipe", icon="trash-icon")
 
+    @enabled_with_permission("launchpad.Edit")
+    def webhooks(self):
+        return Link(
+            "+webhooks", "Manage webhooks", icon="edit",
+            enabled=(
+                bool(getFeatureFlag(SOURCEPACKAGE_RECIPE_WEBHOOKS_FEATURE_FLAG)) and
+                bool(getFeatureFlag("webhooks.new.enabled"))))
 
 class SourcePackageRecipeContextMenu(ContextMenu):
     """Context menu for sourcepackage recipes."""
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index 95a84f9..7bc71b4 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -1307,6 +1307,14 @@
   <subscriber
     for="lp.code.interfaces.sourcepackagerecipe.ISourcePackageRecipe zope.lifecycleevent.interfaces.IObjectModifiedEvent"
     handler="lp.code.model.sourcepackagerecipe.recipe_modified"/>
+  <subscriber
+    for="lp.code.interfaces.sourcepackagerecipebuild.ISourcePackageRecipeBuild
+          lazr.lifecycle.interfaces.IObjectCreatedEvent"
+    handler="lp.code.subscribers.sourcepackagerecipe.sourcepackagerecipe_build_created" />
+<subscriber
+    for="lp.code.interfaces.sourcepackagerecipebuild.ISourcePackageRecipeBuild
+          lazr.lifecycle.interfaces.IObjectModifiedEvent"
+    handler="lp.code.subscribers.sourcepackagerecipe.sourcepackagerecipe_build_modified" />
 
   <!-- LPCIConfiguration -->
   <class class="lp.code.model.lpci.LPCIConfiguration">
diff --git a/lib/lp/code/interfaces/sourcepackagerecipe.py b/lib/lp/code/interfaces/sourcepackagerecipe.py
index e4f7d52..a5c0301 100644
--- a/lib/lp/code/interfaces/sourcepackagerecipe.py
+++ b/lib/lp/code/interfaces/sourcepackagerecipe.py
@@ -12,6 +12,7 @@ __all__ = [
     "MINIMAL_RECIPE_TEXT_BZR",
     "MINIMAL_RECIPE_TEXT_GIT",
     "RecipeBranchType",
+    "SOURCEPACKAGE_RECIPE_WEBHOOKS_FEATURE_FLAG",
 ]
 
 
@@ -44,8 +45,11 @@ from lp.registry.interfaces.distroseries import IDistroSeries
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.role import IHasOwner
 from lp.services.fields import Description, PersonChoice, PublicPersonChoice
+from lp.services.webhooks.interfaces import IWebhookTarget
 from lp.soyuz.interfaces.archive import IArchive
 
+SOURCEPACKAGE_RECIPE_WEBHOOKS_FEATURE_FLAG = "sourcepackage.recipe.webhooks.enabled"
+
 MINIMAL_RECIPE_TEXT_BZR = dedent(
     """\
     # bzr-builder format 0.3 deb-version {debupstream}-0~{revno}
@@ -320,7 +324,7 @@ class ISourcePackageRecipeEditableAttributes(IHasOwner):
     )
 
 
-class ISourcePackageRecipeEdit(Interface):
+class ISourcePackageRecipeEdit(IWebhookTarget):
     """ISourcePackageRecipe methods that require launchpad.Edit permission."""
 
     @mutator_for(ISourcePackageRecipeView["recipe_text"])
diff --git a/lib/lp/code/model/sourcepackagerecipe.py b/lib/lp/code/model/sourcepackagerecipe.py
index d30e1ec..33c7d92 100644
--- a/lib/lp/code/model/sourcepackagerecipe.py
+++ b/lib/lp/code/model/sourcepackagerecipe.py
@@ -49,6 +49,8 @@ from lp.services.database.interfaces import IPrimaryStore, IStore
 from lp.services.database.stormbase import StormBase
 from lp.services.database.stormexpr import Greatest, NullsLast
 from lp.services.propertycache import cachedproperty, get_property_cache
+from lp.services.webhooks.interfaces import IWebhookSet
+from lp.services.webhooks.model import WebhookTargetMixin
 from lp.soyuz.model.archive import Archive
 
 
@@ -82,7 +84,7 @@ class _SourcePackageRecipeDistroSeries(StormBase):
 @implementer(ISourcePackageRecipe)
 @provider(ISourcePackageRecipeSource)
 @delegate_to(ISourcePackageRecipeData, context="_recipe_data")
-class SourcePackageRecipe(StormBase):
+class SourcePackageRecipe(StormBase, WebhookTargetMixin):
     """See `ISourcePackageRecipe` and `ISourcePackageRecipeSource`."""
 
     __storm_table__ = "SourcePackageRecipe"
@@ -150,6 +152,10 @@ class SourcePackageRecipe(StormBase):
             assert self.base_git_repository is not None
             return self.base_git_repository
 
+    @property
+    def valid_webhook_event_types(self):
+        return ["sourcepackage-recipe:build:0.1"]
+
     @staticmethod
     def preLoadDataForSourcePackageRecipes(sourcepackagerecipes):
         # Load the referencing SourcePackageRecipeData.
@@ -285,6 +291,7 @@ class SourcePackageRecipe(StormBase):
         )
         builds.set(recipe_id=None)
         store.remove(self._recipe_data)
+        getUtility(IWebhookSet).delete(self.webhooks)
         store.remove(self)
 
     def containsUnbuildableSeries(self, archive):
diff --git a/lib/lp/code/model/sourcepackagerecipebuild.py b/lib/lp/code/model/sourcepackagerecipebuild.py
index e3043d0..773d748 100644
--- a/lib/lp/code/model/sourcepackagerecipebuild.py
+++ b/lib/lp/code/model/sourcepackagerecipebuild.py
@@ -354,6 +354,31 @@ class SourcePackageRecipeBuild(
     def verifySuccessfulUpload(self) -> bool:
         return self.source_package_release is not None
 
+    def updateStatus(
+        self,
+        status,
+        builder=None,
+        worker_status=None,
+        date_started=None,
+        date_finished=None,
+        force_invalid_transition=False,
+    ):
+        """See `IBuildFarmJob`."""
+        edited_fields = set()
+        with notify_modified(
+            self, edited_fields, snapshot_names=("status",)
+        ) as previous_obj:
+            super().updateStatus(
+                status,
+                builder=builder,
+                worker_status=worker_status,
+                date_started=date_started,
+                date_finished=date_finished,
+                force_invalid_transition=force_invalid_transition,
+            )
+            if self.status != previous_obj.status:
+                edited_fields.add("status")
+    
     def notify(self, extra_info=None):
         """See `IPackageBuild`."""
         # If our recipe has been deleted, any notification will fail.
diff --git a/lib/lp/code/subscribers/sourcepackagerecipebuild.py b/lib/lp/code/subscribers/sourcepackagerecipebuild.py
new file mode 100644
index 0000000..9029ea7
--- /dev/null
+++ b/lib/lp/code/subscribers/sourcepackagerecipebuild.py
@@ -0,0 +1,42 @@
+# 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 Package Recipe builds."""
+
+from lazr.lifecycle.interfaces import IObjectCreatedEvent, IObjectModifiedEvent
+from zope.component import getUtility
+
+from lp.code.interfaces.sourcepackagerecipebuild import ISourcePackageRecipeBuild
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webhooks.interfaces import IWebhookSet
+from lp.services.webhooks.payload import compose_webhook_payload
+
+
+def _trigger_sourcepackagerecipe_build_webhook(build: ISourcePackageRecipe, action: str) -> None:
+    """Trigger a webhook for a source package recipe build event."""
+    payload = {
+        "sourcepackagerecipe_build": canonical_url(build, force_local_path=True),
+        "action": action,
+    }
+    payload.update(
+        compose_webhook_payload(
+            ISourcePackageRecipeBuild,
+            build,
+            ["recipe", "build_request", "status"],
+        )
+    )
+    getUtility(IWebhookSet).trigger(
+        build.recipe, "sourcepackage-recipe:build:0.1", payload
+    )
+
+
+def sourcepackagerecipe_build_created(build: ISourcePackageRecipe, event: IObjectCreatedEvent) -> None:
+    """Trigger events when a new source package recipe build is created."""
+    _trigger_sourcepackagerecipe_build_webhook(build, "created")
+
+
+def sourcepackagerecipe_build_modified(build: ISourcePackageRecipe, event: IObjectModifiedEvent) -> None:
+    """Trigger events when a source package recipe build is modified."""
+    if event.edited_fields is not None:
+        if "status" in event.edited_fields:
+            _trigger_sourcepackagerecipe_build_webhook(build, "status-changed")
diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
index 1515d2f..edffad0 100644
--- a/lib/lp/services/webhooks/interfaces.py
+++ b/lib/lp/services/webhooks/interfaces.py
@@ -67,6 +67,7 @@ WEBHOOK_EVENT_TYPES = {
     "oci-recipe:build:0.1": "OCI recipe build",
     "snap:build:0.1": "Snap build",
     "craft-recipe:build:0.1": "Craft recipe build",
+    "sourcepackage-recipe:build:0.1": "Source package recipe build",
 }
 
 
diff --git a/lib/lp/services/webhooks/model.py b/lib/lp/services/webhooks/model.py
index 397c818..9351a1c 100644
--- a/lib/lp/services/webhooks/model.py
+++ b/lib/lp/services/webhooks/model.py
@@ -117,6 +117,9 @@ class Webhook(StormBase):
         source_package_name_id, "SourcePackageName.id"
     )
 
+    sourcepackage_recipe_id = Int(name="sourcepackage_recipe")
+    sourcepackage_recipe = Reference(sourcepackage_recipe_id, "SourcePackageRecipe.id")
+
     registrant_id = Int(name="registrant", allow_none=False)
     registrant = Reference(registrant_id, "Person.id")
     date_created = DateTime(tzinfo=timezone.utc, allow_none=False)
@@ -233,6 +236,7 @@ class WebhookSet:
         from lp.code.interfaces.branch import IBranch
         from lp.code.interfaces.gitrepository import IGitRepository
         from lp.crafts.interfaces.craftrecipe import ICraftRecipe
+        from lp.code.interfaces.sourcepackagerecipe import ISourcePackageRecipe
         from lp.oci.interfaces.ocirecipe import IOCIRecipe
         from lp.registry.interfaces.distribution import IDistribution
         from lp.registry.interfaces.distributionsourcepackage import (
@@ -257,6 +261,8 @@ class WebhookSet:
             hook.charm_recipe = target
         elif ICraftRecipe.providedBy(target):
             hook.craft_recipe = target
+        elif ISourcePackageRecipe.providedBy(target):
+            hook.sourcepackage_recipe = target
         elif IProduct.providedBy(target):
             hook.project = target
         elif IDistribution.providedBy(target):
@@ -291,6 +297,7 @@ class WebhookSet:
         from lp.code.interfaces.branch import IBranch
         from lp.code.interfaces.gitrepository import IGitRepository
         from lp.crafts.interfaces.craftrecipe import ICraftRecipe
+        from lp.code.interfaces.sourcepackagerecipe import ISourcePackageRecipe
         from lp.oci.interfaces.ocirecipe import IOCIRecipe
         from lp.registry.interfaces.distribution import IDistribution
         from lp.registry.interfaces.distributionsourcepackage import (
@@ -314,6 +321,8 @@ class WebhookSet:
             target_filter = Webhook.charm_recipe == target
         elif ICraftRecipe.providedBy(target):
             target_filter = Webhook.craft_recipe == target
+        elif ISourcePackageRecipe.providedBy(target):
+            target_filter = Webhook.sourcepackage_recipe == target
         elif IProduct.providedBy(target):
             target_filter = Webhook.project == target
         elif IDistribution.providedBy(target):