← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-webhooks into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-webhooks into lp:launchpad.

Commit message:
Add webhook support for snap packages.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1535826 in Launchpad itself: "Add webhook support for snaps"
  https://bugs.launchpad.net/launchpad/+bug/1535826

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-webhooks/+merge/283193

Add webhook support for snap packages.

This currently only supports notifying about build status changes, using a webhook called "snap:build:0.1".  I thought about notifying only terminal statuses, but on reflection a third-party service making snap build requests might well want to show an indication of a build being in progress, so this just notifies everything and the far end can sort out what it wants.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-webhooks into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2015-11-21 11:11:50 +0000
+++ database/schema/security.cfg	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 #
 # Possible permissions: SELECT, INSERT, UPDATE, EXECUTE
@@ -1003,6 +1003,8 @@
 public.teamparticipation                      = SELECT
 public.translationimportqueueentry            = SELECT, INSERT, UPDATE
 public.translationtemplatesbuild              = SELECT, INSERT, UPDATE
+public.webhook                                = SELECT
+public.webhookjob                             = SELECT, INSERT
 type=user
 
 [buildd_manager]
@@ -1429,6 +1431,8 @@
 public.teamparticipation                = SELECT, INSERT
 public.validpersoncache                 = SELECT
 public.validpersonorteamcache           = SELECT
+public.webhook                          = SELECT
+public.webhookjob                       = SELECT, INSERT
 public.wikiname                         = SELECT, INSERT
 public.xref                             = SELECT, INSERT
 type=group

=== modified file 'lib/lp/services/webhooks/interfaces.py'
--- lib/lp/services/webhooks/interfaces.py	2015-10-14 16:03:01 +0000
+++ lib/lp/services/webhooks/interfaces.py	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Webhook interfaces."""
@@ -76,6 +76,7 @@
     "bzr:push:0.1": "Bazaar push",
     "git:push:0.1": "Git push",
     "merge-proposal:0.1": "Merge proposal",
+    "snap:build:0.1": "Snap build",
     }
 
 

=== modified file 'lib/lp/services/webhooks/model.py'
--- lib/lp/services/webhooks/model.py	2015-11-11 18:12:24 +0000
+++ lib/lp/services/webhooks/model.py	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -102,6 +102,9 @@
     branch_id = Int(name='branch')
     branch = Reference(branch_id, 'Branch.id')
 
+    snap_id = Int(name='snap')
+    snap = Reference(snap_id, 'Snap.id')
+
     registrant_id = Int(name='registrant', allow_none=False)
     registrant = Reference(registrant_id, 'Person.id')
     date_created = DateTime(tzinfo=utc, allow_none=False)
@@ -119,6 +122,8 @@
             return self.git_repository
         elif self.branch is not None:
             return self.branch
+        elif self.snap is not None:
+            return self.snap
         else:
             raise AssertionError("No target.")
 
@@ -172,11 +177,14 @@
             secret):
         from lp.code.interfaces.branch import IBranch
         from lp.code.interfaces.gitrepository import IGitRepository
+        from lp.snappy.interfaces.snap import ISnap
         hook = Webhook()
         if IGitRepository.providedBy(target):
             hook.git_repository = target
         elif IBranch.providedBy(target):
             hook.branch = target
+        elif ISnap.providedBy(target):
+            hook.snap = target
         else:
             raise AssertionError("Unsupported target: %r" % (target,))
         hook.registrant = registrant
@@ -200,10 +208,13 @@
     def findByTarget(self, target):
         from lp.code.interfaces.branch import IBranch
         from lp.code.interfaces.gitrepository import IGitRepository
+        from lp.snappy.interfaces.snap import ISnap
         if IGitRepository.providedBy(target):
             target_filter = Webhook.git_repository == target
         elif IBranch.providedBy(target):
             target_filter = Webhook.branch == target
+        elif ISnap.providedBy(target):
+            target_filter = Webhook.snap == target
         else:
             raise AssertionError("Unsupported target: %r" % (target,))
         return IStore(Webhook).find(Webhook, target_filter).order_by(

=== modified file 'lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt'
--- lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt	2015-09-24 13:44:02 +0000
+++ lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt	2016-01-19 17:45:04 +0000
@@ -18,8 +18,8 @@
       <div>
         <div class="beta" style="display: inline">
           <img class="beta" alt="[BETA]" src="/@@/beta" /></div>
-        The only currently supported events are Git and Bazaar pushes. We'll
-        be rolling out webhooks for more soon.
+        The only currently supported events are Git and Bazaar pushes and
+        snap package builds. We'll be rolling out webhooks for more soon.
       </div>
       <ul class="horizontal">
         <li>

=== modified file 'lib/lp/services/webhooks/tests/test_browser.py'
--- lib/lp/services/webhooks/tests/test_browser.py	2015-10-26 11:47:38 +0000
+++ lib/lp/services/webhooks/tests/test_browser.py	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Unit tests for Webhook views."""
@@ -17,6 +17,7 @@
 
 from lp.services.features.testing import FeatureFixture
 from lp.services.webapp.publisher import canonical_url
