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 + )
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp