← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ines-almeida/launchpad:add-bug-webhooks/add-interfaces into launchpad:master

 

Ines Almeida has proposed merging ~ines-almeida/launchpad:add-bug-webhooks/add-interfaces into launchpad:master with ~ines-almeida/launchpad:add-bug-webhooks/update-webhook-model as a prerequisite.

Commit message:
Add interfaces to add new webhooks for bugtask targets

Webhooks can now be added to a project, a distribution or a distribution source package.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This will add the user interfaces to add new webhooks for the new targets (to be used with the new `bug` and `bug:comment` events)

All interfaces are hidden under a new feature flag, so these pages won't be exposed until the feature flag is set.

This MP is based of another open MP: https://code.launchpad.net/~ines-almeida/launchpad/+git/launchpad/+merge/442543

The webhooks are not hooked to anything in this MP - that will be handled in another MP
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ines-almeida/launchpad:add-bug-webhooks/add-interfaces into launchpad:master.
diff --git a/database/schema/patch-2211-19-0.sql b/database/schema/patch-2211-19-0.sql
new file mode 100644
index 0000000..d0f3a80
--- /dev/null
+++ b/database/schema/patch-2211-19-0.sql
@@ -0,0 +1,14 @@
+-- Copyright 2011 Canonical Ltd.  This software is licensed under the
+-- GNU Affero General Public License version 3 (see the file LICENSE).
+
+SET client_min_messages=ERROR;
+
+ALTER TABLE Webhook
+    ADD COLUMN project integer REFERENCES product,
+    ADD COLUMN distribution integer REFERENCES distribution,
+    ADD COLUMN source_package_name integer REFERENCES sourcepackagename;
+
+ALTER TABLE Webhook DROP CONSTRAINT one_target;
+ALTER TABLE Webhook ADD CONSTRAINT one_target CHECK ((public.null_count(ARRAY[git_repository, branch, snap, livefs, oci_recipe, charm_recipe, project, distribution]) = 7));
+
+INSERT INTO LaunchpadDatabaseRevision VALUES (2211, 19, 0);
\ No newline at end of file
diff --git a/lib/lp/bugs/interfaces/bugtarget.py b/lib/lp/bugs/interfaces/bugtarget.py
index 57bc0fe..540079f 100644
--- a/lib/lp/bugs/interfaces/bugtarget.py
+++ b/lib/lp/bugs/interfaces/bugtarget.py
@@ -15,6 +15,7 @@ __all__ = [
     "ISeriesBugTarget",
     "BUG_POLICY_ALLOWED_TYPES",
     "BUG_POLICY_DEFAULT_TYPES",
+    "BUG_WEBHOOKS_FEATURE_FLAG",
 ]
 
 
@@ -156,6 +157,9 @@ BUG_POLICY_DEFAULT_TYPES = {
 }
 
 
+BUG_WEBHOOKS_FEATURE_FLAG = "bugs.webhooks.enabled"
+
+
 @exported_as_webservice_entry(as_of="beta")
 class IHasBugs(Interface):
     """An entity which has a collection of bug tasks."""
diff --git a/lib/lp/registry/browser/distribution.py b/lib/lp/registry/browser/distribution.py
index 3fb8a36..b990f6f 100644
--- a/lib/lp/registry/browser/distribution.py
+++ b/lib/lp/registry/browser/distribution.py
@@ -83,6 +83,7 @@ from lp.bugs.browser.structuralsubscription import (
     StructuralSubscriptionTargetTraversalMixin,
     expose_structural_subscription_data_to_js,
 )
+from lp.bugs.interfaces.bugtarget import BUG_WEBHOOKS_FEATURE_FLAG
 from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.code.browser.vcslisting import TargetDefaultVCSNavigationMixin
 from lp.registry.browser import RegistryEditFormView, add_subscribe_link
@@ -141,6 +142,7 @@ from lp.services.webapp.authorization import check_permission
 from lp.services.webapp.batching import BatchNavigator
 from lp.services.webapp.breadcrumb import Breadcrumb
 from lp.services.webapp.interfaces import ILaunchBag
+from lp.services.webhooks.browser import WebhookTargetNavigationMixin
 from lp.soyuz.browser.archive import EnableProcessorsMixin
 from lp.soyuz.browser.packagesearch import PackageSearchViewBase
 from lp.soyuz.enums import ArchivePurpose
@@ -155,6 +157,7 @@ class DistributionNavigation(
     StructuralSubscriptionTargetTraversalMixin,
     PillarNavigationMixin,
     TargetDefaultVCSNavigationMixin,
+    WebhookTargetNavigationMixin,
 ):
 
     usedfor = IDistribution
