← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~alvarocs/launchpad:mp-webhooks into launchpad:master

 

Alvaro Crespo Serrano has proposed merging ~alvarocs/launchpad:mp-webhooks into launchpad:master.

Commit message:
Implement merge-proposal webhook sub-scopes

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

Teams that integrate Launchpad with CI systems need to react to specific merge-proposal changes (creation, reviews, status change...). The legacy 'merge-proposal:0.1' was triggered too often and not always. Moreover consumers couldn't know what actually changed.

Code changes:

-lp/services/webhooks/interfaces.py: register the 6 new subscopes
-lp/services/webhooks/model.py:
    - WebhookSet.trigger() now accepts sub-scopes
-lp/code/subscribers/branchmergeproposal.py:
    - _trigger_webhook now receives the event_type
    - filter out any unknown possible webhook trigger (inline draft comments...)
    - handlers emit the correct subscope
    - new review_comment_created to trigger a webhook for top level comment.
-lib/lp/code/configure.zcml: link review_comment_created method for when a
  comment is created

Tests:
-lp/code/model/tests/test_branchmergeproposal.py:
    - update existing tests with event_type input
    - new class TestMergeProposalWebhookGranularity to test subscopes
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~alvarocs/launchpad:mp-webhooks into launchpad:master.
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index 4e50bbf..95a84f9 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -296,6 +296,10 @@
       for="lp.code.interfaces.branchmergeproposal.IBranchMergeProposal
            lazr.lifecycle.interfaces.IObjectModifiedEvent"
       handler="lp.code.subscribers.karma.branch_merge_modified"/>
+  <subscriber
+      for="lp.code.interfaces.codereviewcomment.ICodeReviewComment
+          lazr.lifecycle.interfaces.IObjectCreatedEvent"
+      handler="lp.code.subscribers.branchmergeproposal.review_comment_created"/>
 
   <!-- hierarchy -->
 
diff --git a/lib/lp/code/model/tests/test_branchmergeproposal.py b/lib/lp/code/model/tests/test_branchmergeproposal.py
index e628757..03b4cb8 100644
--- a/lib/lp/code/model/tests/test_branchmergeproposal.py
+++ b/lib/lp/code/model/tests/test_branchmergeproposal.py
@@ -1235,11 +1235,13 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
         }
         return {k: Equals(v) for k, v in payload.items()}
 
-    def assertCorrectDelivery(self, expected_payload, hook, delivery):
+    def assertCorrectDelivery(
+        self, expected_payload, hook, delivery, event_type
+    ):
         self.assertThat(
             delivery,
             MatchesStructure(
-                event_type=Equals("merge-proposal:0.1"),
+                event_type=Equals(event_type),
                 payload=MatchesDict(expected_payload),
             ),
         )
@@ -1250,7 +1252,9 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
                 repr(delivery),
             )
 
-    def assertCorrectLogging(self, expected_redacted_payload, hook, logger):
+    def assertCorrectLogging(
+        self, expected_redacted_payload, hook, logger, event_type
+    ):
         with dbuser(config.IWebhookDeliveryJobSource.dbuser):
             self.assertThat(
                 logger.output,
@@ -1258,7 +1262,7 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
                     [
                         (
                             hook,
-                            "merge-proposal:0.1",
+                            event_type,
                             MatchesDict(expected_redacted_payload),
                         )
                     ]
@@ -1297,8 +1301,13 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
             expected_payload,
             new=MatchesDict(self.getExpectedPayload(proposal, redact=True)),
         )
-        self.assertCorrectDelivery(expected_payload, hook, delivery)
-        self.assertCorrectLogging(expected_redacted_payload, hook, logger)
+        expected_event = "merge-proposal:0.1"
+        self.assertCorrectDelivery(
+            expected_payload, hook, delivery, expected_event
+        )
+        self.assertCorrectLogging(
+            expected_redacted_payload, hook, logger, expected_event
+        )
 
     def test_create_triggers_webhooks(self):
         # When a merge proposal is created, any relevant webhooks are
@@ -1325,8 +1334,13 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
             expected_payload,
             new=MatchesDict(self.getExpectedPayload(proposal, redact=True)),
         )
-        self.assertCorrectDelivery(expected_payload, hook, delivery)
-        self.assertCorrectLogging(expected_redacted_payload, hook, logger)
+        expected_event = "merge-proposal:0.1"
+        self.assertCorrectDelivery(
+            expected_payload, hook, delivery, expected_event
+        )
+        self.assertCorrectLogging(
+            expected_redacted_payload, hook, logger, expected_event
+        )
 
     def test_modify_triggers_webhooks(self):
         logger = self.useFixture(FakeLogger())
@@ -1363,9 +1377,14 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
         expected_redacted_payload["new"] = MatchesDict(
             self.getExpectedPayload(proposal, redact=True)
         )
+        expected_event = "merge-proposal:0.1"
         delivery = hook.deliveries.one()
-        self.assertCorrectDelivery(expected_payload, hook, delivery)
-        self.assertCorrectLogging(expected_redacted_payload, hook, logger)
+        self.assertCorrectDelivery(
+            expected_payload, hook, delivery, expected_event
+        )
+        self.assertCorrectLogging(
+            expected_redacted_payload, hook, logger, expected_event
+        )
 
     def test_delete_triggers_webhooks(self):
         # When an existing merge proposal is deleted, any relevant webhooks
@@ -1393,8 +1412,13 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
         )
         proposal.deleteProposal()
         delivery = hook.deliveries.one()
-        self.assertCorrectDelivery(expected_payload, hook, delivery)
-        self.assertCorrectLogging(expected_redacted_payload, hook, logger)
+        expected_event = "merge-proposal:0.1"
+        self.assertCorrectDelivery(
+            expected_payload, hook, delivery, expected_event
+        )
+        self.assertCorrectLogging(
+            expected_redacted_payload, hook, logger, expected_event
+        )
 
     def _create_for_webhook_with_git_ref_pattern(
         self, git_ref_pattern, expect_delivery
@@ -1524,6 +1548,147 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
         )
 
 
+class TestMergeProposalWebhookGranularity(
+    WithVCSScenarios, TestCaseWithFactory
+):
+    """Fine-grained webhook behaviour for merge-proposals."""
+
+    # Use the full functional stack so updatePreviewDiff can store its diff
+    # via the in-memory test-librarian provided by the layer.
+    layer = LaunchpadFunctionalLayer
+
+    valid_diff = (
+        "diff --git a/foo b/foo\n"
+        "new file mode 100644\n"
+        "index 0000000..e69de29\n"
+        "--- /dev/null\n"
+        "+++ b/foo\n"
+        "@@ -0,0 +1 @@\n"
+        "+dummy\n"
+    )
+
+    def _make_mp_and_hook(self, event_types):
+        source = self.makeBranch()
+        target = self.makeBranch(same_target_as=source)
+        mp = source.addLandingTarget(
+            self.factory.makePerson(), target, needs_review=True
+        )
+        hook = self.factory.makeWebhook(
+            target=target.repository if self.git else target,
+            event_types=event_types,
+        )
+        return mp, hook, target
+
+    def _latest_delivery(self, hook):
+        with admin_logged_in():
+            return hook.deliveries.one()
+
+    def test_edit_triggers_webhook(self):
+        """Changing the description fires ::edit."""
+        mp, hook, target = self._make_mp_and_hook(["merge-proposal:0.1::edit"])
+
+        login_person(target.owner)
+        with BranchMergeProposalNoPreviewDiffDelta.monitor(mp):
+            mp.description = "New description"
+
+        self.assertEqual(
+            "merge-proposal:0.1::edit", self._latest_delivery(hook).event_type
+        )
+
+    def test_review_without_inlines_always_triggers(self):
+        """A main comment without inline comments fires exactly once
+        ::review webhook."""
+        mp, hook, target = self._make_mp_and_hook(
+            ["merge-proposal:0.1::review"]
+        )
+
+        login_person(target.owner)
+        preview = mp.updatePreviewDiff(self.valid_diff, "a", "b")
+        transaction.commit()
+
+        author = self.factory.makePerson()
+        login_person(author)
+        mp.createComment(
+            author,
+            subject="LGTM",
+            content="All good!",
+            previewdiff_id=preview.id,
+            vote=CodeReviewVote.APPROVE,
+            inline_comments={},
+        )
+        transaction.commit()
+
+        with admin_logged_in():
+            self.assertEqual(1, hook.deliveries.count())
+            self.assertEqual(
+                "merge-proposal:0.1::review",
+                self._latest_delivery(hook).event_type,
+            )
+
+    def test_review_with_main_and_inline_comments_trigger_once(self):
+        """A main comment and inline comments fires exactly one
+        ::review webhook."""
+        mp, hook, target = self._make_mp_and_hook(
+            ["merge-proposal:0.1::review"]
+        )
+        login_person(target.owner)
+
+        preview = mp.updatePreviewDiff(self.valid_diff, "a", "b")
+        transaction.commit()
+
+        author = self.factory.makePerson()
+        login_person(author)
+        mp.createComment(
+            author,
+            subject="LGTM-with-inline",
+            content="Looks fine with a couple of remarks.",
+            previewdiff_id=preview.id,
+            vote=CodeReviewVote.APPROVE,
+            inline_comments={"10": "one remark", "20": "another remark"},
+        )
+        transaction.commit()
+
+        with admin_logged_in():
+            self.assertEqual(1, hook.deliveries.count())
+            self.assertEqual(
+                "merge-proposal:0.1::review",
+                self._latest_delivery(hook).event_type,
+            )
+
+    def test_parent_scope_still_receives_create(self):
+        """A hook that only has 'merge-proposal:0.1' still fires
+        on ::create."""
+        target = self.makeBranch()
+        hook = self.factory.makeWebhook(
+            target=target.repository if self.git else target,
+            event_types=["merge-proposal:0.1"],
+        )
+
+        # Creating the MP should schedule the webhook.
+        login_person(target.owner)
+        source = self.makeBranch(same_target_as=target)
+        source.addLandingTarget(
+            self.factory.makePerson(), target, needs_review=True
+        )
+
+        self.assertEqual(
+            "merge-proposal:0.1", self._latest_delivery(hook).event_type
+        )
+
+    def test_subscope_does_not_leak_to_other_subscope(self):
+        """A hook for ::status-change is *not* triggered by ::edit."""
+        mp, hook, target = self._make_mp_and_hook(
+            ["merge-proposal:0.1::status-change"]
+        )
+
+        # Change something that is *not* the status (description).
+        login_person(target.owner)
+        mp.description = "just a wording tweak"
+        transaction.commit()
+        with admin_logged_in():
+            self.assertEqual(0, hook.deliveries.count())
+
+
 class TestGetAddress(TestCaseWithFactory):
     """Test that the address property gives expected results."""
 
