← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:ci-build-webhooks into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:ci-build-webhooks into launchpad:master.

Commit message:
Add webhooks for CI builds

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/442596

This was mostly just cribbed from charm recipe build webhooks, with adjustments as needed for the different data model here.  I expect we'll probably need to add some more fields to the payload once people start using this in practice, but this should be enough to get started.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:ci-build-webhooks into launchpad:master.
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index eaf4020..cf2e52e 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2023 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -1297,6 +1297,14 @@
         permission="launchpad.Admin"
         interface="lp.code.interfaces.cibuild.ICIBuildAdmin" />
   </class>
+  <subscriber
+      for="lp.code.interfaces.cibuild.ICIBuild
+           lazr.lifecycle.interfaces.IObjectCreatedEvent"
+      handler="lp.code.subscribers.cibuild.ci_build_created" />
+  <subscriber
+      for="lp.code.interfaces.cibuild.ICIBuild
+           lazr.lifecycle.interfaces.IObjectModifiedEvent"
+      handler="lp.code.subscribers.cibuild.ci_build_modified" />
 
   <!-- CIBuildSet -->
   <lp:securedutility
diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py
index 9599b75..bac6181 100644
--- a/lib/lp/code/interfaces/cibuild.py
+++ b/lib/lp/code/interfaces/cibuild.py
@@ -1,9 +1,10 @@
-# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# Copyright 2022-2023 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interfaces for CI builds."""
 
 __all__ = [
+    "CI_WEBHOOKS_FEATURE_FLAG",
     "CannotFetchConfiguration",
     "CannotParseConfiguration",
     "CIBuildAlreadyRequested",
@@ -41,6 +42,8 @@ from lp.code.interfaces.gitrepository import IGitRepository
 from lp.services.database.constants import DEFAULT
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
 
+CI_WEBHOOKS_FEATURE_FLAG = "ci.webhooks.enabled"
+
 
 class MissingConfiguration(Exception):
     """The repository for this CI build does not have a .launchpad.yaml."""
diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
index ad5bbfb..e1b7450 100644
--- a/lib/lp/code/model/cibuild.py
+++ b/lib/lp/code/model/cibuild.py
@@ -1,4 +1,4 @@
-# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# Copyright 2022-2023 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """CI builds."""
@@ -77,6 +77,7 @@ from lp.services.macaroons.interfaces import (
 )
 from lp.services.macaroons.model import MacaroonIssuerBase
 from lp.services.propertycache import cachedproperty
+from lp.services.webapp.snapshot import notify_modified
 from lp.soyuz.model.binarypackagename import BinaryPackageName
 from lp.soyuz.model.binarypackagerelease import BinaryPackageRelease
 from lp.soyuz.model.distroarchseries import DistroArchSeries
@@ -504,6 +505,31 @@ class CIBuild(PackageBuildMixin, StormBase):
         # We have no interesting checks to perform here.
         return True
 
+    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`."""
         from lp.code.mail.cibuild import CIBuildMailer
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 0ffae31..0384d58 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -417,7 +417,7 @@ class GitRepository(
 
     @property
     def valid_webhook_event_types(self):
-        return ["git:push:0.1", "merge-proposal:0.1"]
+        return ["ci:build:0.1", "git:push:0.1", "merge-proposal:0.1"]
 
     @property
     def default_webhook_event_types(self):
diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
index ec452d6..ae95fbd 100644
--- a/lib/lp/code/model/tests/test_cibuild.py
+++ b/lib/lp/code/model/tests/test_cibuild.py
@@ -1,4 +1,4 @@
-# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# Copyright 2022-2023 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test CI builds."""
@@ -9,13 +9,14 @@ from textwrap import dedent
 from unittest.mock import Mock
 from urllib.request import urlopen
 
-from fixtures import MockPatchObject
+from fixtures import FakeLogger, MockPatchObject
 from pymacaroons import Macaroon
 from storm.locals import Store
 from testtools.matchers import (
     ContainsDict,
     Equals,
     Is,
+    MatchesDict,
     MatchesListwise,
     MatchesSetwise,
     MatchesStructure,
@@ -35,6 +36,7 @@ from lp.buildmaster.model.buildfarmjob import BuildFarmJob
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.code.errors import GitRepositoryBlobNotFound, GitRepositoryScanFault
 from lp.code.interfaces.cibuild import (
+    CI_WEBHOOKS_FEATURE_FLAG,
     CannotFetchConfiguration,
     CannotParseConfiguration,
     CIBuildAlreadyRequested,
@@ -55,12 +57,15 @@ from lp.registry.interfaces.sourcepackage import SourcePackageType
 from lp.services.authserver.xmlrpc import AuthServerAPIView
 from lp.services.config import config
 from lp.services.database.sqlbase import flush_database_caches
+from lp.services.features.testing import FeatureFixture
 from lp.services.librarian.browser import ProxiedLibraryFileAlias
 from lp.services.log.logger import BufferLogger
 from lp.services.macaroons.interfaces import IMacaroonIssuer
 from lp.services.macaroons.testing import MacaroonTestMixin
 from lp.services.propertycache import clear_property_cache
 from lp.services.webapp.interfaces import OAuthPermission
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webhooks.testing import LogsScheduledWebhooks
 from lp.soyuz.enums import BinaryPackageFormat
 from lp.testing import (
     ANONYMOUS,
@@ -73,6 +78,7 @@ from lp.testing import (
     person_logged_in,
     pop_notifications,
 )
+from lp.testing.dbuser import dbuser
 from lp.testing.layers import LaunchpadFunctionalLayer, LaunchpadZopelessLayer
 from lp.testing.matchers import HasQueryCount
 from lp.testing.pages import webservice_for_person
@@ -320,6 +326,95 @@ class TestCIBuild(TestCaseWithFactory):
         build = self.factory.makeCIBuild()
         self.assertTrue(build.verifySuccessfulUpload())
 
+    def test_updateStatus_triggers_webhooks(self):
+        # Updating the status of a CIBuild triggers webhooks on the
+        # corresponding GitRepository.
+        self.useFixture(FeatureFixture({CI_WEBHOOKS_FEATURE_FLAG: "on"}))
+        logger = self.useFixture(FakeLogger())
+        build = self.factory.makeCIBuild()
+        hook = self.factory.makeWebhook(
+            target=build.git_repository, event_types=["ci:build:0.1"]
+        )
+        build.updateStatus(BuildStatus.FULLYBUILT)
+        expected_payload = {
+            "build": Equals(canonical_url(build, force_local_path=True)),
+            "action": Equals("status-changed"),
+            "git_repository": Equals(
+                canonical_url(build.git_repository, force_local_path=True)
+            ),
+            "commit_sha1": Equals(build.commit_sha1),
+            "status": Equals("Successfully built"),
+        }
+        delivery = hook.deliveries.one()
+        self.assertThat(
+            delivery,
+            MatchesStructure(
+                event_type=Equals("ci:build:0.1"),
+                payload=MatchesDict(expected_payload),
+            ),
+        )
+        with dbuser(config.IWebhookDeliveryJobSource.dbuser):
+            self.assertEqual(
+                "<WebhookDeliveryJob for webhook %d on %r>"
+                % (hook.id, hook.target),
+                repr(delivery),
+            )
+            self.assertThat(
+                logger.output,
+                LogsScheduledWebhooks(
+                    [(hook, "ci:build:0.1", MatchesDict(expected_payload))]
+                ),
+            )
+
+    def test_updateStatus_no_change_does_not_trigger_webhooks(self):
+        # An updateStatus call that changes details of the worker status but
+        # that doesn't change the build's status attribute does not trigger
+        # webhooks.
+        self.useFixture(FeatureFixture({CI_WEBHOOKS_FEATURE_FLAG: "on"}))
+        logger = self.useFixture(FakeLogger())
+        build = self.factory.makeCIBuild()
+        hook = self.factory.makeWebhook(
+            target=build.git_repository, event_types=["ci:build:0.1"]
+        )
+        builder = self.factory.makeBuilder()
+        build.updateStatus(BuildStatus.BUILDING)
+        expected_logs = [
+            (
+                hook,
+                "ci:build:0.1",
+                ContainsDict(
+                    {
+                        "action": Equals("status-changed"),
+                        "status": Equals("Currently building"),
+                    }
+                ),
+            )
+        ]
+        self.assertEqual(1, hook.deliveries.count())
+        self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs))
+        build.updateStatus(
+            BuildStatus.BUILDING,
+            builder=builder,
+            worker_status={"revision_id": build.commit_sha1},
+        )
+        self.assertEqual(1, hook.deliveries.count())
+        self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs))
+        build.updateStatus(BuildStatus.UPLOADING)
+        expected_logs.append(
+            (
+                hook,
+                "ci:build:0.1",
+                ContainsDict(
+                    {
+                        "action": Equals("status-changed"),
+                        "status": Equals("Uploading build"),
+                    }
+                ),
+            )
+        )
+        self.assertEqual(2, hook.deliveries.count())
+        self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs))
+
     def addFakeBuildLog(self, build):
         build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt"))
 
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 2aeb2ca..ecf5663 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -12,7 +12,7 @@ from textwrap import dedent
 
 import transaction
 from breezy import urlutils
-from fixtures import MockPatch
+from fixtures import FakeLogger, MockPatch
 from lazr.lifecycle.event import ObjectModifiedEvent
 from pymacaroons import Macaroon
 from storm.exceptions import LostObjectError
@@ -76,7 +76,11 @@ from lp.code.event.git import GitRefsUpdatedEvent
 from lp.code.interfaces.branchmergeproposal import (
     BRANCH_MERGE_PROPOSAL_FINAL_STATES as FINAL_STATES,
 )
-from lp.code.interfaces.cibuild import ICIBuild, ICIBuildSet
+from lp.code.interfaces.cibuild import (
+    CI_WEBHOOKS_FEATURE_FLAG,
+    ICIBuild,
+    ICIBuildSet,
+)
 from lp.code.interfaces.codeimport import ICodeImportSet
 from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
 from lp.code.interfaces.gitjob import (
@@ -170,7 +174,9 @@ from lp.services.propertycache import clear_property_cache
 from lp.services.utils import seconds_since_epoch
 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.webapp.snapshot import notify_modified
+from lp.services.webhooks.testing import LogsScheduledWebhooks
 from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS
 from lp.testing import (
     ANONYMOUS,
@@ -4171,6 +4177,83 @@ class TestGitRepositoryRequestCIBuilds(TestCaseWithFactory):
         )
         self.assertEqual("", logger.getLogBuffer())
 
+    def test_triggers_webhooks(self):
+        # Requesting CI builds triggers any relevant webhooks.
+        self.useFixture(FeatureFixture({CI_WEBHOOKS_FEATURE_FLAG: "on"}))
+        logger = self.useFixture(FakeLogger())
+        repository = self.factory.makeGitRepository()
+        hook = self.factory.makeWebhook(
+            target=repository, event_types=["ci:build:0.1"]
+        )
+        ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        distroseries = self.factory.makeDistroSeries(distribution=ubuntu)
+        das = self.factory.makeBuildableDistroArchSeries(
+            distroseries=distroseries
+        )
+        configuration = dedent(
+            """\
+            pipeline: [test]
+            jobs:
+                test:
+                    series: {series}
+                    architectures: [{architecture}]
+            """.format(
+                series=distroseries.name, architecture=das.architecturetag
+            )
+        ).encode()
+        new_commit = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
+        self.useFixture(
+            GitHostingFixture(
+                commits=[
+                    {
+                        "sha1": new_commit,
+                        "blobs": {".launchpad.yaml": configuration},
+                    },
+                ]
+            )
+        )
+        with dbuser("branchscanner"):
+            repository.createOrUpdateRefs(
+                {
+                    "refs/heads/test": {
+                        "sha1": new_commit,
+                        "type": GitObjectType.COMMIT,
+                    }
+                }
+            )
+
+        [build] = getUtility(ICIBuildSet).findByGitRepository(repository)
+        delivery = hook.deliveries.one()
+        payload_matcher = MatchesDict(
+            {
+                "build": Equals(canonical_url(build, force_local_path=True)),
+                "action": Equals("created"),
+                "git_repository": Equals(
+                    canonical_url(repository, force_local_path=True)
+                ),
+                "commit_sha1": Equals(new_commit),
+                "status": Equals("Needs building"),
+            }
+        )
+        self.assertThat(
+            delivery,
+            MatchesStructure(
+                event_type=Equals("ci:build:0.1"), payload=payload_matcher
+            ),
+        )
+        with dbuser(config.IWebhookDeliveryJobSource.dbuser):
+            self.assertEqual(
+                "<WebhookDeliveryJob for webhook %d on %r>"
+                % (hook.id, hook.target),
+                repr(delivery),
+            )
+            self.assertThat(
+                logger.output,
+                LogsScheduledWebhooks(
+                    [(hook, "ci:build:0.1", payload_matcher)]
+                ),
+            )
+
 
 class TestGitRepositoryGetBlob(TestCaseWithFactory):
     """Tests for retrieving files from a Git repository."""
diff --git a/lib/lp/code/subscribers/cibuild.py b/lib/lp/code/subscribers/cibuild.py
new file mode 100644
index 0000000..a0eb3fc
--- /dev/null
+++ b/lib/lp/code/subscribers/cibuild.py
@@ -0,0 +1,40 @@
+# Copyright 2023 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Event subscribers for CI builds."""
+
+from zope.component import getUtility
+
+from lp.code.interfaces.cibuild import CI_WEBHOOKS_FEATURE_FLAG, ICIBuild
+from lp.services.features import getFeatureFlag
+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_ci_build_webhook(build, action):
+    if getFeatureFlag(CI_WEBHOOKS_FEATURE_FLAG):
+        payload = {
+            "build": canonical_url(build, force_local_path=True),
+            "action": action,
+        }
+        payload.update(
+            compose_webhook_payload(
+                ICIBuild, build, ["git_repository", "commit_sha1", "status"]
+            )
+        )
+        getUtility(IWebhookSet).trigger(
+            build.git_repository, "ci:build:0.1", payload
+        )
+
+
+def ci_build_created(build, event):
+    """Trigger events when a new CI build is created."""
+    _trigger_ci_build_webhook(build, "created")
+
+
+def ci_build_modified(build, event):
+    """Trigger events when a CI build is modified."""
+    if event.edited_fields is not None:
+        if "status" in event.edited_fields:
+            _trigger_ci_build_webhook(build, "status-changed")
diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
index b31f29d..4f0a1e8 100644
--- a/lib/lp/services/webhooks/interfaces.py
+++ b/lib/lp/services/webhooks/interfaces.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2023 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Webhook interfaces."""
@@ -50,6 +50,7 @@ from lp.services.webservice.apihelpers import (
 WEBHOOK_EVENT_TYPES = {
     "bzr:push:0.1": "Bazaar push",
     "charm-recipe:build:0.1": "Charm recipe build",
+    "ci:build:0.1": "CI build",
     "git:push:0.1": "Git push",
     "livefs:build:0.1": "Live filesystem build",
     "merge-proposal:0.1": "Merge proposal",
diff --git a/lib/lp/services/webhooks/tests/test_browser.py b/lib/lp/services/webhooks/tests/test_browser.py
index 65ba0c3..b3f28fe 100644
--- a/lib/lp/services/webhooks/tests/test_browser.py
+++ b/lib/lp/services/webhooks/tests/test_browser.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2023 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Unit tests for Webhook views."""
@@ -63,6 +63,7 @@ class GitRepositoryTestHelpers:
 
     event_type = "git:push:0.1"
     expected_event_types = [
+        ("ci:build:0.1", "CI build"),
         ("git:push:0.1", "Git push"),
         ("merge-proposal:0.1", "Merge proposal"),
     ]