+from lp.snappy.interfaces.snap import SNAP_FEATURE_FLAG
 from lp.testing import (
     login_person,
     record_two_runs,
@@ -59,6 +60,9 @@
     def makeTarget(self):
         return self.factory.makeGitRepository()
 
+    def getTraversalStack(self, obj):
+        return [obj.target, obj]
+
 
 class BranchTestHelpers:
 
@@ -71,6 +75,28 @@
     def makeTarget(self):
         return self.factory.makeBranch()
 
+    def getTraversalStack(self, obj):
+        return [obj.target, obj]
+
+
+class SnapTestHelpers:
+
+    event_type = "snap:build:0.1"
+    expected_event_types = [
+        ("snap:build:0.1", "Snap build"),
+        ]
+
+    def makeTarget(self):
+        self.useFixture(FeatureFixture({
+            SNAP_FEATURE_FLAG: 'true',
+            'webhooks.new.enabled': 'true',
+            }))
+        owner = self.factory.makePerson()
+        return self.factory.makeSnap(registrant=owner, owner=owner)
+
+    def getTraversalStack(self, obj):
+        return [obj]
+
 
 class WebhookTargetViewTestHelpers:
 
@@ -84,8 +110,8 @@
     def makeView(self, name, **kwargs):
         view = create_view(self.target, name, principal=self.owner, **kwargs)
         # To test the breadcrumbs we need a correct traversal stack.
-        view.request.traversed_objects = [
-            self.target.target, self.target, view]
+        view.request.traversed_objects = (
+            self.getTraversalStack(self.target) + [view])
         view.initialize()
         return view
 
@@ -162,6 +188,12 @@
     pass
 
 
+class TestWebhooksViewSnap(
+    TestWebhooksViewBase, SnapTestHelpers, TestCaseWithFactory):
+
+    pass
+
+
 class TestWebhookAddViewBase(WebhookTargetViewTestHelpers):
 
     layer = DatabaseFunctionalLayer
@@ -254,6 +286,12 @@
     pass
 
 
+class TestWebhookAddViewSnap(
+    TestWebhookAddViewBase, SnapTestHelpers, TestCaseWithFactory):
+
+    pass
+
+
 class WebhookViewTestHelpers:
 
     def setUp(self):
@@ -268,8 +306,8 @@
     def makeView(self, name, **kwargs):
         view = create_view(self.webhook, name, principal=self.owner, **kwargs)
         # To test the breadcrumbs we need a correct traversal stack.
-        view.request.traversed_objects = [
-            self.target.target, self.target, self.webhook, view]
+        view.request.traversed_objects = (
+            self.getTraversalStack(self.target) + [self.webhook, view])
         view.initialize()
         return view
 
@@ -350,6 +388,12 @@
     pass
 
 
+class TestWebhookViewSnap(
+    TestWebhookViewBase, SnapTestHelpers, TestCaseWithFactory):
+
+    pass
+
+
 class TestWebhookDeleteViewBase(WebhookViewTestHelpers):
 
     layer = DatabaseFunctionalLayer
@@ -394,3 +438,9 @@
     TestWebhookDeleteViewBase, BranchTestHelpers, TestCaseWithFactory):
 
     pass
