← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/bmp-webhooks into lp:launchpad with lp:~cjwatson/launchpad/no-excess-aags as a prerequisite.

Commit message:
Add webhook support for merge proposals.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

Add webhook support for merge proposals.  They are only dispatched if the webhook owner can see both the source and the target.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/bmp-webhooks into lp:launchpad.
=== modified file 'lib/lp/code/adapters/branch.py'
--- lib/lp/code/adapters/branch.py	2015-07-08 16:05:11 +0000
+++ lib/lp/code/adapters/branch.py	2015-10-09 16:59:27 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Components related to branches."""
@@ -23,6 +23,7 @@
     IBranchDelta,
     )
 from lp.code.interfaces.branchmergeproposal import IBranchMergeProposal
+from lp.services.webhooks.payload import compose_webhook_payload
 
 # XXX: thumper 2006-12-20: This needs to be extended
 # to cover bugs and specs linked and unlinked, as
@@ -80,8 +81,11 @@
     delta_values = (
         'registrant',
         'source_branch',
+        'source_git_ref',
         'target_branch',
+        'target_git_ref',
         'prerequisite_branch',
+        'prerequisite_git_ref',
         'queue_status',
         )
     new_values = (
@@ -111,6 +115,17 @@
         return BranchMergeProposalDelta(**delta.changes)
 
     @classmethod
+    def composeWebhookPayload(klass, old_merge_proposal, new_merge_proposal):
+        """Return part of a webhook payload representing the differences."""
+        return {
+            "old": compose_webhook_payload(
+                IBranchMergeProposal, old_merge_proposal, klass.delta_values),
+            "new": compose_webhook_payload(
+                IBranchMergeProposal, new_merge_proposal,
+                klass.delta_values + klass.new_values),
+            }
+
+    @classmethod
     def snapshot(klass, merge_proposal):
         """Return a snapshot suitable for use with construct.
 

=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml	2015-10-06 16:09:06 +0000
+++ lib/lp/code/configure.zcml	2015-10-09 16:59:27 +0000
@@ -291,6 +291,10 @@
       handler="lp.code.subscribers.branchmergeproposal.merge_proposal_modified"/>
   <subscriber
       for="lp.code.interfaces.branchmergeproposal.IBranchMergeProposal
+           lazr.lifecycle.interfaces.IObjectDeletedEvent"
+      handler="lp.code.subscribers.branchmergeproposal.merge_proposal_deleted"/>
+  <subscriber
+      for="lp.code.interfaces.branchmergeproposal.IBranchMergeProposal
            lazr.lifecycle.interfaces.IObjectCreatedEvent"
       handler="lp.code.subscribers.karma.branch_merge_proposed"/>
   <subscriber

=== modified file 'lib/lp/code/interfaces/branchmergeproposal.py'
--- lib/lp/code/interfaces/branchmergeproposal.py	2015-06-12 03:48:40 +0000
+++ lib/lp/code/interfaces/branchmergeproposal.py	2015-10-09 16:59:27 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """The interface for branch merge proposals."""
@@ -7,6 +7,7 @@
 __all__ = [
     'BRANCH_MERGE_PROPOSAL_FINAL_STATES',
     'BRANCH_MERGE_PROPOSAL_OBSOLETE_STATES',
+    'BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG',
     'IBranchMergeProposal',
     'IBranchMergeProposalGetter',
     'IBranchMergeProposalJob',
@@ -104,6 +105,10 @@
     )
 
 
+BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG = (
+    "merge_proposals.webhooks.enabled")
+
+
 class IBranchMergeProposalPublic(IPrivacy):
 
     id = Int(

=== modified file 'lib/lp/code/model/branch.py'
--- lib/lp/code/model/branch.py	2015-10-06 16:08:27 +0000
+++ lib/lp/code/model/branch.py	2015-10-09 16:59:27 +0000
@@ -206,6 +206,10 @@
 
     @property
     def valid_webhook_event_types(self):
+        return ["bzr:push:0.1", "merge-proposal:0.1"]
+
+    @property
+    def default_webhook_event_types(self):
         return ["bzr:push:0.1"]
 
     @property

=== modified file 'lib/lp/code/model/branchmergeproposal.py'
--- lib/lp/code/model/branchmergeproposal.py	2015-10-06 16:09:06 +0000
+++ lib/lp/code/model/branchmergeproposal.py	2015-10-09 16:59:27 +0000
@@ -12,7 +12,10 @@
 
 from email.utils import make_msgid
 
-from lazr.lifecycle.event import ObjectCreatedEvent
+from lazr.lifecycle.event import (
+    ObjectCreatedEvent,
+    ObjectDeletedEvent,
+    )
 from sqlobject import (
     ForeignKey,
     IntCol,
@@ -777,6 +780,7 @@
             branch_merge_proposal=self.id):
             job.destroySelf()
         self._preview_diffs.remove()
+        notify(ObjectDeletedEvent(self))
         self.destroySelf()
 
     def getUnlandedSourceBranchRevisions(self):

=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	2015-10-05 11:11:47 +0000
+++ lib/lp/code/model/gitrepository.py	2015-10-09 16:59:27 +0000
@@ -236,6 +236,10 @@
 
     @property
     def valid_webhook_event_types(self):
+        return ["git:push:0.1", "merge-proposal:0.1"]
+
+    @property
+    def default_webhook_event_types(self):
         return ["git:push:0.1"]
 
     # Marker for references to Git URL layouts: ##GITNAMESPACE##

=== modified file 'lib/lp/code/model/tests/test_branchmergeproposal.py'
--- lib/lp/code/model/tests/test_branchmergeproposal.py	2015-10-06 16:09:06 +0000
+++ lib/lp/code/model/tests/test_branchmergeproposal.py	2015-10-09 16:59:27 +0000
@@ -20,12 +20,18 @@
 from pytz import UTC
 from sqlobject import SQLObjectNotFound
 from storm.locals import Store
+from testtools.matchers import (
+    Equals,
+    MatchesDict,
+    MatchesStructure,
+    )
 import transaction
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
 from lp.app.interfaces.launchpad import IPrivacy
+from lp.code.adapters.branch import BranchMergeProposalNoPreviewDiffDelta
 from lp.code.enums import (
     BranchMergeProposalStatus,
     BranchSubscriptionDiffSize,
@@ -46,6 +52,7 @@
 from lp.code.interfaces.branchmergeproposal import (
     BRANCH_MERGE_PROPOSAL_FINAL_STATES as FINAL_STATES,
     BRANCH_MERGE_PROPOSAL_OBSOLETE_STATES as OBSOLETE_STATES,
+    BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG,
     IBranchMergeProposal,
     IBranchMergeProposalGetter,
     notify_modified,
@@ -67,6 +74,7 @@
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.product import IProductSet
 from lp.services.database.constants import UTC_NOW
+from lp.services.features.testing import FeatureFixture
 from lp.services.webapp import canonical_url
 from lp.testing import (
     ExpectedException,
@@ -921,6 +929,143 @@
         self.assertIn(charlie, recipients)
 
 
+class TestMergeProposalWebhooksMixin:
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestMergeProposalWebhooksMixin, self).setUp()
+        self.useFixture(FeatureFixture(
+            {BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG: "on"}))
+
+    @staticmethod
+    def getURL(obj):
+        if obj is not None:
+            return canonical_url(obj, force_local_path=True)
+        else:
+            return None
+
+    @classmethod
+    def getExpectedPayload(cls, proposal, include_new_values=True):
+        payload = {
+            "registrant": "/~%s" % proposal.registrant.name,
+            "source_branch": cls.getURL(proposal.source_branch),
+            "source_git_ref": cls.getURL(proposal.source_git_ref),
+            "target_branch": cls.getURL(proposal.target_branch),
+            "target_git_ref": cls.getURL(proposal.target_git_ref),
+            "prerequisite_branch": cls.getURL(proposal.prerequisite_branch),
+            "prerequisite_git_ref": cls.getURL(proposal.prerequisite_git_ref),
+            "queue_status": proposal.queue_status.title,
+            }
+        if include_new_values:
+            payload.update({
+                "commit_message": proposal.commit_message,
+                "whiteboard": proposal.whiteboard,
+                "description": proposal.description,
+                "preview_diff": cls.getURL(proposal.preview_diff),
+                })
+        return {k: Equals(v) for k, v in payload.items()}
+
+    def assertCorrectDelivery(self, expected_payload, delivery):
+        self.assertThat(
+            delivery, MatchesStructure(
+                event_type=Equals("merge-proposal:0.1"),
+                payload=MatchesDict(expected_payload)))
+
+    def test_create_triggers_webhooks(self):
+        # When a merge proposal is created, any relevant webhooks are
+        # triggered.
+        source = self.makeBranch()
+        target = self.makeBranch(same_target_as=source)
+        registrant = self.factory.makePerson()
+        hook = self.factory.makeWebhook(
+            target=self.getWebhookTarget(target),
+            event_types=["merge-proposal:0.1"])
+        proposal = source.addLandingTarget(
+            registrant, target, needs_review=True)
+        login_person(target.owner)
+        delivery = hook.deliveries.one()
+        expected_payload = {
+            "merge_proposal": Equals(self.getURL(proposal)),
+            "action": Equals("created"),
+            }
+        expected_payload.update(self.getExpectedPayload(proposal))
+        self.assertCorrectDelivery(expected_payload, delivery)
+
+    def test_modify_triggers_webhooks(self):
+        # When an existing merge proposal is modified, any relevant webhooks
+        # are triggered.
+        source = self.makeBranch()
+        target = self.makeBranch(same_target_as=source)
+        registrant = self.factory.makePerson()
+        proposal = source.addLandingTarget(
+            registrant, target, needs_review=True)
+        hook = self.factory.makeWebhook(
+            target=self.getWebhookTarget(target),
+            event_types=["merge-proposal:0.1"])
+        login_person(target.owner)
+        expected_payload = {
+            "merge_proposal": Equals(self.getURL(proposal)),
+            "action": Equals("modified"),
+            }
+        expected_payload["old"] = MatchesDict(
+            self.getExpectedPayload(proposal, include_new_values=False))
+        with BranchMergeProposalNoPreviewDiffDelta.monitor(proposal):
+            proposal.setStatus(
+                BranchMergeProposalStatus.CODE_APPROVED, user=target.owner)
+            proposal.description = "An excellent proposal."
+        expected_payload["new"] = MatchesDict(
+            self.getExpectedPayload(proposal))
+        delivery = hook.deliveries.one()
+        self.assertCorrectDelivery(expected_payload, delivery)
+
+    def test_delete_triggers_webhooks(self):
+        # When an existing merge proposal is deleted, any relevant webhooks
+        # are triggered.
+        source = self.makeBranch()
+        target = self.makeBranch(same_target_as=source)
+        registrant = self.factory.makePerson()
+        proposal = source.addLandingTarget(
+            registrant, target, needs_review=True)
+        hook = self.factory.makeWebhook(
+            target=self.getWebhookTarget(target),
+            event_types=["merge-proposal:0.1"])
+        login_person(target.owner)
+        expected_payload = {
+            "merge_proposal": Equals(self.getURL(proposal)),
+            "action": Equals("deleted"),
+            }
+        proposal.deleteProposal()
+        delivery = hook.deliveries.one()
+        self.assertCorrectDelivery(expected_payload, delivery)
+
+
+class TestMergeProposalWebhooksBzr(
+    TestMergeProposalWebhooksMixin, TestCaseWithFactory):
+
+    def makeBranch(self, same_target_as=None):
+        kwargs = {}
+        if same_target_as is not None:
+            kwargs["product"] = same_target_as.product
+        return self.factory.makeProductBranch(**kwargs)
+
+    def getWebhookTarget(self, branch):
+        return branch
+
+
+class TestMergeProposalWebhooksGit(
+    TestMergeProposalWebhooksMixin, TestCaseWithFactory):
+
+    def makeBranch(self, same_target_as=None):
+        kwargs = {}
+        if same_target_as is not None:
+            kwargs["target"] = same_target_as.target
+        return self.factory.makeGitRefs(**kwargs)[0]
+
+    def getWebhookTarget(self, branch):
+        return branch.repository
+
+
 class TestGetAddress(TestCaseWithFactory):
     """Test that the address property gives expected results."""
 

=== modified file 'lib/lp/code/subscribers/branchmergeproposal.py'
--- lib/lp/code/subscribers/branchmergeproposal.py	2015-10-06 15:15:45 +0000
+++ lib/lp/code/subscribers/branchmergeproposal.py	2015-10-09 16:59:27 +0000
@@ -5,20 +5,42 @@
 
 __metaclass__ = type
 
-
 from zope.component import getUtility
 from zope.principalregistry.principalregistry import UnauthenticatedPrincipal
 
-from lp.code.adapters.branch import BranchMergeProposalNoPreviewDiffDelta
+from lp.code.adapters.branch import (
+    BranchMergeProposalDelta,
+    BranchMergeProposalNoPreviewDiffDelta,
+    )
 from lp.code.enums import BranchMergeProposalStatus
 from lp.code.interfaces.branchmergeproposal import (
+    BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG,
+    IBranchMergeProposal,
     IMergeProposalNeedsReviewEmailJobSource,
     IMergeProposalUpdatedEmailJobSource,
     IReviewRequestedEmailJobSource,
     IUpdatePreviewDiffJobSource,
     )
 from lp.registry.interfaces.person import IPerson
+from lp.services.features import getFeatureFlag
 from lp.services.utils import text_delta
+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_webhook(merge_proposal, payload):
+    payload = dict(payload)
+    payload["merge_proposal"] = canonical_url(
+        merge_proposal, force_local_path=True)
+    if merge_proposal.target_branch is not None:
+        source = merge_proposal.source_branch
+        target = merge_proposal.target_branch
+    else:
+        source = merge_proposal.source_git_repository
+        target = merge_proposal.target_git_repository
+    getUtility(IWebhookSet).trigger(
+        target, "merge-proposal:0.1", payload, source=source)
 
 
 def merge_proposal_created(merge_proposal, event):
@@ -28,6 +50,13 @@
     Also create a job to email the subscribers about the new proposal.
     """
     getUtility(IUpdatePreviewDiffJobSource).create(merge_proposal)
+    if getFeatureFlag(BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG):
+        payload = {"action": "created"}
+        payload.update(compose_webhook_payload(
+            IBranchMergeProposal, merge_proposal,
+            BranchMergeProposalDelta.delta_values +
+                BranchMergeProposalDelta.new_values))
+        _trigger_webhook(merge_proposal, payload)
 
 
 def merge_proposal_needs_review(merge_proposal, event):
@@ -49,9 +78,6 @@
         from_person = None
     else:
         from_person = IPerson(event.user)
-    # If the merge proposal was work in progress and is now needs review,
-    # then we don't want to send out an email as the needs review email will
-    # cover that.
     old_status = event.object_before_modification.queue_status
     new_status = merge_proposal.queue_status
 
@@ -59,20 +85,26 @@
         BranchMergeProposalStatus.WORK_IN_PROGRESS,
         BranchMergeProposalStatus.NEEDS_REVIEW)
 
-    if (old_status == BranchMergeProposalStatus.WORK_IN_PROGRESS and
-            new_status in in_progress_states):
-        return
-    # Create a delta of the changes.  If there are no changes to report, then
-    # we're done.
-    delta = BranchMergeProposalNoPreviewDiffDelta.construct(
-        event.object_before_modification, merge_proposal)
-    if delta is None:
-        return
-    changes = text_delta(
-        delta, delta.delta_values, delta.new_values, delta.interface)
-    # Now create the job to send the email.
-    getUtility(IMergeProposalUpdatedEmailJobSource).create(
-        merge_proposal, changes, from_person)
+    # If the merge proposal was work in progress and is now needs review,
+    # then we don't want to send out an email as the needs review email will
+    # cover that.
+    if (old_status != BranchMergeProposalStatus.WORK_IN_PROGRESS or
+            new_status not in in_progress_states):
+        # Create a delta of the changes.  If there are no changes to report,
+        # then we're done.
+        delta = BranchMergeProposalNoPreviewDiffDelta.construct(
+            event.object_before_modification, merge_proposal)
+        if delta is not None:
+            changes = text_delta(
+                delta, delta.delta_values, delta.new_values, delta.interface)
+            # Now create the job to send the email.
+            getUtility(IMergeProposalUpdatedEmailJobSource).create(
+                merge_proposal, changes, from_person)
+    if getFeatureFlag(BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG):
+        payload = {"action": "modified"}
+        payload.update(BranchMergeProposalDelta.composeWebhookPayload(
+            event.object_before_modification, merge_proposal))
+        _trigger_webhook(merge_proposal, payload)
 
 
 def review_requested(vote_reference, event):
@@ -81,3 +113,12 @@
     bmp_status = vote_reference.branch_merge_proposal.queue_status
     if bmp_status != BranchMergeProposalStatus.WORK_IN_PROGRESS:
         getUtility(IReviewRequestedEmailJobSource).create(vote_reference)
+
+
+def merge_proposal_deleted(merge_proposal, event):
+    """A merge proposal has been deleted."""
+    if getFeatureFlag(BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG):
+        # The merge proposal link will be invalid by the time the webhook is
+        # delivered, but this may still be useful for endpoints that might
+        # e.g. want to cancel CI jobs in flight.
+        _trigger_webhook(merge_proposal, {"action": "deleted"})

=== modified file 'lib/lp/services/webhooks/configure.zcml'
--- lib/lp/services/webhooks/configure.zcml	2015-10-01 12:24:36 +0000
+++ lib/lp/services/webhooks/configure.zcml	2015-10-09 16:59:27 +0000
@@ -61,6 +61,13 @@
         factory="lp.services.webhooks.client.WebhookClient"
         permission="zope.Public"/>
 
+    <adapter
+        for="zope.interface.Interface
+             lp.services.webhooks.interfaces.IWebhookPayloadRequest"
+        provides="zope.traversing.browser.interfaces.IAbsoluteURL"
+        factory="lp.services.webhooks.payload.WebhookAbsoluteURL"
+        />
+
     <browser:url
        for="lp.services.webhooks.interfaces.IWebhook"
        path_expression="string:+webhook/${id}"

=== modified file 'lib/lp/services/webhooks/interfaces.py'
--- lib/lp/services/webhooks/interfaces.py	2015-10-05 11:11:47 +0000
+++ lib/lp/services/webhooks/interfaces.py	2015-10-09 16:59:27 +0000
@@ -13,6 +13,7 @@
     'IWebhookDeliveryJobSource',
     'IWebhookJob',
     'IWebhookJobSource',
+    'IWebhookPayloadRequest',
     'IWebhookSet',
     'IWebhookTarget',
     'WEBHOOK_EVENT_TYPES',
@@ -65,6 +66,7 @@
     IJobSource,
     IRunnableJob,
     )
+from lp.services.webapp.interfaces import ILaunchpadBrowserApplicationRequest
 from lp.services.webservice.apihelpers import (
     patch_collection_property,
     patch_entry_return_type,
@@ -75,6 +77,7 @@
 WEBHOOK_EVENT_TYPES = {
     "bzr:push:0.1": "Bazaar push",
     "git:push:0.1": "Git push",
+    "merge-proposal:0.1": "Merge proposal",
     }
 
 
@@ -199,7 +202,7 @@
     def findByTarget(target):
         """Find all webhooks for the given target."""
 
-    def trigger(target, event_type, payload):
+    def trigger(target, event_type, payload, source=None):
         """Trigger subscribed webhooks to deliver a payload."""
 
 
@@ -359,6 +362,11 @@
         header will be sent using HMAC-SHA1.
         """
 
+
+class IWebhookPayloadRequest(ILaunchpadBrowserApplicationRequest):
+    """An internal fake request used while composing webhook payloads."""
+
+
 patch_collection_property(IWebhook, 'deliveries', IWebhookDeliveryJob)
 patch_entry_return_type(IWebhook, 'ping', IWebhookDeliveryJob)
 patch_reference_property(IWebhook, 'target', IWebhookTarget)

=== modified file 'lib/lp/services/webhooks/model.py'
--- lib/lp/services/webhooks/model.py	2015-10-05 11:11:47 +0000
+++ lib/lp/services/webhooks/model.py	2015-10-09 16:59:27 +0000
@@ -204,10 +204,47 @@
         return IStore(Webhook).find(Webhook, target_filter).order_by(
             Webhook.id)
 
-    def trigger(self, target, event_type, payload):
-        # XXX wgrant 2015-08-10: Two INSERTs and one celery submission
-        # for each webhook, but the set should be small and we'd have to
-        # defer the triggering itself to a job to fix it.
+    @classmethod
+    def _checkVisibility(cls, target, source=None):
+        """Check visibility of webhook context objects.
+
+        In order to be able to dispatch a webhook without disclosing
+        unauthorised information, the webhook owner (currently always equal
+        to the webhook target owner) must be able to see the webhook target.
+        If deliveries are being triggered due to a change to some different
+        source object, then the webhook owner must also be able to see that
+        source.
+
+        :return: True if all objects are visible to the webhook owner,
+            otherwise False.
+        """
+        from lp.code.interfaces.branch import IBranch
+        from lp.code.interfaces.gitrepository import IGitRepository
+        owner = removeSecurityProxy(target).owner
+        if IGitRepository.providedBy(target):
+            if not removeSecurityProxy(target).visibleByUser(owner):
+                return False
+            if source is not None:
+                assert IGitRepository.providedBy(source)
+                if not removeSecurityProxy(source).visibleByUser(owner):
+                    return False
+        elif IBranch.providedBy(target):
+            if not removeSecurityProxy(target).visibleByUser(owner):
+                return False
+            if source is not None:
+                assert IBranch.providedBy(source)
+                if not removeSecurityProxy(source).visibleByUser(owner):
+                    return False
+        else:
+            raise AssertionError("Unsupported target: %r" % (target,))
+        return True
+
+    def trigger(self, target, event_type, payload, source=None):
+        if not self._checkVisibility(target, source=source):
+            return
+        # XXX wgrant 2015-08-10: Two INSERTs and one celery submission for
+        # each webhook, but the set should be small and we'd have to defer
+        # the triggering itself to a job to fix it.
         for webhook in self.findByTarget(target):
             if webhook.active and event_type in webhook.event_types:
                 WebhookDeliveryJob.create(webhook, event_type, payload)

=== added file 'lib/lp/services/webhooks/payload.py'
--- lib/lp/services/webhooks/payload.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/webhooks/payload.py	2015-10-09 16:59:27 +0000
@@ -0,0 +1,73 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+__all__ = [
+    'compose_webhook_payload',
+    'WebhookAbsoluteURL',
+    'WebhookPayloadRequest',
+    ]
+
+import StringIO
+
+from lazr.restful.interfaces import IFieldMarshaller
+from zope.component import getMultiAdapter
+from zope.interface import implementer
+from zope.traversing.browser.interfaces import IAbsoluteURL
+
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webapp.servers import LaunchpadBrowserRequest
+from lp.services.webhooks.interfaces import IWebhookPayloadRequest
+
+
+@implementer(IWebhookPayloadRequest)
+class WebhookPayloadRequest(LaunchpadBrowserRequest):
+    """An internal fake request used while composing webhook payloads."""
+
+    def __init__(self):
+        super(WebhookPayloadRequest, self).__init__(StringIO.StringIO(), {})
+
+
+@implementer(IAbsoluteURL)
+class WebhookAbsoluteURL:
+    """A variant of CanonicalAbsoluteURL that always forces a local path."""
+
+    def __init__(self, context, request):
+        """Initialize with respect to a context and request."""
+        self.context = context
+        self.request = request
+
+    def __unicode__(self):
+        """Returns the URL as a unicode string."""
+        raise NotImplementedError()
+
+    def __str__(self):
+        """Returns an ASCII string with all unicode characters url quoted."""
+        return canonical_url(self.context, force_local_path=True)
+
+    def __repr__(self):
+        """Get a string representation """
+        raise NotImplementedError()
+
+    __call__ = __str__
+
+
+def compose_webhook_payload(interface, obj, names):
+    """Compose a webhook payload dictionary from some fields of an object.
+
+    Fields are serialised in the same way that lazr.restful does for
+    webservice-exported objects, except that object paths are always local.
+
+    :param interface: The interface of the object to serialise.
+    :param obj: The object to serialise.
+    :param names: A list of fields from `obj` to serialise.
+    """
+    payload = {}
+    request = WebhookPayloadRequest()
+    for name in names:
+        field = interface[name]
+        marshaller = getMultiAdapter((field, request), IFieldMarshaller)
+        value = getattr(obj, name, None)
+        payload[name] = marshaller.unmarshall(field, value)
+    return payload

=== modified file 'lib/lp/services/webhooks/tests/test_browser.py'
--- lib/lp/services/webhooks/tests/test_browser.py	2015-10-01 12:24:36 +0000
+++ lib/lp/services/webhooks/tests/test_browser.py	2015-10-09 16:59:27 +0000
@@ -52,7 +52,10 @@
 class GitRepositoryTestHelpers:
 
     event_type = "git:push:0.1"
-    expected_event_types = [("git:push:0.1", "Git push")]
+    expected_event_types = [
+        ("git:push:0.1", "Git push"),
+        ("merge-proposal:0.1", "Merge proposal"),
+        ]
 
     def makeTarget(self):
         return self.factory.makeGitRepository()
@@ -61,7 +64,10 @@
 class BranchTestHelpers:
 
     event_type = "bzr:push:0.1"
-    expected_event_types = [("bzr:push:0.1", "Bazaar push")]
+    expected_event_types = [
+        ("bzr:push:0.1", "Bazaar push"),
+        ("merge-proposal:0.1", "Merge proposal"),
+        ]
 
     def makeTarget(self):
         return self.factory.makeBranch()

=== modified file 'lib/lp/services/webhooks/tests/test_model.py'
--- lib/lp/services/webhooks/tests/test_model.py	2015-09-24 13:44:02 +0000
+++ lib/lp/services/webhooks/tests/test_model.py	2015-10-09 16:59:27 +0000
@@ -13,13 +13,18 @@
 from zope.event import notify
 from zope.security.checker import getChecker
 
+from lp.app.enums import InformationType
+from lp.registry.enums import BranchSharingPolicy
 from lp.services.database.interfaces import IStore
 from lp.services.webapp.authorization import check_permission
 from lp.services.webhooks.interfaces import (
     IWebhook,
     IWebhookSet,
     )
-from lp.services.webhooks.model import WebhookJob
+from lp.services.webhooks.model import (
+    WebhookJob,
+    WebhookSet,
+    )
 from lp.testing import (
     admin_logged_in,
     anonymous_logged_in,
@@ -193,6 +198,50 @@
         self.assertEqual(1, IStore(WebhookJob).find(WebhookJob).count())
         self.assertEqual(1, hooks[2].deliveries.count())
 
+    def test__checkVisibility_public_artifact(self):
+        target = self.makeTarget()
+        login_person(target.owner)
+        self.assertTrue(WebhookSet._checkVisibility(target))
+
+    def test__checkVisibility_private_artifact(self):
+        owner = self.factory.makePerson()
+        target = self.makeTarget(
+            owner=owner, information_type=InformationType.PROPRIETARY)
+        login_person(owner)
+        self.assertTrue(WebhookSet._checkVisibility(target))
+
+    def test__checkVisibility_lost_access_to_private_artifact(self):
+        # A user may lose access to a private artifact even if they own it,
+        # for example if they leave the team that has a policy grant for
+        # branches on that project; in such cases they should stop receiving
+        # webhooks.
+        project = self.factory.makeProduct(
+            branch_sharing_policy=BranchSharingPolicy.PROPRIETARY)
+        grantee_team = self.factory.makeTeam()
+        policy = self.factory.makeAccessPolicy(
+            pillar=project, check_existing=True)
+        self.factory.makeAccessPolicyGrant(
+            policy=policy, grantee=grantee_team)
+        grantee_member = self.factory.makePerson(member_of=[grantee_team])
+        target = self.makeTarget(owner=grantee_member, project=project)
+        login_person(grantee_member)
+        self.assertTrue(WebhookSet._checkVisibility(target))
+        grantee_member.leave(grantee_team)
+        self.assertFalse(WebhookSet._checkVisibility(target))
+
+    def test__checkVisibility_with_source(self):
+        project = self.factory.makeProduct(
+            branch_sharing_policy=BranchSharingPolicy.PUBLIC_OR_PROPRIETARY)
+        owner = self.factory.makePerson()
+        source = self.makeTarget(
+            owner=owner, project=project,
+            information_type=InformationType.PROPRIETARY)
+        target1 = self.makeTarget(owner=owner, project=project)
+        target2 = self.makeTarget(project=project)
+        login_person(owner)
+        self.assertTrue(WebhookSet._checkVisibility(target1, source=source))
+        self.assertFalse(WebhookSet._checkVisibility(target2, source=source))
+
     def test_trigger(self):
         owner = self.factory.makePerson()
         target1 = self.makeTarget(owner=owner)
@@ -229,18 +278,71 @@
             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(
+            branch_sharing_policy=BranchSharingPolicy.PUBLIC_OR_PROPRIETARY)
+        owner = self.factory.makePerson()
+        source = self.makeTarget(
+            owner=owner, project=project,
+            information_type=InformationType.PROPRIETARY)
+        target1 = self.makeTarget(project=project)
+        target2 = self.makeTarget(owner=owner, project=project)
+        event_type = 'merge-proposal:0.1'
+        hook1 = self.factory.makeWebhook(
+            target=target1, event_types=[event_type])
+        hook2 = self.factory.makeWebhook(
+            target=target2, event_types=[event_type])
+
+        # The owner of target1 cannot see source, so their webhook is kept
+        # in the dark too.
+        getUtility(IWebhookSet).trigger(
+            target1, event_type, {'some': 'payload'}, source=source)
+        with admin_logged_in():
+            self.assertThat(list(hook1.deliveries), HasLength(0))
+            self.assertThat(list(hook2.deliveries), HasLength(0))
+
+        # The owner of target2 can see source, so it's OK to tell their
+        # webhook about the existence of source.
+        getUtility(IWebhookSet).trigger(
+            target2, event_type, {'some': 'payload'}, source=source)
+        with admin_logged_in():
+            self.assertThat(list(hook1.deliveries), HasLength(0))
+            self.assertThat(list(hook2.deliveries), HasLength(1))
+            delivery = hook2.deliveries.one()
+            self.assertEqual(delivery.payload, {'some': 'payload'})
+
+    def test_trigger_different_source_and_target_owners(self):
+        # Only people who can edit the webhook target can view the webhook,
+        # so, for a merge proposal between two branches with different
+        # owners, the owner of the merge source will in general not be able
+        # to see a webhook attached to the target.  trigger copes with this.
+        project = self.factory.makeProduct()
+        source = self.makeTarget(project=project)
+        target = self.makeTarget(project=project)
+        event_type = 'merge-proposal:0.1'
+        hook = self.factory.makeWebhook(
+            target=target, event_types=[event_type])
+        login_person(source.owner)
+        getUtility(IWebhookSet).trigger(
+            target, event_type, {'some': 'payload'}, source=source)
+        with admin_logged_in():
+            self.assertThat(list(hook.deliveries), HasLength(1))
+            delivery = hook.deliveries.one()
+            self.assertEqual(delivery.payload, {'some': 'payload'})
+
 
 class TestWebhookSetGitRepository(TestWebhookSetBase, TestCaseWithFactory):
 
     event_type = 'git:push:0.1'
 
-    def makeTarget(self, owner=None):
-        return self.factory.makeGitRepository(owner=owner)
+    def makeTarget(self, project=None, **kwargs):
+        return self.factory.makeGitRepository(target=project, **kwargs)
 
 
 class TestWebhookSetBranch(TestWebhookSetBase, TestCaseWithFactory):
 
     event_type = 'bzr:push:0.1'
 
-    def makeTarget(self, owner=None):
-        return self.factory.makeBranch(owner=owner)
+    def makeTarget(self, project=None, **kwargs):
+        return self.factory.makeBranch(product=project, **kwargs)

=== added file 'lib/lp/services/webhooks/tests/test_payload.py'
--- lib/lp/services/webhooks/tests/test_payload.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/webhooks/tests/test_payload.py	2015-10-09 16:59:27 +0000
@@ -0,0 +1,31 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+from testtools.matchers import (
+    Equals,
+    Is,
+    MatchesDict,
+    )
+
+from lp.registry.interfaces.product import IProduct
+from lp.services.webhooks.payload import compose_webhook_payload
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestComposeWebhookPayload(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_serialises(self):
+        project = self.factory.makeProduct()
+        self.assertThat(
+            compose_webhook_payload(
+                IProduct, project, ["display_name", "owner", "projectgroup"]),
+            MatchesDict({
+                "display_name": Equals(project.display_name),
+                "owner": Equals("/~%s" % project.owner.name),
+                "projectgroup": Is(None),
+                }))


Follow ups