@@ -388,6 +391,18 @@ class DistributionNavigationMenu(NavigationMenu, DistributionLinksMixin):
     usedfor = IDistribution
     facet = "overview"
 
+    links = (
+        "edit",
+        "admin",
+        "pubconf",
+        "subscribe_to_bug_mail",
+        "edit_bug_mail",
+        "sharing",
+        "new_oci_project",
+        "search_oci_project",
+        "webhooks",
+    )
+
     @enabled_with_permission("launchpad.Admin")
     def admin(self):
         text = "Administer"
@@ -419,18 +434,14 @@ class DistributionNavigationMenu(NavigationMenu, DistributionLinksMixin):
         link.enabled = not oci_projects.is_empty()
         return link
 
-    @cachedproperty
-    def links(self):
-        return [
-            "edit",
-            "admin",
-            "pubconf",
-            "subscribe_to_bug_mail",
-            "edit_bug_mail",
-            "sharing",
-            "new_oci_project",
-            "search_oci_project",
-        ]
+    @enabled_with_permission("launchpad.Edit")
+    def webhooks(self):
+        return Link(
+            "+webhooks",
+            "Manage webhooks",
+            icon="edit",
+            enabled=bool(getFeatureFlag(BUG_WEBHOOKS_FEATURE_FLAG)),
+        )
 
 
 class DistributionOverviewMenu(ApplicationMenu, DistributionLinksMixin):
@@ -623,6 +634,7 @@ class DistributionBugsMenu(PillarBugsMenu):
     def links(self):
         links = ["bugsupervisor", "cve", "filebug"]
         add_subscribe_link(links)
+        links.append("webhooks")
         return links
 
 
diff --git a/lib/lp/registry/browser/distributionsourcepackage.py b/lib/lp/registry/browser/distributionsourcepackage.py
index a55097b..e0d960a 100644
--- a/lib/lp/registry/browser/distributionsourcepackage.py
+++ b/lib/lp/registry/browser/distributionsourcepackage.py
@@ -42,6 +42,7 @@ from lp.bugs.browser.structuralsubscription import (
     StructuralSubscriptionTargetTraversalMixin,
     expose_structural_subscription_data_to_js,
 )
+from lp.bugs.interfaces.bugtarget import BUG_WEBHOOKS_FEATURE_FLAG
 from lp.bugs.interfaces.bugtask import BugTaskStatus
 from lp.bugs.interfaces.bugtasksearch import BugTaskSearchParams
 from lp.code.browser.vcslisting import TargetDefaultVCSNavigationMixin