+
+
+class TestWebhookDeleteViewSnap(
+    TestWebhookDeleteViewBase, SnapTestHelpers, TestCaseWithFactory):
+
+    pass

=== modified file 'lib/lp/services/webhooks/tests/test_model.py'
--- lib/lp/services/webhooks/tests/test_model.py	2015-10-14 16:03:01 +0000
+++ lib/lp/services/webhooks/tests/test_model.py	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 from lazr.lifecycle.event import ObjectModifiedEvent
@@ -17,6 +17,7 @@
 from lp.app.enums import InformationType
 from lp.registry.enums import BranchSharingPolicy
 from lp.services.database.interfaces import IStore
+from lp.services.features.testing import FeatureFixture
 from lp.services.webapp.authorization import check_permission
 from lp.services.webhooks.interfaces import (
     IWebhook,
@@ -26,6 +27,7 @@
     WebhookJob,
     WebhookSet,
     )
+from lp.snappy.interfaces.snap import SNAP_FEATURE_FLAG
 from lp.testing import (
     admin_logged_in,
     anonymous_logged_in,
@@ -204,6 +206,45 @@
         login_person(target.owner)
         self.assertTrue(WebhookSet._checkVisibility(target, target.owner))
 
+    def test_trigger(self):
+        owner = self.factory.makePerson()
+        target1 = self.makeTarget(owner=owner)
+        target2 = self.makeTarget(owner=owner)
+        hook1a = self.factory.makeWebhook(
+            target=target1, event_types=[])
+        hook1b = self.factory.makeWebhook(
+            target=target1, event_types=[self.event_type])
+        hook2a = self.factory.makeWebhook(
+            target=target2, event_types=[self.event_type])
+        hook2b = self.factory.makeWebhook(
+            target=target2, event_types=[self.event_type], active=False)
+
+        # Only webhooks subscribed to the relevant target and event type
+        # are triggered.
+        getUtility(IWebhookSet).trigger(
+            target1, self.event_type, {'some': 'payload'})
+        with admin_logged_in():
+            self.assertThat(list(hook1a.deliveries), HasLength(0))
+            self.assertThat(list(hook1b.deliveries), HasLength(1))
+            self.assertThat(list(hook2a.deliveries), HasLength(0))
+            self.assertThat(list(hook2b.deliveries), HasLength(0))
+            delivery = hook1b.deliveries.one()
+            self.assertEqual(delivery.payload, {'some': 'payload'})
+
+        # Disabled webhooks aren't triggered.
+        getUtility(IWebhookSet).trigger(
+            target2, self.event_type, {'other': 'payload'})
+        with admin_logged_in():
+            self.assertThat(list(hook1a.deliveries), HasLength(0))
+            self.assertThat(list(hook1b.deliveries), HasLength(1))
+            self.assertThat(list(hook2a.deliveries), HasLength(1))
+            self.assertThat(list(hook2b.deliveries), HasLength(0))
+            delivery = hook2a.deliveries.one()
+            self.assertEqual(delivery.payload, {'other': 'payload'})
+
+
+class TestWebhookSetMergeProposalBase(TestWebhookSetBase):
+
     def test__checkVisibility_private_artifact(self):
         owner = self.factory.makePerson()
         target = self.makeTarget(
@@ -248,42 +289,6 @@
         self.assertFalse(
             WebhookSet._checkVisibility(mp2, mp2.merge_target.owner))
 
-    def test_trigger(self):
-        owner = self.factory.makePerson()
-        target1 = self.makeTarget(owner=owner)
-        target2 = self.makeTarget(owner=owner)
-        hook1a = self.factory.makeWebhook(
-            target=target1, event_types=[])
-        hook1b = self.factory.makeWebhook(
-            target=target1, event_types=[self.event_type])
-        hook2a = self.factory.makeWebhook(
-            target=target2, event_types=[self.event_type])
-        hook2b = self.factory.makeWebhook(
-            target=target2, event_types=[self.event_type], active=False)
-
-        # Only webhooks subscribed to the relevant target and event type
-        # are triggered.
-        getUtility(IWebhookSet).trigger(
-            target1, self.event_type, {'some': 'payload'})
-        with admin_logged_in():
-            self.assertThat(list(hook1a.deliveries), HasLength(0))
-            self.assertThat(list(hook1b.deliveries), HasLength(1))
-            self.assertThat(list(hook2a.deliveries), HasLength(0))
-            self.assertThat(list(hook2b.deliveries), HasLength(0))
-            delivery = hook1b.deliveries.one()
-            self.assertEqual(delivery.payload, {'some': 'payload'})
-
-        # Disabled webhooks aren't triggered.
-        getUtility(IWebhookSet).trigger(
-            target2, self.event_type, {'other': 'payload'})
-        with admin_logged_in():
-            self.assertThat(list(hook1a.deliveries), HasLength(0))
-            self.assertThat(list(hook1b.deliveries), HasLength(1))
-            self.assertThat(list(hook2a.deliveries), HasLength(1))
-            self.assertThat(list(hook2b.deliveries), HasLength(0))
-            delivery = hook2a.deliveries.one()
-            self.assertEqual(delivery.payload, {'other': 'payload'})
-
     def test_trigger_skips_invisible(self):
         # No webhooks are dispatched if the visibility check fails.
         project = self.factory.makeProduct(
@@ -344,7 +349,8 @@
             self.assertEqual(delivery.payload, {'some': 'payload'})
 
 
-class TestWebhookSetGitRepository(TestWebhookSetBase, TestCaseWithFactory):
+class TestWebhookSetGitRepository(
+    TestWebhookSetMergeProposalBase, TestCaseWithFactory):
 
     event_type = 'git:push:0.1'
 
@@ -366,7 +372,8 @@
                 reviewer=reviewer)
 
 
-class TestWebhookSetBranch(TestWebhookSetBase, TestCaseWithFactory):
+class TestWebhookSetBranch(
+    TestWebhookSetMergeProposalBase, TestCaseWithFactory):
 
     event_type = 'bzr:push:0.1'
 
@@ -384,3 +391,14 @@
             return self.factory.makeBranchMergeProposal(
                 registrant=owner, target_branch=target, source_branch=source,
                 reviewer=reviewer)
+
+
+class TestWebhookSetSnap(TestWebhookSetBase, TestCaseWithFactory):
+
+    event_type = 'snap:build:0.1'
+
+    def makeTarget(self, owner=None, **kwargs):
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: 'true'}))
+        if owner is None:
+            owner = self.factory.makePerson()
+        return self.factory.makeSnap(registrant=owner, owner=owner, **kwargs)

