← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ines-almeida/launchpad:add-bug-webhooks/hook-webhooks-up-to-events into launchpad:master

 

Ines Almeida has proposed merging ~ines-almeida/launchpad:add-bug-webhooks/hook-webhooks-up-to-events into launchpad:master with ~ines-almeida/launchpad:add-bug-webhooks/add-interfaces as a prerequisite.

Commit message:
Subscribe webhooks to creation/modification events

The new bug webhooks are triggered by bug creation/modification and bugtask creation/modification.
The new bug comment webhook is only triggered by comment creation.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ines-almeida/launchpad/+git/launchpad/+merge/442744

With this change, webhooks can now be fully setup and triggered.

This is based of these 2 other proposals:
 - https://code.launchpad.net/~ines-almeida/launchpad/+git/launchpad/+merge/442544
 - https://code.launchpad.net/~ines-almeida/launchpad/+git/launchpad/+merge/442543

To test it locally (or later on dogfood):
 - Set the feature flag by going into /+feature-rules and adding `webhooks.new.enabled	default	0	on`
 - Go to a project (or distribution, or distribution source package). You should now see the button 'Manage Webhooks'
 - Add a new webhook
 - Create a bug and a bug comment for that project
 - [Locally] See the logs for a new webhook trigger

-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ines-almeida/launchpad:add-bug-webhooks/hook-webhooks-up-to-events into launchpad:master.
diff --git a/lib/lp/bugs/configure.zcml b/lib/lp/bugs/configure.zcml
index fe55acc..4beb6e0 100644
--- a/lib/lp/bugs/configure.zcml
+++ b/lib/lp/bugs/configure.zcml
@@ -92,6 +92,10 @@
         for="lp.bugs.interfaces.bugmessage.IBugMessage                 lazr.lifecycle.interfaces.IObjectCreatedEvent"
         handler="lp.bugs.subscribers.bug.notify_bug_comment_added"/>
     <subscriber
+        for="lp.bugs.interfaces.bugmessage.IBugMessage
+             lazr.lifecycle.interfaces.IObjectCreatedEvent"
+        handler="lp.bugs.subscribers.webhooks.bug_comment_added"/>
+    <subscriber
         for="lp.bugs.interfaces.bugmessage.IBugMessage                 lazr.lifecycle.interfaces.IObjectCreatedEvent"
         handler="lp.bugs.subscribers.karma.bug_comment_added"/>
     <subscriber
@@ -115,6 +119,22 @@
     <subscriber
         for="lp.bugs.interfaces.bug.IBug                 lazr.lifecycle.interfaces.IObjectModifiedEvent"
         handler="lp.bugs.subscribers.bug.notify_bug_modified"/>
+    <subscriber
+        for="lp.bugs.interfaces.bug.IBug
+             lazr.lifecycle.interfaces.IObjectCreatedEvent"
+        handler="lp.bugs.subscribers.webhooks.bug_created"/>
+    <subscriber
+        for="lp.bugs.interfaces.bug.IBug
+             lazr.lifecycle.interfaces.IObjectModifiedEvent"
+        handler="lp.bugs.subscribers.webhooks.bug_modified"/>
+    <subscriber
+        for="lp.bugs.interfaces.bug.IBugTask
+             lazr.lifecycle.interfaces.IObjectCreatedEvent"
+        handler="lp.bugs.subscribers.webhooks.bugtask_created"/>
+    <subscriber
+        for="lp.bugs.interfaces.bug.IBugTask
+             lazr.lifecycle.interfaces.IObjectModifiedEvent"
+        handler="lp.bugs.subscribers.webhooks.bugtask_modified"/>
     <lp:securedutility
         class="lp.systemhomes.MaloneApplication"
         provides="lp.bugs.interfaces.malone.IMaloneApplication">
