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

Proposed by Colin Watson on 2016-01-19
Status: Merged
Merged at revision: 17929
Proposed branch: lp:~cjwatson/launchpad/snap-webhooks
Merge into: lp:launchpad
Diff against target: 810 lines (+269/-62)
16 files modified
database/schema/security.cfg (+5/-1)
lib/lp/services/webhooks/interfaces.py (+2/-1)
lib/lp/services/webhooks/model.py (+12/-1)
lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt (+2/-2)
lib/lp/services/webhooks/tests/test_browser.py (+55/-5)
lib/lp/services/webhooks/tests/test_model.py (+57/-39)
lib/lp/services/webhooks/tests/test_webservice.py (+12/-1)
lib/lp/snappy/browser/snap.py (+10/-3)
lib/lp/snappy/configure.zcml (+5/-1)
lib/lp/snappy/interfaces/snap.py (+7/-3)
lib/lp/snappy/interfaces/snapbuild.py (+7/-1)
lib/lp/snappy/model/snap.py (+9/-2)
lib/lp/snappy/model/snapbuild.py (+19/-1)
lib/lp/snappy/subscribers/snapbuild.py (+30/-0)
lib/lp/snappy/tests/test_snap.py (+12/-1)
lib/lp/snappy/tests/test_snapbuild.py (+25/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-webhooks
Reviewer Review Type Date Requested Status
Celso Providelo (community) Approve on 2016-02-17
Launchpad code reviewers 2016-01-19 Pending
Review via email: mp+283193@code.launchpad.net

Commit Message

Add webhook support for snap packages.

Description of the Change

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.

To post a comment you must log in.
Celso Providelo (cprov) wrote :

Thanks, Colin.

Regarding the DB security changes, do we need to deploy them specially or are they applied for every devel revision on-the-fly ?

The new webhook payload looks complete (action, snap_url, snap_build_url, status) and useful for API operations.

Obviously I'm missing tons of important details, but the drive-by refactoring make sense and testing looks sufficient.

[]

review: Approve
Colin Watson (cjwatson) wrote :

DB security changes are applied automatically, indeed.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/security.cfg'
2--- database/schema/security.cfg 2015-11-21 11:11:50 +0000
3+++ database/schema/security.cfg 2016-02-19 14:20:42 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8 #
9 # Possible permissions: SELECT, INSERT, UPDATE, EXECUTE
10@@ -1003,6 +1003,8 @@
11 public.teamparticipation = SELECT
12 public.translationimportqueueentry = SELECT, INSERT, UPDATE
13 public.translationtemplatesbuild = SELECT, INSERT, UPDATE
14+public.webhook = SELECT
15+public.webhookjob = SELECT, INSERT
16 type=user
17
18 [buildd_manager]
19@@ -1429,6 +1431,8 @@
20 public.teamparticipation = SELECT, INSERT
21 public.validpersoncache = SELECT
22 public.validpersonorteamcache = SELECT
23+public.webhook = SELECT
24+public.webhookjob = SELECT, INSERT
25 public.wikiname = SELECT, INSERT
26 public.xref = SELECT, INSERT
27 type=group
28
29=== modified file 'lib/lp/services/webhooks/interfaces.py'
30--- lib/lp/services/webhooks/interfaces.py 2015-10-14 16:03:01 +0000
31+++ lib/lp/services/webhooks/interfaces.py 2016-02-19 14:20:42 +0000
32@@ -1,4 +1,4 @@
33-# Copyright 2015 Canonical Ltd. This software is licensed under the
34+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
35 # GNU Affero General Public License version 3 (see the file LICENSE).
36
37 """Webhook interfaces."""
38@@ -76,6 +76,7 @@
39 "bzr:push:0.1": "Bazaar push",
40 "git:push:0.1": "Git push",
41 "merge-proposal:0.1": "Merge proposal",
42+ "snap:build:0.1": "Snap build",
43 }
44
45
46
47=== modified file 'lib/lp/services/webhooks/model.py'
48--- lib/lp/services/webhooks/model.py 2015-11-11 18:12:24 +0000
49+++ lib/lp/services/webhooks/model.py 2016-02-19 14:20:42 +0000
50@@ -1,4 +1,4 @@
51-# Copyright 2015 Canonical Ltd. This software is licensed under the
52+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
53 # GNU Affero General Public License version 3 (see the file LICENSE).
54
55 __metaclass__ = type
56@@ -102,6 +102,9 @@
57 branch_id = Int(name='branch')
58 branch = Reference(branch_id, 'Branch.id')
59
60+ snap_id = Int(name='snap')
61+ snap = Reference(snap_id, 'Snap.id')
62+
63 registrant_id = Int(name='registrant', allow_none=False)
64 registrant = Reference(registrant_id, 'Person.id')
65 date_created = DateTime(tzinfo=utc, allow_none=False)
66@@ -119,6 +122,8 @@
67 return self.git_repository
68 elif self.branch is not None:
69 return self.branch
70+ elif self.snap is not None:
71+ return self.snap
72 else:
73 raise AssertionError("No target.")
74
75@@ -172,11 +177,14 @@
76 secret):
77 from lp.code.interfaces.branch import IBranch
78 from lp.code.interfaces.gitrepository import IGitRepository
79+ from lp.snappy.interfaces.snap import ISnap
80 hook = Webhook()
81 if IGitRepository.providedBy(target):
82 hook.git_repository = target
83 elif IBranch.providedBy(target):
84 hook.branch = target
85+ elif ISnap.providedBy(target):
86+ hook.snap = target
87 else:
88 raise AssertionError("Unsupported target: %r" % (target,))
89 hook.registrant = registrant
90@@ -200,10 +208,13 @@
91 def findByTarget(self, target):
92 from lp.code.interfaces.branch import IBranch
93 from lp.code.interfaces.gitrepository import IGitRepository
94+ from lp.snappy.interfaces.snap import ISnap
95 if IGitRepository.providedBy(target):
96 target_filter = Webhook.git_repository == target
97 elif IBranch.providedBy(target):
98 target_filter = Webhook.branch == target
99+ elif ISnap.providedBy(target):
100+ target_filter = Webhook.snap == target
101 else:
102 raise AssertionError("Unsupported target: %r" % (target,))
103 return IStore(Webhook).find(Webhook, target_filter).order_by(
104
105=== modified file 'lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt'
106--- lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt 2015-09-24 13:44:02 +0000
107+++ lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt 2016-02-19 14:20:42 +0000
108@@ -18,8 +18,8 @@
109 <div>
110 <div class="beta" style="display: inline">
111 <img class="beta" alt="[BETA]" src="/@@/beta" /></div>
112- The only currently supported events are Git and Bazaar pushes. We'll
113- be rolling out webhooks for more soon.
114+ The only currently supported events are Git and Bazaar pushes and
115+ snap package builds. We'll be rolling out webhooks for more soon.
116 </div>
117 <ul class="horizontal">
118 <li>
119
120=== modified file 'lib/lp/services/webhooks/tests/test_browser.py'
121--- lib/lp/services/webhooks/tests/test_browser.py 2015-10-26 11:47:38 +0000
122+++ lib/lp/services/webhooks/tests/test_browser.py 2016-02-19 14:20:42 +0000
123@@ -1,4 +1,4 @@
124-# Copyright 2015 Canonical Ltd. This software is licensed under the
125+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
126 # GNU Affero General Public License version 3 (see the file LICENSE).
127
128 """Unit tests for Webhook views."""
129@@ -17,6 +17,7 @@
130
131 from lp.services.features.testing import FeatureFixture
132 from lp.services.webapp.publisher import canonical_url
133+from lp.snappy.interfaces.snap import SNAP_FEATURE_FLAG
134 from lp.testing import (
135 login_person,
136 record_two_runs,
137@@ -59,6 +60,9 @@
138 def makeTarget(self):
139 return self.factory.makeGitRepository()
140
141+ def getTraversalStack(self, obj):
142+ return [obj.target, obj]
143+
144
145 class BranchTestHelpers:
146
147@@ -71,6 +75,28 @@
148 def makeTarget(self):
149 return self.factory.makeBranch()
150
151+ def getTraversalStack(self, obj):
152+ return [obj.target, obj]
153+
154+
155+class SnapTestHelpers:
156+
157+ event_type = "snap:build:0.1"
158+ expected_event_types = [
159+ ("snap:build:0.1", "Snap build"),
160+ ]
161+
162+ def makeTarget(self):
163+ self.useFixture(FeatureFixture({
164+ SNAP_FEATURE_FLAG: 'true',
165+ 'webhooks.new.enabled': 'true',
166+ }))
167+ owner = self.factory.makePerson()
168+ return self.factory.makeSnap(registrant=owner, owner=owner)
169+
170+ def getTraversalStack(self, obj):
171+ return [obj]
172+
173
174 class WebhookTargetViewTestHelpers:
175
176@@ -84,8 +110,8 @@
177 def makeView(self, name, **kwargs):
178 view = create_view(self.target, name, principal=self.owner, **kwargs)
179 # To test the breadcrumbs we need a correct traversal stack.
180- view.request.traversed_objects = [
181- self.target.target, self.target, view]
182+ view.request.traversed_objects = (
183+ self.getTraversalStack(self.target) + [view])
184 view.initialize()
185 return view
186
187@@ -162,6 +188,12 @@
188 pass
189
190
191+class TestWebhooksViewSnap(
192+ TestWebhooksViewBase, SnapTestHelpers, TestCaseWithFactory):
193+
194+ pass
195+
196+
197 class TestWebhookAddViewBase(WebhookTargetViewTestHelpers):
198
199 layer = DatabaseFunctionalLayer
200@@ -254,6 +286,12 @@
201 pass
202
203
204+class TestWebhookAddViewSnap(
205+ TestWebhookAddViewBase, SnapTestHelpers, TestCaseWithFactory):
206+
207+ pass
208+
209+
210 class WebhookViewTestHelpers:
211
212 def setUp(self):
213@@ -268,8 +306,8 @@
214 def makeView(self, name, **kwargs):
215 view = create_view(self.webhook, name, principal=self.owner, **kwargs)
216 # To test the breadcrumbs we need a correct traversal stack.
217- view.request.traversed_objects = [
218- self.target.target, self.target, self.webhook, view]
219+ view.request.traversed_objects = (
220+ self.getTraversalStack(self.target) + [self.webhook, view])
221 view.initialize()
222 return view
223
224@@ -350,6 +388,12 @@
225 pass
226
227
228+class TestWebhookViewSnap(
229+ TestWebhookViewBase, SnapTestHelpers, TestCaseWithFactory):
230+
231+ pass
232+
233+
234 class TestWebhookDeleteViewBase(WebhookViewTestHelpers):
235
236 layer = DatabaseFunctionalLayer
237@@ -394,3 +438,9 @@
238 TestWebhookDeleteViewBase, BranchTestHelpers, TestCaseWithFactory):
239
240 pass
241+
242+
243+class TestWebhookDeleteViewSnap(
244+ TestWebhookDeleteViewBase, SnapTestHelpers, TestCaseWithFactory):
245+
246+ pass
247
248=== modified file 'lib/lp/services/webhooks/tests/test_model.py'
249--- lib/lp/services/webhooks/tests/test_model.py 2015-10-14 16:03:01 +0000
250+++ lib/lp/services/webhooks/tests/test_model.py 2016-02-19 14:20:42 +0000
251@@ -1,4 +1,4 @@
252-# Copyright 2015 Canonical Ltd. This software is licensed under the
253+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
254 # GNU Affero General Public License version 3 (see the file LICENSE).
255
256 from lazr.lifecycle.event import ObjectModifiedEvent
257@@ -17,6 +17,7 @@
258 from lp.app.enums import InformationType
259 from lp.registry.enums import BranchSharingPolicy
260 from lp.services.database.interfaces import IStore
261+from lp.services.features.testing import FeatureFixture
262 from lp.services.webapp.authorization import check_permission
263 from lp.services.webhooks.interfaces import (
264 IWebhook,
265@@ -26,6 +27,7 @@
266 WebhookJob,
267 WebhookSet,
268 )
269+from lp.snappy.interfaces.snap import SNAP_FEATURE_FLAG
270 from lp.testing import (
271 admin_logged_in,
272 anonymous_logged_in,
273@@ -204,6 +206,45 @@
274 login_person(target.owner)
275 self.assertTrue(WebhookSet._checkVisibility(target, target.owner))
276
277+ def test_trigger(self):
278+ owner = self.factory.makePerson()
279+ target1 = self.makeTarget(owner=owner)
280+ target2 = self.makeTarget(owner=owner)
281+ hook1a = self.factory.makeWebhook(
282+ target=target1, event_types=[])
283+ hook1b = self.factory.makeWebhook(
284+ target=target1, event_types=[self.event_type])
285+ hook2a = self.factory.makeWebhook(
286+ target=target2, event_types=[self.event_type])
287+ hook2b = self.factory.makeWebhook(
288+ target=target2, event_types=[self.event_type], active=False)
289+
290+ # Only webhooks subscribed to the relevant target and event type
291+ # are triggered.
292+ getUtility(IWebhookSet).trigger(
293+ target1, self.event_type, {'some': 'payload'})
294+ with admin_logged_in():
295+ self.assertThat(list(hook1a.deliveries), HasLength(0))
296+ self.assertThat(list(hook1b.deliveries), HasLength(1))
297+ self.assertThat(list(hook2a.deliveries), HasLength(0))
298+ self.assertThat(list(hook2b.deliveries), HasLength(0))
299+ delivery = hook1b.deliveries.one()
300+ self.assertEqual(delivery.payload, {'some': 'payload'})
301+
302+ # Disabled webhooks aren't triggered.
303+ getUtility(IWebhookSet).trigger(
304+ target2, self.event_type, {'other': 'payload'})
305+ with admin_logged_in():
306+ self.assertThat(list(hook1a.deliveries), HasLength(0))
307+ self.assertThat(list(hook1b.deliveries), HasLength(1))
308+ self.assertThat(list(hook2a.deliveries), HasLength(1))
309+ self.assertThat(list(hook2b.deliveries), HasLength(0))
310+ delivery = hook2a.deliveries.one()
311+ self.assertEqual(delivery.payload, {'other': 'payload'})
312+
313+
314+class TestWebhookSetMergeProposalBase(TestWebhookSetBase):
315+
316 def test__checkVisibility_private_artifact(self):
317 owner = self.factory.makePerson()
318 target = self.makeTarget(
319@@ -248,42 +289,6 @@
320 self.assertFalse(
321 WebhookSet._checkVisibility(mp2, mp2.merge_target.owner))
322
323- def test_trigger(self):
324- owner = self.factory.makePerson()
325- target1 = self.makeTarget(owner=owner)
326- target2 = self.makeTarget(owner=owner)
327- hook1a = self.factory.makeWebhook(
328- target=target1, event_types=[])
329- hook1b = self.factory.makeWebhook(
330- target=target1, event_types=[self.event_type])
331- hook2a = self.factory.makeWebhook(
332- target=target2, event_types=[self.event_type])
333- hook2b = self.factory.makeWebhook(
334- target=target2, event_types=[self.event_type], active=False)
335-
336- # Only webhooks subscribed to the relevant target and event type
337- # are triggered.
338- getUtility(IWebhookSet).trigger(
339- target1, self.event_type, {'some': 'payload'})
340- with admin_logged_in():
341- self.assertThat(list(hook1a.deliveries), HasLength(0))
342- self.assertThat(list(hook1b.deliveries), HasLength(1))
343- self.assertThat(list(hook2a.deliveries), HasLength(0))
344- self.assertThat(list(hook2b.deliveries), HasLength(0))
345- delivery = hook1b.deliveries.one()
346- self.assertEqual(delivery.payload, {'some': 'payload'})
347-
348- # Disabled webhooks aren't triggered.
349- getUtility(IWebhookSet).trigger(
350- target2, self.event_type, {'other': 'payload'})
351- with admin_logged_in():
352- self.assertThat(list(hook1a.deliveries), HasLength(0))
353- self.assertThat(list(hook1b.deliveries), HasLength(1))
354- self.assertThat(list(hook2a.deliveries), HasLength(1))
355- self.assertThat(list(hook2b.deliveries), HasLength(0))
356- delivery = hook2a.deliveries.one()
357- self.assertEqual(delivery.payload, {'other': 'payload'})
358-
359 def test_trigger_skips_invisible(self):
360 # No webhooks are dispatched if the visibility check fails.
361 project = self.factory.makeProduct(
362@@ -344,7 +349,8 @@
363 self.assertEqual(delivery.payload, {'some': 'payload'})
364
365
366-class TestWebhookSetGitRepository(TestWebhookSetBase, TestCaseWithFactory):
367+class TestWebhookSetGitRepository(
368+ TestWebhookSetMergeProposalBase, TestCaseWithFactory):
369
370 event_type = 'git:push:0.1'
371
372@@ -366,7 +372,8 @@
373 reviewer=reviewer)
374
375
376-class TestWebhookSetBranch(TestWebhookSetBase, TestCaseWithFactory):
377+class TestWebhookSetBranch(
378+ TestWebhookSetMergeProposalBase, TestCaseWithFactory):
379
380 event_type = 'bzr:push:0.1'
381
382@@ -384,3 +391,14 @@
383 return self.factory.makeBranchMergeProposal(
384 registrant=owner, target_branch=target, source_branch=source,
385 reviewer=reviewer)
386+
387+
388+class TestWebhookSetSnap(TestWebhookSetBase, TestCaseWithFactory):
389+
390+ event_type = 'snap:build:0.1'
391+
392+ def makeTarget(self, owner=None, **kwargs):
393+ self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: 'true'}))
394+ if owner is None:
395+ owner = self.factory.makePerson()
396+ return self.factory.makeSnap(registrant=owner, owner=owner, **kwargs)
397
398=== modified file 'lib/lp/services/webhooks/tests/test_webservice.py'
399--- lib/lp/services/webhooks/tests/test_webservice.py 2015-10-13 16:58:20 +0000
400+++ lib/lp/services/webhooks/tests/test_webservice.py 2016-02-19 14:20:42 +0000
401@@ -1,4 +1,4 @@
402-# Copyright 2015 Canonical Ltd. This software is licensed under the
403+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
404 # GNU Affero General Public License version 3 (see the file LICENSE).
405
406 """Tests for the webhook webservice objects."""
407@@ -23,6 +23,7 @@
408
409 from lp.services.features.testing import FeatureFixture
410 from lp.services.webapp.interfaces import OAuthPermission
411+from lp.snappy.interfaces.snap import SNAP_FEATURE_FLAG
412 from lp.testing import (
413 api_url,
414 person_logged_in,
415@@ -370,3 +371,13 @@
416
417 def makeTarget(self):
418 return self.factory.makeBranch()
419+
420+
421+class TestWebhookTargetSnap(TestWebhookTargetBase, TestCaseWithFactory):
422+
423+ event_type = 'snap:build:0.1'
424+
425+ def makeTarget(self):
426+ self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: 'true'}))
427+ owner = self.factory.makePerson()
428+ return self.factory.makeSnap(registrant=owner, owner=owner)
429
430=== modified file 'lib/lp/snappy/browser/snap.py'
431--- lib/lp/snappy/browser/snap.py 2016-02-05 06:05:50 +0000
432+++ lib/lp/snappy/browser/snap.py 2016-02-19 14:20:42 +0000
433@@ -1,4 +1,4 @@
434-# Copyright 2015 Canonical Ltd. This software is licensed under the
435+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
436 # GNU Affero General Public License version 3 (see the file LICENSE).
437
438 """Snap views."""
439@@ -65,6 +65,7 @@
440 Breadcrumb,
441 NameBreadcrumb,
442 )
443+from lp.services.webhooks.browser import WebhookTargetNavigationMixin
444 from lp.snappy.browser.widgets.snaparchive import SnapArchiveWidget
445 from lp.snappy.interfaces.snap import (
446 ISnap,
447@@ -82,7 +83,7 @@
448 from lp.soyuz.interfaces.archive import IArchive
449
450
451-class SnapNavigation(Navigation):
452+class SnapNavigation(WebhookTargetNavigationMixin, Navigation):
453 usedfor = ISnap
454
455 @stepthrough('+build')
456@@ -110,7 +111,7 @@
457
458 facet = 'overview'
459
460- links = ('edit', 'delete', 'admin')
461+ links = ('admin', 'edit', 'webhooks', 'delete')
462
463 @enabled_with_permission('launchpad.Admin')
464 def admin(self):
465@@ -121,6 +122,12 @@
466 return Link('+edit', 'Edit snap package', icon='edit')
467
468 @enabled_with_permission('launchpad.Edit')
469+ def webhooks(self):
470+ return Link(
471+ '+webhooks', 'Manage webhooks', icon='edit',
472+ enabled=bool(getFeatureFlag('webhooks.new.enabled')))
473+
474+ @enabled_with_permission('launchpad.Edit')
475 def delete(self):
476 return Link('+delete', 'Delete snap package', icon='trash-icon')
477
478
479=== modified file 'lib/lp/snappy/configure.zcml'
480--- lib/lp/snappy/configure.zcml 2015-09-25 17:26:03 +0000
481+++ lib/lp/snappy/configure.zcml 2016-02-19 14:20:42 +0000
482@@ -1,4 +1,4 @@
483-<!-- Copyright 2015 Canonical Ltd. This software is licensed under the
484+<!-- Copyright 2015-2016 Canonical Ltd. This software is licensed under the
485 GNU Affero General Public License version 3 (see the file LICENSE).
486 -->
487
488@@ -52,6 +52,10 @@
489 permission="launchpad.Admin"
490 interface="lp.snappy.interfaces.snapbuild.ISnapBuildAdmin" />
491 </class>
492+ <subscriber
493+ for="lp.snappy.interfaces.snapbuild.ISnapBuild
494+ lp.snappy.interfaces.snapbuild.ISnapBuildStatusChangedEvent"
495+ handler="lp.snappy.subscribers.snapbuild.snap_build_status_changed" />
496
497 <!-- SnapBuildSet -->
498 <securedutility
499
500=== modified file 'lib/lp/snappy/interfaces/snap.py'
501--- lib/lp/snappy/interfaces/snap.py 2016-02-05 06:05:50 +0000
502+++ lib/lp/snappy/interfaces/snap.py 2016-02-19 14:20:42 +0000
503@@ -1,4 +1,4 @@
504-# Copyright 2015 Canonical Ltd. This software is licensed under the
505+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
506 # GNU Affero General Public License version 3 (see the file LICENSE).
507
508 """Snap package interfaces."""
509@@ -18,6 +18,7 @@
510 'SNAP_FEATURE_FLAG',
511 'SNAP_PRIVATE_FEATURE_FLAG',
512 'SNAP_TESTING_FLAGS',
513+ 'SNAP_WEBHOOKS_FEATURE_FLAG',
514 'SnapBuildAlreadyPending',
515 'SnapBuildArchiveOwnerMismatch',
516 'SnapBuildDisallowedArchitecture',
517@@ -85,18 +86,21 @@
518 PersonChoice,
519 PublicPersonChoice,
520 )
521+from lp.services.webhooks.interfaces import IWebhookTarget
522 from lp.soyuz.interfaces.archive import IArchive
523 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
524
525
526 SNAP_FEATURE_FLAG = u"snap.allow_new"
527 SNAP_PRIVATE_FEATURE_FLAG = u"snap.allow_private"
528+SNAP_WEBHOOKS_FEATURE_FLAG = u"snap.webhooks.enabled"
529
530
531 SNAP_TESTING_FLAGS = {
532 SNAP_FEATURE_FLAG: u"on",
533 SNAP_PRIVATE_FEATURE_FLAG: u"on",
534-}
535+ SNAP_WEBHOOKS_FEATURE_FLAG: u"on",
536+ }
537
538
539 @error_status(httplib.BAD_REQUEST)
540@@ -293,7 +297,7 @@
541 value_type=Reference(schema=Interface), readonly=True)))
542
543
544-class ISnapEdit(Interface):
545+class ISnapEdit(IWebhookTarget):
546 """`ISnap` methods that require launchpad.Edit permission."""
547
548 @export_destructor_operation()
549
550=== modified file 'lib/lp/snappy/interfaces/snapbuild.py'
551--- lib/lp/snappy/interfaces/snapbuild.py 2015-07-23 16:41:12 +0000
552+++ lib/lp/snappy/interfaces/snapbuild.py 2016-02-19 14:20:42 +0000
553@@ -1,4 +1,4 @@
554-# Copyright 2015 Canonical Ltd. This software is licensed under the
555+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
556 # GNU Affero General Public License version 3 (see the file LICENSE).
557
558 """Snap package build interfaces."""
559@@ -8,6 +8,7 @@
560 __all__ = [
561 'ISnapBuild',
562 'ISnapBuildSet',
563+ 'ISnapBuildStatusChangedEvent',
564 'ISnapFile',
565 ]
566
567@@ -20,6 +21,7 @@
568 operation_parameters,
569 )
570 from lazr.restful.fields import Reference
571+from zope.component.interfaces import IObjectEvent
572 from zope.interface import Interface
573 from zope.schema import (
574 Bool,
575@@ -39,6 +41,10 @@
576 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
577
578
579+class ISnapBuildStatusChangedEvent(IObjectEvent):
580+ """The status of a snap package build changed."""
581+
582+
583 class ISnapFile(Interface):
584 """A file produced by a snap package build."""
585
586
587=== modified file 'lib/lp/snappy/model/snap.py'
588--- lib/lp/snappy/model/snap.py 2016-02-05 06:05:50 +0000
589+++ lib/lp/snappy/model/snap.py 2016-02-19 14:20:42 +0000
590@@ -1,4 +1,4 @@
591-# Copyright 2015 Canonical Ltd. This software is licensed under the
592+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
593 # GNU Affero General Public License version 3 (see the file LICENSE).
594
595 __metaclass__ = type
596@@ -71,6 +71,8 @@
597 )
598 from lp.services.features import getFeatureFlag
599 from lp.services.webapp.interfaces import ILaunchBag
600+from lp.services.webhooks.interfaces import IWebhookSet
601+from lp.services.webhooks.model import WebhookTargetMixin
602 from lp.snappy.interfaces.snap import (
603 BadSnapSearchContext,
604 CannotDeleteSnap,
605@@ -110,7 +112,7 @@
606
607
608 @implementer(ISnap, IHasOwner)
609-class Snap(Storm):
610+class Snap(Storm, WebhookTargetMixin):
611 """See `ISnap`."""
612
613 __storm_table__ = 'Snap'
614@@ -169,6 +171,10 @@
615 self.private = private
616
617 @property
618+ def valid_webhook_event_types(self):
619+ return ["snap:build:0.1"]
620+
621+ @property
622 def git_ref(self):
623 """See `ISnap`."""
624 if self.git_repository is not None:
625@@ -353,6 +359,7 @@
626 raise CannotDeleteSnap("Cannot delete a snap package with builds.")
627 store = IStore(Snap)
628 store.find(SnapArch, SnapArch.snap == self).remove()
629+ getUtility(IWebhookSet).delete(self.webhooks)
630 store.remove(self)
631
632
633
634=== modified file 'lib/lp/snappy/model/snapbuild.py'
635--- lib/lp/snappy/model/snapbuild.py 2016-02-04 00:45:12 +0000
636+++ lib/lp/snappy/model/snapbuild.py 2016-02-19 14:20:42 +0000
637@@ -1,4 +1,4 @@
638-# Copyright 2015 Canonical Ltd. This software is licensed under the
639+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
640 # GNU Affero General Public License version 3 (see the file LICENSE).
641
642 __metaclass__ = type
643@@ -22,6 +22,8 @@
644 )
645 from storm.store import EmptyResultSet
646 from zope.component import getUtility
647+from zope.component.interfaces import ObjectEvent
648+from zope.event import notify
649 from zope.interface import implementer
650
651 from lp.app.errors import NotFoundError
652@@ -59,6 +61,7 @@
653 from lp.snappy.interfaces.snapbuild import (
654 ISnapBuild,
655 ISnapBuildSet,
656+ ISnapBuildStatusChangedEvent,
657 ISnapFile,
658 )
659 from lp.snappy.mail.snapbuild import SnapBuildMailer
660@@ -67,6 +70,11 @@
661 from lp.soyuz.model.distroarchseries import DistroArchSeries
662
663
664+@implementer(ISnapBuildStatusChangedEvent)
665+class SnapBuildStatusChangedEvent(ObjectEvent):
666+ """See `ISnapBuildStatusChangedEvent`."""
667+
668+
669 @implementer(ISnapFile)
670 class SnapFile(Storm):
671 """See `ISnap`."""
672@@ -300,6 +308,16 @@
673 """See `IPackageBuild`."""
674 return not self.getFiles().is_empty()
675
676+ def updateStatus(self, status, builder=None, slave_status=None,
677+ date_started=None, date_finished=None,
678+ force_invalid_transition=False):
679+ """See `IBuildFarmJob`."""
680+ super(SnapBuild, self).updateStatus(
681+ status, builder=builder, slave_status=slave_status,
682+ date_started=date_started, date_finished=date_finished,
683+ force_invalid_transition=force_invalid_transition)
684+ notify(SnapBuildStatusChangedEvent(self))
685+
686 def notify(self, extra_info=None):
687 """See `IPackageBuild`."""
688 if not config.builddmaster.send_build_notification:
689
690=== added directory 'lib/lp/snappy/subscribers'
691=== added file 'lib/lp/snappy/subscribers/__init__.py'
692=== added file 'lib/lp/snappy/subscribers/snapbuild.py'
693--- lib/lp/snappy/subscribers/snapbuild.py 1970-01-01 00:00:00 +0000
694+++ lib/lp/snappy/subscribers/snapbuild.py 2016-02-19 14:20:42 +0000
695@@ -0,0 +1,30 @@
696+# Copyright 2016 Canonical Ltd. This software is licensed under the
697+# GNU Affero General Public License version 3 (see the file LICENSE).
698+
699+"""Event subscribers for snap builds."""
700+
701+from __future__ import absolute_import, print_function, unicode_literals
702+
703+__metaclass__ = type
704+
705+from zope.component import getUtility
706+
707+from lp.services.features import getFeatureFlag
708+from lp.services.webapp.publisher import canonical_url
709+from lp.services.webhooks.interfaces import IWebhookSet
710+from lp.services.webhooks.payload import compose_webhook_payload
711+from lp.snappy.interfaces.snap import SNAP_WEBHOOKS_FEATURE_FLAG
712+from lp.snappy.interfaces.snapbuild import ISnapBuild
713+
714+
715+def snap_build_status_changed(snapbuild, event):
716+ """Trigger webhooks when snap package build statuses change."""
717+ if getFeatureFlag(SNAP_WEBHOOKS_FEATURE_FLAG):
718+ payload = {
719+ "snap_build": canonical_url(snapbuild, force_local_path=True),
720+ "action": "status-changed",
721+ }
722+ payload.update(compose_webhook_payload(
723+ ISnapBuild, snapbuild, ["snap", "status"]))
724+ getUtility(IWebhookSet).trigger(
725+ snapbuild.snap, "snap:build:0.1", payload)
726
727=== modified file 'lib/lp/snappy/tests/test_snap.py'
728--- lib/lp/snappy/tests/test_snap.py 2016-02-05 06:05:50 +0000
729+++ lib/lp/snappy/tests/test_snap.py 2016-02-19 14:20:42 +0000
730@@ -1,4 +1,4 @@
731-# Copyright 2015 Canonical Ltd. This software is licensed under the
732+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
733 # GNU Affero General Public License version 3 (see the file LICENSE).
734
735 """Test snap packages."""
736@@ -8,6 +8,7 @@
737 from datetime import timedelta
738
739 from lazr.lifecycle.event import ObjectModifiedEvent
740+from storm.exceptions import LostObjectError
741 from storm.locals import Store
742 from testtools.matchers import Equals
743 import transaction
744@@ -371,6 +372,16 @@
745 self.assertRaises(CannotDeleteSnap, snap.destroySelf)
746 self.assertTrue(getUtility(ISnapSet).exists(owner, u"condemned"))
747
748+ def test_related_webhooks_deleted(self):
749+ owner = self.factory.makePerson()
750+ snap = self.factory.makeSnap(registrant=owner, owner=owner)
751+ webhook = self.factory.makeWebhook(target=snap)
752+ with person_logged_in(snap.owner):
753+ webhook.ping()
754+ snap.destroySelf()
755+ transaction.commit()
756+ self.assertRaises(LostObjectError, getattr, webhook, "target")
757+
758
759 class TestSnapSet(TestCaseWithFactory):
760
761
762=== modified file 'lib/lp/snappy/tests/test_snapbuild.py'
763--- lib/lp/snappy/tests/test_snapbuild.py 2016-02-06 00:12:30 +0000
764+++ lib/lp/snappy/tests/test_snapbuild.py 2016-02-19 14:20:42 +0000
765@@ -15,6 +15,11 @@
766 )
767
768 import pytz
769+from testtools.matchers import (
770+ Equals,
771+ MatchesDict,
772+ MatchesStructure,
773+ )
774 from zope.component import getUtility
775 from zope.security.proxy import removeSecurityProxy
776
777@@ -29,6 +34,7 @@
778 from lp.services.features.testing import FeatureFixture
779 from lp.services.librarian.browser import ProxiedLibraryFileAlias
780 from lp.services.webapp.interfaces import OAuthPermission
781+from lp.services.webapp.publisher import canonical_url
782 from lp.snappy.interfaces.snap import (
783 SNAP_TESTING_FLAGS,
784 SnapFeatureDisabled,
785@@ -218,6 +224,25 @@
786 self.factory.makeSnapFile(snapbuild=self.build)
787 self.assertTrue(self.build.verifySuccessfulUpload())
788
789+ def test_updateStatus_triggers_webhooks(self):
790+ # Updating the status of a SnapBuild triggers webhooks on the
791+ # corresponding Snap.
792+ hook = self.factory.makeWebhook(
793+ target=self.build.snap, event_types=["snap:build:0.1"])
794+ self.build.updateStatus(BuildStatus.FULLYBUILT)
795+ expected_payload = {
796+ "snap_build": Equals(
797+ canonical_url(self.build, force_local_path=True)),
798+ "action": Equals("status-changed"),
799+ "snap": Equals(
800+ canonical_url(self.build.snap, force_local_path=True)),
801+ "status": Equals("Successfully built"),
802+ }
803+ self.assertThat(
804+ hook.deliveries.one(), MatchesStructure(
805+ event_type=Equals("snap:build:0.1"),
806+ payload=MatchesDict(expected_payload)))
807+
808 def test_notify_fullybuilt(self):
809 # notify does not send mail when a SnapBuild completes normally.
810 person = self.factory.makePerson(name="person")