=== modified file 'lib/lp/services/webhooks/tests/test_webservice.py'
--- lib/lp/services/webhooks/tests/test_webservice.py	2015-10-13 16:58:20 +0000
+++ lib/lp/services/webhooks/tests/test_webservice.py	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for the webhook webservice objects."""
@@ -23,6 +23,7 @@
 
 from lp.services.features.testing import FeatureFixture
 from lp.services.webapp.interfaces import OAuthPermission
+from lp.snappy.interfaces.snap import SNAP_FEATURE_FLAG
 from lp.testing import (
     api_url,
     person_logged_in,
@@ -370,3 +371,13 @@
 
     def makeTarget(self):
         return self.factory.makeBranch()
+
+
+class TestWebhookTargetSnap(TestWebhookTargetBase, TestCaseWithFactory):
+
+    event_type = 'snap:build:0.1'
+
+    def makeTarget(self):
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: 'true'}))
+        owner = self.factory.makePerson()
+        return self.factory.makeSnap(registrant=owner, owner=owner)

=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	2015-10-08 13:58:57 +0000
+++ lib/lp/snappy/browser/snap.py	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Snap views."""
@@ -63,6 +63,7 @@
     Breadcrumb,
     NameBreadcrumb,
     )
+from lp.services.webhooks.browser import WebhookTargetNavigationMixin
 from lp.snappy.browser.widgets.snaparchive import SnapArchiveWidget
 from lp.snappy.interfaces.snap import (
     ISnap,
@@ -78,7 +79,7 @@
 from lp.soyuz.interfaces.archive import IArchive
 
 
-class SnapNavigation(Navigation):
+class SnapNavigation(WebhookTargetNavigationMixin, Navigation):
     usedfor = ISnap
 
     @stepthrough('+build')
@@ -106,7 +107,7 @@
 
     facet = 'overview'
 
-    links = ('edit', 'delete', 'admin')
+    links = ('admin', 'edit', 'webhooks', 'delete')
 
     @enabled_with_permission('launchpad.Admin')
     def admin(self):
@@ -117,6 +118,12 @@
         return Link('+edit', 'Edit snap package', icon='edit')
 
     @enabled_with_permission('launchpad.Edit')
+    def webhooks(self):
+        return Link(
+            '+webhooks', 'Manage webhooks', icon='edit',
+            enabled=bool(getFeatureFlag('webhooks.new.enabled')))
+
+    @enabled_with_permission('launchpad.Edit')
     def delete(self):
         return Link('+delete', 'Delete snap package', icon='trash-icon')
 

=== modified file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml	2015-09-25 17:26:03 +0000
+++ lib/lp/snappy/configure.zcml	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2015 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -52,6 +52,10 @@
             permission="launchpad.Admin"
             interface="lp.snappy.interfaces.snapbuild.ISnapBuildAdmin" />
     </class>
+    <subscriber
+        for="lp.snappy.interfaces.snapbuild.ISnapBuild
+             lp.snappy.interfaces.snapbuild.ISnapBuildStatusChangedEvent"
+        handler="lp.snappy.subscribers.snapbuild.snap_build_status_changed" />
 
     <!-- SnapBuildSet -->
     <securedutility

=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	2015-09-25 17:26:03 +0000
+++ lib/lp/snappy/interfaces/snap.py	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Snap package interfaces."""
@@ -16,6 +16,7 @@
     'NoSourceForSnap',
     'NoSuchSnap',
     'SNAP_FEATURE_FLAG',
+    'SNAP_WEBHOOKS_FEATURE_FLAG',
     'SnapBuildAlreadyPending',
     'SnapBuildArchiveOwnerMismatch',
     'SnapBuildDisallowedArchitecture',
@@ -80,11 +81,13 @@
     PersonChoice,
     PublicPersonChoice,
     )
+from lp.services.webhooks.interfaces import IWebhookTarget
 from lp.soyuz.interfaces.archive import IArchive
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
 
 
 SNAP_FEATURE_FLAG = u"snap.allow_new"
+SNAP_WEBHOOKS_FEATURE_FLAG = u"snap.webhooks.enabled"
 
 
 @error_status(httplib.BAD_REQUEST)
@@ -263,7 +266,7 @@
         value_type=Reference(schema=Interface), readonly=True)))
 
 
-class ISnapEdit(Interface):
+class ISnapEdit(IWebhookTarget):
     """`ISnap` methods that require launchpad.Edit permission."""
 
     @export_destructor_operation()

=== modified file 'lib/lp/snappy/interfaces/snapbuild.py'
--- lib/lp/snappy/interfaces/snapbuild.py	2015-07-23 16:41:12 +0000
+++ lib/lp/snappy/interfaces/snapbuild.py	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Snap package build interfaces."""
@@ -8,6 +8,7 @@
 __all__ = [
     'ISnapBuild',
     'ISnapBuildSet',
+    'ISnapBuildStatusChangedEvent',
     'ISnapFile',
     ]
 
@@ -20,6 +21,7 @@
     operation_parameters,
     )
 from lazr.restful.fields import Reference