diff --git a/lib/lp/bugs/subscribers/webhooks.py b/lib/lp/bugs/subscribers/webhooks.py
new file mode 100644
index 0000000..9b3758a
--- /dev/null
+++ b/lib/lp/bugs/subscribers/webhooks.py
@@ -0,0 +1,122 @@
+# Copyright 2023 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Webhook subscribers for bugs and bugtasks."""
+
+from lazr.lifecycle.interfaces import IObjectCreatedEvent, IObjectModifiedEvent
+from zope.component import getUtility
+
+from lp.bugs.interfaces.bug import IBug
+from lp.bugs.interfaces.bugmessage import IBugMessage
+from lp.bugs.interfaces.bugtarget import BUG_WEBHOOKS_FEATURE_FLAG
+from lp.bugs.interfaces.bugtask import IBugTask
+from lp.bugs.subscribers.bugactivity import what_changed
+from lp.services.database.sqlbase import block_implicit_flushes
+from lp.services.features import getFeatureFlag
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webhooks.interfaces import IWebhookSet, IWebhookTarget
+from lp.services.webhooks.payload import compose_webhook_payload
+
+
+@block_implicit_flushes
+def _trigger_bugtask_webhook(bugtask: IBugTask, action: str):
+    """Triggers 'bug' event for a specific bugtask"""
+    if not getFeatureFlag(BUG_WEBHOOKS_FEATURE_FLAG):
+        return
+
+    if IWebhookTarget.providedBy(bugtask.target):
+        payload = create_bugtask_payload(bugtask, action)
+        getUtility(IWebhookSet).trigger(bugtask.target, "bug:0.1", payload)
+
+
+@block_implicit_flushes
+def _trigger_bug_comment_webhook(bug_comment: IBugMessage, action: str):
+    """Triggers 'bug:comment' events for each bug target for that comment"""
+    if not getFeatureFlag(BUG_WEBHOOKS_FEATURE_FLAG):
+        return
+
+    bugtasks = bug_comment.bug.bugtasks
+
+    # We trigger one webhook for each coment's bugtask that has webhooks set up
+    for bugtask in bugtasks:
+        target = bugtask.target
+        if IWebhookTarget.providedBy(target):
+            payload = create_bug_comment_payload(bugtask, bug_comment, action)
+            getUtility(IWebhookSet).trigger(target, "bug:comment:0.1", payload)
+
+
+def create_bugtask_payload(bugtask, action):
+    payload = {
+        "target": canonical_url(bugtask.target, force_local_path=True),
+        "action": action,
+        "bug": canonical_url(bugtask.bug, force_local_path=True),
+    }
+    payload.update(
+        compose_webhook_payload(
+            IBug,
+            bugtask.bug,
+            ["title", "description", "owner"],
+        )
+    )
+    payload.update(
+        compose_webhook_payload(
+            IBugTask,
+            bugtask,
+            ["status", "importance", "assignee", "datecreated"],
+        )
+    )
+    return payload
+
+
+def create_bug_comment_payload(bugtask, bug_comment, action):
+    payload = {
+        "target": canonical_url(bugtask.target, force_local_path=True),
+        "action": action,
+        "bug": canonical_url(bug_comment.bug, force_local_path=True),
+        "bug_comment": canonical_url(bug_comment, force_local_path=True),
+        "content": bug_comment.text_contents,
+    }
+    payload.update(
+        compose_webhook_payload(IBugMessage, bug_comment, ["owner"])
+    )
+    return payload
+
+
+def bug_created(bug: IBug, event: IObjectCreatedEvent):
+    """Trigger a 'created' event when a new Bug is created
+
+    #NOTE When a user creates a new Bug, only the 'bug_created' is called
+    (not 'bugtask_created'). On the other hand, when a new project is added to
+    a bug as being affected by it, then only 'bugtask_created' is called.
+    """
+    for bugtask in bug.bugtasks:
+        _trigger_bugtask_webhook(bugtask, "created")
+
+
+def bug_modified(bug: IBug, event: IObjectCreatedEvent):
+    """Trigger a '<field>-updated' event when a bug is modified"""
+    changed_fields = what_changed(event)
+
+    for field in changed_fields:
+        action = "{}-updated".format(field)
+        for bugtask in bug.bugtasks:
+            _trigger_bugtask_webhook(bugtask, action)
+
+
+def bugtask_created(bugtask: IBugTask, event: IObjectCreatedEvent):
+    """Trigger a 'created' event when a BugTask is created in existing bug"""
+    _trigger_bugtask_webhook(bugtask, "created")
+
+
+def bugtask_modified(bugtask: IBugTask, event: IObjectModifiedEvent):
+    """Trigger a '<field>-updated' event when a BugTask is modified"""
+    changed_fields = what_changed(event)
+
+    for field in changed_fields:
+        action = "{}-updated".format(field)
+        _trigger_bugtask_webhook(bugtask, action)
+
+
+def bug_comment_added(comment, event: IObjectCreatedEvent):
+    """Trigger a 'created' event when a new comment is added to a Bug"""
+    _trigger_bug_comment_webhook(comment, "created")
diff --git a/lib/lp/bugs/tests/test_subscribers.py b/lib/lp/bugs/tests/test_subscribers.py
index 9a398e0..403432f 100644
--- a/lib/lp/bugs/tests/test_subscribers.py
+++ b/lib/lp/bugs/tests/test_subscribers.py
@@ -3,11 +3,18 @@
 
 """Tests for the BugActivity code."""
 