diff --git a/lib/lp/code/subscribers/branchmergeproposal.py b/lib/lp/code/subscribers/branchmergeproposal.py
index ec1874b..e4470dd 100644
--- a/lib/lp/code/subscribers/branchmergeproposal.py
+++ b/lib/lp/code/subscribers/branchmergeproposal.py
@@ -45,7 +45,7 @@ def _compose_merge_proposal_webhook_payload(merge_proposal):
     )
 
 
-def _trigger_webhook(merge_proposal, payload):
+def _trigger_webhook(merge_proposal, payload, event_type):
     payload = dict(payload)
     payload["merge_proposal"] = canonical_url(
         merge_proposal, force_local_path=True
@@ -63,13 +63,34 @@ def _trigger_webhook(merge_proposal, payload):
 
     getUtility(IWebhookSet).trigger(
         target,
-        "merge-proposal:0.1",
+        event_type,
         payload,
         context=merge_proposal,
         git_refs=git_refs,
     )
 
 
+def review_comment_created(comment, event):
+    """Fire a webhook when a main comment is posted."""
+    merge_proposal = comment.branch_merge_proposal
+    base_payload = _compose_merge_proposal_webhook_payload(merge_proposal)
+    # Add review-specific fields to the payload
+    base_payload.update(
+        {
+            "review": comment.id,
+            "vote": comment.vote.title if comment.vote else None,
+            "author": comment.owner.name,
+            "content": comment.message.text_contents,
+            "date_created": comment.date_created.isoformat(),
+        }
+    )
+    payload = {
+        "action": "reviewed",
+        "new": base_payload,
+    }
+    _trigger_webhook(merge_proposal, payload, "merge-proposal:0.1::review")
+
+
 def merge_proposal_created(merge_proposal, event):
     """A new merge proposal has been created.
 
@@ -80,7 +101,7 @@ def merge_proposal_created(merge_proposal, event):
         "action": "created",
         "new": _compose_merge_proposal_webhook_payload(merge_proposal),
     }
-    _trigger_webhook(merge_proposal, payload)
+    _trigger_webhook(merge_proposal, payload, "merge-proposal:0.1::create")
 
 
 def merge_proposal_needs_review(merge_proposal, event):
@@ -94,6 +115,11 @@ def merge_proposal_needs_review(merge_proposal, event):
 
 def merge_proposal_modified(merge_proposal, event):
     """Notify branch subscribers when merge proposals are updated."""
+    old_status = event.object_before_modification.queue_status
+    new_status = merge_proposal.queue_status
+    # No webhook triggered if the status and edited fields are the same
+    if event.edited_fields is None and old_status == new_status:
+        return
     # Check the user.
     if event.user is None:
         return
@@ -101,8 +127,6 @@ def merge_proposal_modified(merge_proposal, event):
         from_person = None
     else:
         from_person = IPerson(event.user)
-    old_status = event.object_before_modification.queue_status
-    new_status = merge_proposal.queue_status
 
     in_progress_states = (
         BranchMergeProposalStatus.WORK_IN_PROGRESS,
@@ -141,7 +165,24 @@ def merge_proposal_modified(merge_proposal, event):
     for field in payload["old"]:
         if not hasattr(event.object_before_modification, field):
             payload["old"][field] = payload["new"][field]
-    _trigger_webhook(merge_proposal, payload)
+
+    # Filter modified fields to trigger the corresponding webhook
+    edited_fields = event.edited_fields or []
+    if old_status != new_status:
+        _trigger_webhook(
+            merge_proposal,
+            payload,
+            event_type="merge-proposal:0.1::status-change",
+        )
+    elif "description" in edited_fields:
+        _trigger_webhook(
+            merge_proposal, payload, event_type="merge-proposal:0.1::edit"
+        )
+    elif "commit_message" in edited_fields:
+        _trigger_webhook(
+            merge_proposal, payload, event_type="merge-proposal:0.1::edit"
+        )
+    return
 
 
 def review_requested(vote_reference, event):
@@ -161,4 +202,4 @@ def merge_proposal_deleted(merge_proposal, event):
         "action": "deleted",
         "old": _compose_merge_proposal_webhook_payload(merge_proposal),
     }
-    _trigger_webhook(merge_proposal, payload)
+    _trigger_webhook(merge_proposal, payload, "merge-proposal:0.1::delete")
diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
index 166c503..bee5de4 100644
--- a/lib/lp/services/webhooks/interfaces.py
+++ b/lib/lp/services/webhooks/interfaces.py
@@ -56,6 +56,13 @@ WEBHOOK_EVENT_TYPES = {
     "git:push:0.1": "Git push",
     "livefs:build:0.1": "Live filesystem build",
     "merge-proposal:0.1": "Merge proposal",
+    # New granular MP scopes
+    "merge-proposal:0.1::create": "Create",
+    "merge-proposal:0.1::push": "Push",
+    "merge-proposal:0.1::review": "Review",
+    "merge-proposal:0.1::edit": "Edit",
+    "merge-proposal:0.1::status-change": "Status Change",
+    "merge-proposal:0.1::delete": "Delete",
     "oci-recipe:build:0.1": "OCI recipe build",
     "snap:build:0.1": "Snap build",
     "craft-recipe:build:0.1": "Craft recipe build",
diff --git a/lib/lp/services/webhooks/model.py b/lib/lp/services/webhooks/model.py
index a3d6503..a13ef7f 100644
--- a/lib/lp/services/webhooks/model.py
+++ b/lib/lp/services/webhooks/model.py
@@ -359,13 +359,28 @@ class WebhookSet:
         # 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.
+        parent_event_type = event_type.split("::", 1)[0]
         for webhook in self.findByTarget(target):
+            # Support sub-scoped event types or the parent subscope.
+            matched_type = None
+            # Allow triggering if the webhook is subscribed to the exact
+            # subscope
             if (
                 webhook.active
                 and event_type in webhook.event_types
                 and self._checkGitRefs(webhook, git_refs)
             ):
-                WebhookDeliveryJob.create(webhook, event_type, payload)
+                matched_type = event_type
+            # Allow triggering if the webhook is subscribed to the parent
+            # subscope
+            elif (
+                webhook.active
+                and parent_event_type in webhook.event_types
+                and self._checkGitRefs(webhook, git_refs)
+            ):
+                matched_type = parent_event_type
+            if matched_type is not None:
+                WebhookDeliveryJob.create(webhook, matched_type, payload)
 
 
 class WebhookTargetMixin:

Follow ups