@@ -54,6 +55,7 @@ from lp.registry.interfaces.distributionsourcepackage import (
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.database.decoratedresultset import DecoratedResultSet
+from lp.services.features import getFeatureFlag
 from lp.services.helpers import shortlist
 from lp.services.propertycache import cachedproperty
 from lp.services.webapp import (
@@ -76,6 +78,7 @@ from lp.services.webapp.menu import (
 )
 from lp.services.webapp.publisher import LaunchpadView
 from lp.services.webapp.sorting import sorted_dotted_numbers
+from lp.services.webhooks.browser import WebhookTargetNavigationMixin
 from lp.soyuz.browser.sourcepackagerelease import linkify_changelog
 from lp.soyuz.interfaces.archive import IArchiveSet
 from lp.soyuz.interfaces.distributionsourcepackagerelease import (
@@ -170,6 +173,15 @@ class DistributionSourcePackageLinksMixin:
         get_data = "?field.status=OPEN"
         return Link(base_path + get_data, "Open Questions", site="answers")
 
+    @enabled_with_permission("launchpad.Edit")
+    def webhooks(self):
+        return Link(
+            "+webhooks",
+            "Manage webhooks",
+            icon="edit",
+            enabled=bool(getFeatureFlag(BUG_WEBHOOKS_FEATURE_FLAG)),
+        )
+
 
 class DistributionSourcePackageOverviewMenu(
     ApplicationMenu, DistributionSourcePackageLinksMixin
@@ -193,6 +205,7 @@ class DistributionSourcePackageBugsMenu(
     def links(self):
         links = ["filebug"]
         add_subscribe_link(links)
+        links.append("webhooks")
         return links
 
 
@@ -214,6 +227,7 @@ class DistributionSourcePackageNavigation(
     QuestionTargetTraversalMixin,
     TargetDefaultVCSNavigationMixin,
     StructuralSubscriptionTargetTraversalMixin,
+    WebhookTargetNavigationMixin,
 ):
 
     usedfor = IDistributionSourcePackage
@@ -285,7 +299,7 @@ class DistributionSourcePackageActionMenu(
     def links(self):
         links = ["publishing_history", "change_log"]
         add_subscribe_link(links)
-        links.append("edit")
+        links.extend(["edit", "webhooks"])
         return links
 
     def publishing_history(self):
diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py
index 56c03a3..907d7f1 100644
--- a/lib/lp/registry/browser/product.py
+++ b/lib/lp/registry/browser/product.py
@@ -111,6 +111,7 @@ from lp.bugs.browser.structuralsubscription import (
     StructuralSubscriptionTargetTraversalMixin,
     expose_structural_subscription_data_to_js,
 )
+from lp.bugs.interfaces.bugtarget import BUG_WEBHOOKS_FEATURE_FLAG
 from lp.bugs.interfaces.bugtask import RESOLVED_BUGTASK_STATUSES
 from lp.charms.browser.hascharmrecipes import HasCharmRecipesMenuMixin
 from lp.code.browser.branchref import BranchRef
@@ -190,6 +191,7 @@ from lp.services.webapp.interfaces import UnsafeFormGetSubmissionError
 from lp.services.webapp.menu import NavigationMenu
 from lp.services.webapp.url import urlsplit
 from lp.services.webapp.vhosts import allvhosts
+from lp.services.webhooks.browser import WebhookTargetNavigationMixin
 from lp.services.worlddata.helpers import browser_languages
 from lp.services.worlddata.interfaces.country import ICountry
 from lp.snappy.browser.hassnaps import HasSnapsMenuMixin
@@ -210,6 +212,7 @@ class ProductNavigation(
     StructuralSubscriptionTargetTraversalMixin,
     PillarNavigationMixin,
     TargetDefaultVCSNavigationMixin,
+    WebhookTargetNavigationMixin,
 ):
 
     usedfor = IProduct
@@ -512,6 +515,15 @@ class ProductEditLinksMixin(StructuralSubscriptionMenuMixin):
         ) and product.canAdministerOCIProjects(self.user)
         return link
 
+    @enabled_with_permission("launchpad.Edit")
+    def webhooks(self):
+        return Link(
+            "+webhooks",
+            "Manage webhooks",
+            icon="edit",
+            enabled=bool(getFeatureFlag(BUG_WEBHOOKS_FEATURE_FLAG)),
+        )
+
 
 class IProductEditMenu(Interface):
     """A marker interface for the 'Change details' navigation menu."""
@@ -537,6 +549,7 @@ class ProductActionNavigationMenu(NavigationMenu, ProductEditLinksMixin):
             "sharing",
             "search_oci_project",
             "new_oci_project",
+            "webhooks",
         ]
         add_subscribe_link(links)
         return links
@@ -648,7 +661,7 @@ class ProductBugsMenu(PillarBugsMenu, ProductEditLinksMixin):
     def links(self):
         links = ["filebug", "bugsupervisor", "cve"]
         add_subscribe_link(links)
-        links.append("configure_bugtracker")
+        links.extend(["configure_bugtracker", "webhooks"])
         return links
 
 
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index 22178f0..b2dc87f 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -550,48 +550,14 @@
     <class
         class="lp.registry.model.distributionsourcepackage.DistributionSourcePackage">
         <allow interface="lp.bugs.interfaces.bugsummary.IBugSummaryDimension"/>
-        <allow interface="lp.bugs.interfaces.bugtarget.IBugTarget"/>
         <allow
             interface="lp.translations.interfaces.customlanguagecode.IHasCustomLanguageCodes"/>
 
         <allow
-            attributes="
-                __eq__
-                __getitem__
-                __ne__
-                _getOfficialTagClause
-                binary_names
-                bug_count
-                bugtasks
-                current_publishing_records
-                currentrelease
-                delete
-                development_version
-                display_name
-                displayname
-                distribution
-                distro
-                drivers
-                findRelatedArchivePublications
-                findRelatedArchives
-                getBranches
-                getMergeProposals
-                getReleasesAndPublishingHistory
-                getUsedBugTagsWithOpenCounts
-                getVersion
-                get_distroseries_packages
-                is_official
-                latest_overall_publication
-                name
-                official_bug_tags
-                personHasDriverRights
-                publishing_history
-                releases
-                sourcepackagename
-                subscribers
-                summary
-                title
-                upstream_product"/>
+            interface="lp.registry.interfaces.distributionsourcepackage.IDistributionSourcePackageView"/>
+        <require
+            permission="launchpad.Edit"
+            interface="lp.registry.interfaces.distributionsourcepackage.IDistributionSourcePackageEdit"/>
 
         <!-- IStructuralSubscriptionTarget -->
 
@@ -615,16 +581,6 @@
                 bug_reporting_guidelines
                 enable_bugfiling_duplicate_search
                 "/>
-
-        <!-- IHasGitRepositories -->
-
-        <allow
-            interface="lp.code.interfaces.hasgitrepositories.IHasGitRepositories" />
-
-        <!-- IHasCodeImports -->
-
-        <allow
-            interface="lp.code.interfaces.hasbranches.IHasCodeImports" />
     </class>
     <adapter
         provides="lp.registry.interfaces.distribution.IDistribution"
diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
index 7fdc5ff..ef966c7 100644
--- a/lib/lp/registry/interfaces/distribution.py
+++ b/lib/lp/registry/interfaces/distribution.py
@@ -102,6 +102,7 @@ from lp.services.fields import (
     Summary,
     Title,
 )
+from lp.services.webhooks.interfaces import IWebhookTarget
 from lp.soyuz.interfaces.buildrecords import IHasBuildRecords
 from lp.translations.interfaces.hastranslationimports import (
     IHasTranslationImports,
@@ -1064,7 +1065,9 @@ class IDistributionView(
         """Return the vulnerability in this distribution with the given id."""
 
 
-class IDistributionEditRestricted(IOfficialBugTagTargetRestricted):
+class IDistributionEditRestricted(
+    IOfficialBugTagTargetRestricted, IWebhookTarget
+):
     """IDistribution properties requiring launchpad.Edit permission."""
 
     @mutator_for(IDistributionView["bug_sharing_policy"])
diff --git a/lib/lp/registry/interfaces/distributionsourcepackage.py b/lib/lp/registry/interfaces/distributionsourcepackage.py
index cba5873..11235d9 100644
--- a/lib/lp/registry/interfaces/distributionsourcepackage.py
+++ b/lib/lp/registry/interfaces/distributionsourcepackage.py
@@ -27,27 +27,22 @@ from lp.code.interfaces.hasbranches import (
 from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.role import IHasDrivers
+from lp.services.webhooks.interfaces import IWebhookTarget
 from lp.soyuz.enums import ArchivePurpose
 
 
 @exported_as_webservice_entry(as_of="beta")
-class IDistributionSourcePackage(
+class IDistributionSourcePackageView(
     IHeadingContext,
     IBugTarget,
     IHasBranches,
     IHasMergeProposals,
     IHasOfficialBugTags,
-    IStructuralSubscriptionTarget,
-    IQuestionTarget,
     IHasDrivers,
     IHasGitRepositories,
     IHasCodeImports,
 ):
-    """Represents a source package in a distribution.
-
-    Create IDistributionSourcePackages by invoking
-    `IDistribution.getSourcePackage()`.
-    """
+    """`IDistributionSourcePackage` attributes that require launchpad.View."""
 
     distribution = exported(
         Reference(IDistribution, title=_("The distribution."))
@@ -112,6 +107,7 @@ class IDistributionSourcePackage(
         "of the IDistributionSourcePackage."
     )
 
+    # XXX unused
     po_message_count = Attribute(
         "Number of translations matching the distribution and "
         "sourcepackagename of the IDistributionSourcePackage."
@@ -209,3 +205,21 @@ class IDistributionSourcePackage(
 
         :return: True if a persistent object was removed, otherwise False.
         """
+
+
+class IDistributionSourcePackageEdit(IWebhookTarget):
+    """`IDistributionSourcePackage` attributes that require launchpad.Edit."""
+
+
+@exported_as_webservice_entry(as_of="beta")
+class IDistributionSourcePackage(
+    IDistributionSourcePackageView,
+    IDistributionSourcePackageEdit,
+    IStructuralSubscriptionTarget,
+    IQuestionTarget,
+):
+    """Represents a source package in a distribution.
+
+    Create IDistributionSourcePackages by invoking
+    `IDistribution.getSourcePackage()`.
+    """
diff --git a/lib/lp/registry/interfaces/product.py b/lib/lp/registry/interfaces/product.py
index e28f68c..a3e3e45 100644
--- a/lib/lp/registry/interfaces/product.py
+++ b/lib/lp/registry/interfaces/product.py
@@ -130,6 +130,7 @@ from lp.services.fields import (
     Title,
     URIField,
 )
+from lp.services.webhooks.interfaces import IWebhookTarget
 from lp.services.webservice.apihelpers import (
     patch_collection_property,
     patch_reference_property,
@@ -1099,7 +1100,7 @@ class IProductView(
         """
 
 
-class IProductEditRestricted(IOfficialBugTagTargetRestricted):
+class IProductEditRestricted(IOfficialBugTagTargetRestricted, IWebhookTarget):
     """`IProduct` properties which require launchpad.Edit permission."""
 
     @mutator_for(IProductView["bug_sharing_policy"])
diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
index cf812a7..045fb98 100644
--- a/lib/lp/registry/model/distribution.py
+++ b/lib/lp/registry/model/distribution.py
@@ -172,6 +172,7 @@ from lp.services.helpers import backslashreplace, shortlist
 from lp.services.propertycache import cachedproperty, get_property_cache
 from lp.services.webapp.interfaces import ILaunchBag
 from lp.services.webapp.url import urlparse
+from lp.services.webhooks.model import WebhookTargetMixin
 from lp.services.worlddata.model.country import Country
 from lp.soyuz.enums import (
     ArchivePurpose,
@@ -238,6 +239,7 @@ class Distribution(
     TranslationPolicyMixin,
     InformationTypeMixin,
     SharingPolicyMixin,
+    WebhookTargetMixin,
 ):
     """A distribution of an operating system, e.g. Debian GNU/Linux."""
 
@@ -2312,6 +2314,10 @@ class Distribution(
             .one()
         )
 
+    @property
+    def valid_webhook_event_types(self):
+        return ["bug:0.1", "bug:comment:0.1"]
+
 
 @implementer(IDistributionSet)
 class DistributionSet:
diff --git a/lib/lp/registry/model/distributionsourcepackage.py b/lib/lp/registry/model/distributionsourcepackage.py
index 0b6a1ce..5235ff7 100644
--- a/lib/lp/registry/model/distributionsourcepackage.py
+++ b/lib/lp/registry/model/distributionsourcepackage.py
@@ -46,6 +46,7 @@ from lp.services.database.decoratedresultset import DecoratedResultSet
 from lp.services.database.interfaces import IStore
 from lp.services.database.stormbase import StormBase
 from lp.services.propertycache import cachedproperty
+from lp.services.webhooks.model import WebhookTargetMixin
 from lp.soyuz.enums import ArchivePurpose, PackagePublishingStatus
 from lp.soyuz.model.archive import Archive
 from lp.soyuz.model.distributionsourcepackagerelease import (
@@ -88,6 +89,7 @@ class DistributionSourcePackage(
     HasCustomLanguageCodesMixin,
     HasMergeProposalsMixin,
     HasDriversMixin,
+    WebhookTargetMixin,
 ):
     """This is a "Magic Distribution Source Package". It is not an
     SQLObject, but instead it represents a source package with a particular
@@ -561,6 +563,10 @@ class DistributionSourcePackage(
         if dsp is None:
             cls._new(distribution, sourcepackagename)
 
+    @property
+    def valid_webhook_event_types(self):
+        return ["bug:0.1", "bug:comment:0.1"]
+
 
 @implementer(transaction.interfaces.ISynchronizer)
 class ThreadLocalLRUCache(LRUCache, local):
diff --git a/lib/lp/registry/model/product.py b/lib/lp/registry/model/product.py
index 4bbf796..3c95e99 100644
--- a/lib/lp/registry/model/product.py
+++ b/lib/lp/registry/model/product.py
@@ -164,6 +164,7 @@ from lp.services.propertycache import cachedproperty, get_property_cache
 from lp.services.statistics.interfaces.statistic import ILaunchpadStatisticSet
 from lp.services.webapp.interfaces import ILaunchBag
 from lp.services.webapp.snapshot import notify_modified
+from lp.services.webhooks.model import WebhookTargetMixin
 from lp.translations.enums import TranslationPermission
 from lp.translations.interfaces.customlanguagecode import (
     IHasCustomLanguageCodes,
@@ -265,6 +266,7 @@ class Product(
     HasAliasMixin,
     HasCustomLanguageCodesMixin,
     SharingPolicyMixin,
+    WebhookTargetMixin,
 ):
     """A Product."""
 
@@ -1593,6 +1595,10 @@ class Product(
             .is_empty()
         )
 
+    @property
+    def valid_webhook_event_types(self):
+        return ["bug:0.1", "bug:comment:0.1"]
+
 
 def get_precached_products(
     products,
diff --git a/lib/lp/services/features/flags.py b/lib/lp/services/features/flags.py
index 4b19185..5d42d9d 100644
--- a/lib/lp/services/features/flags.py
+++ b/lib/lp/services/features/flags.py
@@ -295,6 +295,14 @@ flag_info = sorted(
             "",
             "",
         ),
+        (
+            "bugs.webhooks.enabled",
+            "boolean",
+            "If true, allow adding webhooks to bug updates and comments",
+            "",
+            "",
+            "",
+        ),
     ]
 )
 
diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
index b31f29d..898aeb8 100644
--- a/lib/lp/services/webhooks/interfaces.py
+++ b/lib/lp/services/webhooks/interfaces.py
@@ -48,6 +48,8 @@ from lp.services.webservice.apihelpers import (
 )
 
 WEBHOOK_EVENT_TYPES = {
+    "bug:0.1": "Bug change",
+    "bug:comment:0.1": "Bug comment",
     "bzr:push:0.1": "Bazaar push",
     "charm-recipe:build:0.1": "Charm recipe build",
     "git:push:0.1": "Git push",
diff --git a/lib/lp/services/webhooks/tests/test_browser.py b/lib/lp/services/webhooks/tests/test_browser.py
index 65ba0c3..a155c00 100644
--- a/lib/lp/services/webhooks/tests/test_browser.py
+++ b/lib/lp/services/webhooks/tests/test_browser.py
@@ -10,6 +10,7 @@ import transaction
 from testtools.matchers import MatchesAll, MatchesStructure, Not
 from zope.component import getUtility
 
+from lp.bugs.interfaces.bugtarget import BUG_WEBHOOKS_FEATURE_FLAG
 from lp.charms.interfaces.charmrecipe import (
     CHARM_RECIPE_ALLOW_CREATE,
     CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
@@ -173,13 +174,54 @@ class CharmRecipeTestHelpers:
         return [obj]
 
 
+class BugUpdateTestHelpersBase:
+
+    # Overriding this since product webhooks don't have breadcrumbs
+    _webhook_listing = soupmatchers.HTMLContains(add_webhook_tag)
+
+    event_type = "bug:0.1"
+    expected_event_types = [
+        ("bug:0.1", "Bug change"),
+        ("bug:comment:0.1", "Bug comment"),
+    ]
+
+    def getTraversalStack(self, obj):
+        return [obj]
+
+
+class ProductTestHelpers(BugUpdateTestHelpersBase):
+    def makeTarget(self):
+        self.useFixture(FeatureFixture({BUG_WEBHOOKS_FEATURE_FLAG: "on"}))
+        owner = self.factory.makePerson()
+        return self.factory.makeProduct(owner=owner)
+
+
+class DistributionTestHelpers(BugUpdateTestHelpersBase):
+    def makeTarget(self):
+        self.useFixture(FeatureFixture({BUG_WEBHOOKS_FEATURE_FLAG: "on"}))
+        owner = self.factory.makePerson()
+        return self.factory.makeDistribution(owner=owner)
+
+
+class DistributionSourcePackageTestHelpers(BugUpdateTestHelpersBase):
+    def get_target_owner(self):
+        return self.target.distribution.owner
+
+    def makeTarget(self):
+        self.useFixture(FeatureFixture({BUG_WEBHOOKS_FEATURE_FLAG: "on"}))
+        return self.factory.makeDistributionSourcePackage()
+
+
 class WebhookTargetViewTestHelpers:
     def setUp(self):
         super().setUp()
         self.target = self.makeTarget()
-        self.owner = self.target.owner
+        self.owner = self.get_target_owner()
         login_person(self.owner)
 
+    def get_target_owner(self):
+        return self.target.owner
+
     def makeView(self, name, **kwargs):
         # XXX cjwatson 2020-02-06: We need to give the view a
         # LaunchpadPrincipal rather than just a person, since otherwise bits
@@ -211,6 +253,7 @@ class WebhookTargetViewTestHelpers:
 class TestWebhooksViewBase(WebhookTargetViewTestHelpers):
 
     layer = DatabaseFunctionalLayer
+    _webhook_listing = webhook_listing_constants
 
     def makeHooksAndMatchers(self, count):
         hooks = [
@@ -256,7 +299,7 @@ class TestWebhooksViewBase(WebhookTargetViewTestHelpers):
         self.assertThat(
             self.makeView("+webhooks")(),
             MatchesAll(
-                webhook_listing_constants,
+                self._webhook_listing,
                 Not(soupmatchers.HTMLContains(webhook_listing_tag)),
             ),
         )
@@ -267,7 +310,7 @@ class TestWebhooksViewBase(WebhookTargetViewTestHelpers):
         self.assertThat(
             self.makeView("+webhooks")(),
             MatchesAll(
-                webhook_listing_constants,
+                self._webhook_listing,
                 soupmatchers.HTMLContains(webhook_listing_tag, *link_matchers),
                 Not(soupmatchers.HTMLContains(batch_nav_tag)),
             ),
@@ -279,7 +322,7 @@ class TestWebhooksViewBase(WebhookTargetViewTestHelpers):
         self.assertThat(
             self.makeView("+webhooks")(),
             MatchesAll(
-                webhook_listing_constants,
+                self._webhook_listing,
                 soupmatchers.HTMLContains(
                     webhook_listing_tag, batch_nav_tag, *link_matchers[:5]
                 ),
@@ -342,6 +385,29 @@ class TestWebhooksViewCharmRecipe(
     pass
 
 
+class TestWebhooksViewProductBugUpdate(
+    ProductTestHelpers, TestWebhooksViewBase, TestCaseWithFactory
+):
+
+    pass
+
+
+class TestWebhooksViewDistributionBugUpdate(
+    DistributionTestHelpers, TestWebhooksViewBase, TestCaseWithFactory
+):
+
+    pass
+
+
+class TestWebhooksViewDistributionSourcePackageBugUpdate(
+    DistributionSourcePackageTestHelpers,
+    TestWebhooksViewBase,
+    TestCaseWithFactory,
+):
+
+    pass
+
+
 class TestWebhookAddViewBase(WebhookTargetViewTestHelpers):
 
     layer = DatabaseFunctionalLayer
@@ -491,16 +557,42 @@ class TestWebhookAddViewCharmRecipe(
     pass
 
 
+class TestWebhookAddViewProductBugUpdate(
+    ProductTestHelpers, TestWebhookAddViewBase, TestCaseWithFactory
+):
+
+    pass
+
+
+class TestWebhookAddViewDistributionBugUpdate(
+    DistributionTestHelpers, TestWebhookAddViewBase, TestCaseWithFactory
+):
+
+    pass
+
+
+class TestWebhookAddViewDistributionSourcePackageBugUpdate(
+    DistributionSourcePackageTestHelpers,
+    TestWebhookAddViewBase,
+    TestCaseWithFactory,
+):
+
+    pass
+
+
 class WebhookViewTestHelpers:
     def setUp(self):
         super().setUp()
         self.target = self.makeTarget()
-        self.owner = self.target.owner
+        self.owner = self.get_target_owner()
         self.webhook = self.factory.makeWebhook(
             target=self.target, delivery_url="http://example.com/original";
         )
         login_person(self.owner)
 
+    def get_target_owner(self):
+        return self.target.owner
+
     def makeView(self, name, **kwargs):
         view = create_view(self.webhook, name, principal=self.owner, **kwargs)
         # To test the breadcrumbs we need a correct traversal stack.
@@ -728,3 +820,26 @@ class TestWebhookDeleteViewCharmRecipe(
 ):
 
     pass
+
+
+class TestWebhookDeleteViewProductBugUpdate(
+    ProductTestHelpers, TestWebhookDeleteViewBase, TestCaseWithFactory
+):
+
+    pass
+
+
+class TestWebhookDeleteViewDistributionBugUpdate(
+    DistributionTestHelpers, TestWebhookDeleteViewBase, TestCaseWithFactory
+):
+
+    pass
+
+
+class TestWebhookDeleteViewDistributionSourcePackageBugUpdate(
+    DistributionSourcePackageTestHelpers,
+    TestWebhookDeleteViewBase,
+    TestCaseWithFactory,
+):
+
+    pass
diff --git a/lib/lp/services/webhooks/tests/test_model.py b/lib/lp/services/webhooks/tests/test_model.py
index 6a128d6..c148e24 100644
--- a/lib/lp/services/webhooks/tests/test_model.py
+++ b/lib/lp/services/webhooks/tests/test_model.py
@@ -560,6 +560,43 @@ class TestWebhookSetBugBase(TestWebhookSetBase):
             self.assertEqual(delivery.payload, {"some": "payload"})
 
 
+class TestWebhookSetProductBugComment(
+    TestWebhookSetBugBase, TestCaseWithFactory
+):
+
+    event_type = "bug:comment:0.1"
+
+    def makeTarget(self, **kwargs):
+        return self.factory.makeProduct(**kwargs)
+
+
+class TestWebhookSetDistributionBugComment(
+    TestWebhookSetBugBase, TestCaseWithFactory
+):
+
+    event_type = "bug:comment:0.1"
+
+    def makeTarget(self, **kwargs):
+        return self.factory.makeDistribution(**kwargs)
+
+
+class TestWebhookSetDistributionSourcePackageBugComment(
+    TestWebhookSetBugBase, TestCaseWithFactory
+):
+
+    event_type = "bug:comment:0.1"
+
+    def get_target_owner(self, target):
+        return target.distribution.owner
+
+    def makeTarget(self, **kwargs):
+        with admin_logged_in():
+            distribution = self.factory.makeDistribution(**kwargs)
+            return self.factory.makeDistributionSourcePackage(
+                distribution=distribution
+            )
+
+
 class TestWebhookSetProductBugUpdate(
     TestWebhookSetBugBase, TestCaseWithFactory
 ):
diff --git a/lib/lp/services/webhooks/tests/test_webservice.py b/lib/lp/services/webhooks/tests/test_webservice.py
index 7f84a14..a6f9c0b 100644
--- a/lib/lp/services/webhooks/tests/test_webservice.py
+++ b/lib/lp/services/webhooks/tests/test_webservice.py
@@ -45,17 +45,20 @@ class TestWebhook(TestCaseWithFactory):
 
     def setUp(self):
         super().setUp()
-        target = self.factory.makeGitRepository()
-        self.owner = target.owner
+        self.target = self.factory.makeGitRepository()
+        self.owner = self.get_target_owner()
         with person_logged_in(self.owner):
             self.webhook = self.factory.makeWebhook(
-                target=target, delivery_url="http://example.com/ep";
+                target=self.target, delivery_url="http://example.com/ep";
             )
             self.webhook_url = api_url(self.webhook)
         self.webservice = webservice_for_person(
             self.owner, permission=OAuthPermission.WRITE_PRIVATE
         )
 
+    def get_target_owner(self):
+        return self.target.owner
+
     def test_get(self):
         representation = self.webservice.get(
             self.webhook_url, api_version="devel"
@@ -262,11 +265,11 @@ class TestWebhookDelivery(TestCaseWithFactory):
 
     def setUp(self):
         super().setUp()
-        target = self.factory.makeGitRepository()
-        self.owner = target.owner
+        self.target = self.factory.makeGitRepository()
+        self.owner = self.get_target_owner()
         with person_logged_in(self.owner):
             self.webhook = self.factory.makeWebhook(
-                target=target, delivery_url="http://example.com/ep";
+                target=self.target, delivery_url="http://example.com/ep";
             )
             self.webhook_url = api_url(self.webhook)
             self.delivery = self.webhook.ping()
@@ -275,6 +278,9 @@ class TestWebhookDelivery(TestCaseWithFactory):
             self.owner, permission=OAuthPermission.WRITE_PRIVATE
         )
 
+    def get_target_owner(self):
+        return self.target.owner
+
     def test_get(self):
         representation = self.webservice.get(
             self.delivery_url, api_version="devel"
@@ -355,12 +361,15 @@ class TestWebhookTargetBase:
     def setUp(self):
         super().setUp()
         self.target = self.makeTarget()
-        self.owner = self.target.owner
+        self.owner = self.get_target_owner()
         self.target_url = api_url(self.target)
         self.webservice = webservice_for_person(
             self.owner, permission=OAuthPermission.WRITE_PRIVATE
         )
 
+    def get_target_owner(self):
+        return self.target.owner
+
     def test_webhooks(self):
         with person_logged_in(self.owner):
             for ep in ("http://example.com/ep1";, "http://example.com/ep2";):
@@ -511,3 +520,36 @@ class TestWebhookTargetCharmRecipe(TestWebhookTargetBase, TestCaseWithFactory):
             }
         ):
             return self.factory.makeCharmRecipe(registrant=owner, owner=owner)
+
+
+class TestWebhookTargetProduct(TestWebhookTargetBase, TestCaseWithFactory):
+
+    event_type = "bug:0.1"
+
+    def makeTarget(self):
+        owner = self.factory.makePerson()
+        return self.factory.makeProduct(owner=owner)
+
+
+class TestWebhookTargetDistribution(
+    TestWebhookTargetBase, TestCaseWithFactory
+):
+
+    event_type = "bug:0.1"
+
+    def makeTarget(self):
+        owner = self.factory.makePerson()
+        return self.factory.makeDistribution(owner=owner)
+
+
+class TestWebhookTargetDistributionSourcePackage(
+    TestWebhookTargetBase, TestCaseWithFactory
+):
+
+    event_type = "bug:0.1"
+
+    def makeTarget(self):
+        return self.factory.makeDistributionSourcePackage()
+
+    def get_target_owner(self):
+        return self.target.distribution.owner