+from zope.component.interfaces import IObjectEvent
 from zope.interface import Interface
 from zope.schema import (
     Bool,
@@ -39,6 +41,10 @@
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
 
 
+class ISnapBuildStatusChangedEvent(IObjectEvent):
+    """The status of a snap package build changed."""
+
+
 class ISnapFile(Interface):
     """A file produced by a snap package build."""
 

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2015-10-08 14:18:29 +0000
+++ lib/lp/snappy/model/snap.py	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -69,6 +69,8 @@
     )
 from lp.services.features import getFeatureFlag
 from lp.services.webapp.interfaces import ILaunchBag
+from lp.services.webhooks.interfaces import IWebhookSet
+from lp.services.webhooks.model import WebhookTargetMixin
 from lp.snappy.interfaces.snap import (
     BadSnapSearchContext,
     CannotDeleteSnap,
@@ -105,7 +107,7 @@
 
 
 @implementer(ISnap, IHasOwner)
-class Snap(Storm):
+class Snap(Storm, WebhookTargetMixin):
     """See `ISnap`."""
 
     __storm_table__ = 'Snap'
@@ -160,6 +162,10 @@
         self.date_last_modified = date_created
 
     @property
+    def valid_webhook_event_types(self):
+        return ["snap:build:0.1"]
+
+    @property
     def git_ref(self):
         """See `ISnap`."""
         if self.git_repository is not None:
@@ -344,6 +350,7 @@
             raise CannotDeleteSnap("Cannot delete a snap package with builds.")
         store = IStore(Snap)
         store.find(SnapArch, SnapArch.snap == self).remove()
+        getUtility(IWebhookSet).delete(self.webhooks)
         store.remove(self)
 
 

=== modified file 'lib/lp/snappy/model/snapbuild.py'
--- lib/lp/snappy/model/snapbuild.py	2015-09-16 13:30:33 +0000
+++ lib/lp/snappy/model/snapbuild.py	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -22,6 +22,8 @@
     )
 from storm.store import EmptyResultSet
 from zope.component import getUtility