-from lazr.lifecycle.event import ObjectModifiedEvent
+from lazr.lifecycle.event import ObjectCreatedEvent, ObjectModifiedEvent
 from lazr.lifecycle.snapshot import Snapshot
+from testtools.matchers import Equals, MatchesDict, MatchesStructure
+from zope.event import notify
 
 from lp.bugs.interfaces.bug import IBug
+from lp.bugs.interfaces.bugtarget import BUG_WEBHOOKS_FEATURE_FLAG
+from lp.bugs.interfaces.bugtask import BugTaskStatus
 from lp.bugs.subscribers.bugactivity import what_changed
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webapp.snapshot import notify_modified
 from lp.testing import TestCaseWithFactory, person_logged_in
 from lp.testing.layers import DatabaseFunctionalLayer
 
@@ -44,3 +51,164 @@ class TestWhatChanged(TestCaseWithFactory):
         expected_changes = {"private": ["False", "True"]}
         changes = what_changed(event)
         self.assertEqual(expected_changes, changes)
+
+
+class TestBugWebhooksTriggered(TestCaseWithFactory):
+    """Tests that bug and bug comment webhooks get triggered"""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(
+            FeatureFixture(
+                {
+                    BUG_WEBHOOKS_FEATURE_FLAG: "on",
+                }
+            )
+        )
+        self.target = self.factory.makeProduct()
+        self.owner = self.target.owner
+        self.bugtask = self.factory.makeBugTask(target=self.target)
+        self.hook = self.factory.makeWebhook(
+            target=self.target, event_types=["bug:comment:0.1", "bug:0.1"]
+        )
+
+    def _assert_last_webhook_delivery(self, hook, event, payload):
+        with person_logged_in(self.owner):
+            delivery = hook.deliveries.last()
+            expected_structure = MatchesStructure(
+                event_type=Equals(event),
+                payload=MatchesDict(payload),
+            )
+            self.assertThat(delivery, expected_structure)
+
+    def _build_comment_expected_payload(self, comment, content):
+        return {
+            "action": Equals("created"),
+            "bug_comment": Equals(
+                canonical_url(comment, force_local_path=True)
+            ),
+            "bug": Equals(
+                canonical_url(self.bugtask.bug, force_local_path=True)
+            ),
+            "target": Equals(
+                canonical_url(self.target, force_local_path=True)
+            ),
+            "content": Equals(content),
+            "owner": Equals("/~" + comment.owner.name),
+        }
+
+    def _build_bugtask_expected_payload(self, bugtask, action):
+        assignee = "/~" + bugtask.assignee.name if bugtask.assignee else None
+
+        return {
+            "action": Equals(action),
+            "bug": Equals(canonical_url(bugtask.bug, force_local_path=True)),
+            "target": Equals(
+                canonical_url(self.target, force_local_path=True)
+            ),
+            "title": Equals(bugtask.bug.title),
+            "description": Equals(bugtask.bug.description),
+            "owner": Equals("/~" + bugtask.bug.owner.name),
+            "status": Equals(bugtask.status.title),
+            "importance": Equals(bugtask.importance.title),
+            "assignee": Equals(assignee),
+            "datecreated": Equals(bugtask.datecreated.isoformat()),
+        }
+
+    def test_new_bug_comment_triggers_webhook(self):
+        """Adding a comment to a bug with a webhook, triggers webhook"""
+        comment = self.factory.makeBugComment(
+            bug=self.bugtask.bug, body="test comment"
+        )
+        expected_payload = self._build_comment_expected_payload(
+            comment, content="test comment"
+        )
+        self._assert_last_webhook_delivery(
+            self.hook, "bug:comment:0.1", expected_payload
+        )
+
+    def test_new_bug_triggers_webhook(self):
+        """Adding a bug to a target with a webhook, triggers webhook"""
+        bug = self.factory.makeBug(target=self.target)
+        expected_payload = self._build_bugtask_expected_payload(
+            bug.bugtasks[0], "created"
+        )
+        self._assert_last_webhook_delivery(
+            self.hook, "bug:0.1", expected_payload
+        )
+
+    def test_new_bugtask_triggers_webhook(self):
+        """Adding a new task targeted at our webhook target, will invoke the
+        bug 'created' event"""
+        bug = self.factory.makeBug()
+        with person_logged_in(self.owner):
+            new_task = bug.addTask(owner=self.owner, target=self.target)
+            # The ObjectCreatedEvent would be triggered in BugAlsoAffectsView
+            notify(ObjectCreatedEvent(new_task))
+
+        expected_payload = self._build_bugtask_expected_payload(
+            new_task, "created"
+        )
+        self._assert_last_webhook_delivery(
+            self.hook, "bug:0.1", expected_payload
+        )
+
+    def test_bug_modified_triggers_webhook(self):
+        """Modifying the bug fields, will trigger webhook"""
+        bug = self.bugtask.bug
+        with person_logged_in(self.owner), notify_modified(
+            bug, ["title"], user=self.owner
+        ):
+            bug.title = "new title"
+        expected_payload = self._build_bugtask_expected_payload(
+            self.bugtask, "title-updated"
+        )
+        self._assert_last_webhook_delivery(
+            self.hook, "bug:0.1", expected_payload
+        )
+
+    def test_bugtask_modified_triggers_webhook(self):
+        """Modifying the bug task fields, will trigger webhook"""
+        with person_logged_in(self.owner):
+            self.bugtask.bug.setStatus(
+                self.target, BugTaskStatus.FIXRELEASED, self.owner
+            )
+        expected_payload = self._build_bugtask_expected_payload(
+            self.bugtask, "status-updated"
+        )
+        self._assert_last_webhook_delivery(
+            self.hook, "bug:0.1", expected_payload
+        )
+
+    def test_webhook_subscription(self):
+        """Check that a webhook only subscribed to the 'bug:0.1' event, does
+        not get a 'bug:comment:0.1' event, and vice-versa"""
+        bug_hook = self.factory.makeWebhook(
+            target=self.target, event_types=["bug:0.1"]
+        )
+        comment_hook = self.factory.makeWebhook(
+            target=self.target, event_types=["bug:comment:0.1"]
+        )
+
+        bug = self.factory.makeBug(target=self.target)
+        comment = self.factory.makeBugComment(
+            bug=self.bugtask.bug, body="test comment"
+        )
+
+        b_exptd_payload = self._build_bugtask_expected_payload(
+            bug.bugtasks[0], "created"
+        )
+        c_exptd_payload = self._build_comment_expected_payload(
+            comment,
+            content="test comment",
+        )
+
+        self._assert_last_webhook_delivery(
+            bug_hook, "bug:0.1", b_exptd_payload
+        )
+
+        self._assert_last_webhook_delivery(
+            comment_hook, "bug:comment:0.1", c_exptd_payload
+        )