+from zope.component.interfaces import ObjectEvent
+from zope.event import notify
 from zope.interface import implementer
 
 from lp.app.errors import NotFoundError
@@ -59,6 +61,7 @@
 from lp.snappy.interfaces.snapbuild import (
     ISnapBuild,
     ISnapBuildSet,
+    ISnapBuildStatusChangedEvent,
     ISnapFile,
     )
 from lp.snappy.mail.snapbuild import SnapBuildMailer
@@ -67,6 +70,11 @@
 from lp.soyuz.model.distroarchseries import DistroArchSeries
 
 
+@implementer(ISnapBuildStatusChangedEvent)
+class SnapBuildStatusChangedEvent(ObjectEvent):
+    """See `ISnapBuildStatusChangedEvent`."""
+
+
 @implementer(ISnapFile)
 class SnapFile(Storm):
     """See `ISnap`."""
@@ -296,6 +304,16 @@
         """See `IPackageBuild`."""
         return not self.getFiles().is_empty()
 
+    def updateStatus(self, status, builder=None, slave_status=None,
+                     date_started=None, date_finished=None,
+                     force_invalid_transition=False):
+        """See `IBuildFarmJob`."""
+        super(SnapBuild, self).updateStatus(
+            status, builder=builder, slave_status=slave_status,
+            date_started=date_started, date_finished=date_finished,
+            force_invalid_transition=force_invalid_transition)
+        notify(SnapBuildStatusChangedEvent(self))
+
     def notify(self, extra_info=None):
         """See `IPackageBuild`."""
         if not config.builddmaster.send_build_notification:

=== added directory 'lib/lp/snappy/subscribers'
=== added file 'lib/lp/snappy/subscribers/__init__.py'
=== added file 'lib/lp/snappy/subscribers/snapbuild.py'
--- lib/lp/snappy/subscribers/snapbuild.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/subscribers/snapbuild.py	2016-01-19 17:45:04 +0000
@@ -0,0 +1,30 @@
+# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Event subscribers for snap builds."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+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.services.webhooks.payload import compose_webhook_payload
+from lp.snappy.interfaces.snap import SNAP_WEBHOOKS_FEATURE_FLAG
+from lp.snappy.interfaces.snapbuild import ISnapBuild
+
+
+def snap_build_status_changed(snapbuild, event):
+    """Trigger webhooks when snap package build statuses change."""
+    if getFeatureFlag(SNAP_WEBHOOKS_FEATURE_FLAG):
+        payload = {
+            "snap_build": canonical_url(snapbuild, force_local_path=True),
+            "action": "status-changed",
+            }
+        payload.update(compose_webhook_payload(
+            ISnapBuild, snapbuild, ["snap", "status"]))
+        getUtility(IWebhookSet).trigger(
+            snapbuild.snap, "snap:build:0.1", payload)

=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py	2015-11-26 15:46:38 +0000
+++ lib/lp/snappy/tests/test_snap.py	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test snap packages."""
@@ -8,6 +8,7 @@
 from datetime import timedelta
 
 from lazr.lifecycle.event import ObjectModifiedEvent
+from storm.exceptions import LostObjectError
 from storm.locals import Store
 from testtools.matchers import Equals
 import transaction
@@ -354,6 +355,16 @@
             self.assertRaises(CannotDeleteSnap, snap.destroySelf)
         self.assertTrue(getUtility(ISnapSet).exists(owner, u"condemned"))
 
+    def test_related_webhooks_deleted(self):
+        owner = self.factory.makePerson()
+        snap = self.factory.makeSnap(registrant=owner, owner=owner)
+        webhook = self.factory.makeWebhook(target=snap)
+        with person_logged_in(snap.owner):
+            webhook.ping()
+            snap.destroySelf()
+            transaction.commit()
+            self.assertRaises(LostObjectError, getattr, webhook, "target")
+
 
 class TestSnapSet(TestCaseWithFactory):
 

=== modified file 'lib/lp/snappy/tests/test_snapbuild.py'
--- lib/lp/snappy/tests/test_snapbuild.py	2015-09-11 12:20:23 +0000
+++ lib/lp/snappy/tests/test_snapbuild.py	2016-01-19 17:45:04 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test snap package build features."""
@@ -15,6 +15,11 @@
     )
 
 import pytz
+from testtools.matchers import (
+    Equals,
+    MatchesDict,
+    MatchesStructure,
+    )
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
@@ -29,8 +34,10 @@
 from lp.services.features.testing import FeatureFixture
 from lp.services.librarian.browser import ProxiedLibraryFileAlias
 from lp.services.webapp.interfaces import OAuthPermission
+from lp.services.webapp.publisher import canonical_url
 from lp.snappy.interfaces.snap import (
     SNAP_FEATURE_FLAG,
+    SNAP_WEBHOOKS_FEATURE_FLAG,
     SnapFeatureDisabled,
     )
 from lp.snappy.interfaces.snapbuild import (
@@ -217,6 +224,26 @@
         self.factory.makeSnapFile(snapbuild=self.build)
         self.assertTrue(self.build.verifySuccessfulUpload())
 
+    def test_updateStatus_triggers_webhooks(self):
+        # Updating the status of a SnapBuild triggers webhooks on the
+        # corresponding Snap.
+        self.useFixture(FeatureFixture({SNAP_WEBHOOKS_FEATURE_FLAG: u"on"}))
+        hook = self.factory.makeWebhook(
+            target=self.build.snap, event_types=["snap:build:0.1"])
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        expected_payload = {
+            "snap_build": Equals(
+                canonical_url(self.build, force_local_path=True)),
+            "action": Equals("status-changed"),
+            "snap": Equals(
+                canonical_url(self.build.snap, force_local_path=True)),
+            "status": Equals("Successfully built"),
+            }
+        self.assertThat(
+            hook.deliveries.one(), MatchesStructure(
+                event_type=Equals("snap:build:0.1"),
+                payload=MatchesDict(expected_payload)))
+
     def test_notify_fullybuilt(self):
         # notify does not send mail when a SnapBuild completes normally.
         person = self.factory.makePerson(name="person")


Follow ups