← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ines-almeida/launchpad:webhook-patterns/add-functionality into launchpad:master

 

Ines Almeida has proposed merging ~ines-almeida/launchpad:webhook-patterns/add-functionality into launchpad:master with ~ines-almeida/launchpad:webhook-patterns/update-models as a prerequisite.

Commit message:
Filter git repository webhooks by git_ref_pattern field

Git push, merge proposal and CI Build events (all events associated with a git repository/git references/git branches) will now be matched against the webhook's git_ref_pattern field. The webhook is not be triggered if there is a git_ref_pattern but it doesn't match the git references associated with the events.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This MP follows this one: https://code.launchpad.net/~ines-almeida/launchpad/+git/launchpad/+merge/446948

In the first we update the Webhook and CIBuild models. In this one we add all the remaining functionality for filtering webhook triggers by a git ref pattern:
 - Update 'WebhookSet.trigger()' function to match against the pattern
 - Update UI to show new `git_ref_pattern` field for git repo webhooks
 - FilterCI Builds, Merge Proposal and Git Push events
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ines-almeida/launchpad:webhook-patterns/add-functionality into launchpad:master.
diff --git a/lib/lp/code/model/gitjob.py b/lib/lp/code/model/gitjob.py
index 566c728..1c51b94 100644
--- a/lib/lp/code/model/gitjob.py
+++ b/lib/lp/code/model/gitjob.py
@@ -253,8 +253,12 @@ class GitRefScanJob(GitJobDerived):
                     upserted_refs,
                     removed_refs,
                 )
+                git_refs = list(payload["ref_changes"].keys())
                 getUtility(IWebhookSet).trigger(
-                    self.repository, "git:push:0.1", payload
+                    self.repository,
+                    "git:push:0.1",
+                    payload,
+                    git_refs=git_refs,
                 )
         except LostObjectError:
             log.info(
diff --git a/lib/lp/code/model/tests/test_branchmergeproposal.py b/lib/lp/code/model/tests/test_branchmergeproposal.py
index 2a7007a..06f8c04 100644
--- a/lib/lp/code/model/tests/test_branchmergeproposal.py
+++ b/lib/lp/code/model/tests/test_branchmergeproposal.py
@@ -112,6 +112,7 @@ class WithVCSScenarios(WithScenarios):
         stacked_on=None,
         information_type=None,
         owner=None,
+        name=None,
     ):
         # Create the product pillar and its access policy if information
         # type is "PROPRIETARY".
@@ -128,10 +129,12 @@ class WithVCSScenarios(WithScenarios):
         kwargs = {"information_type": information_type, "owner": owner}
         if self.git:
             kwargs["target"] = product
-            return self.factory.makeGitRefs(**kwargs)[0]
+            paths = [name] if name else None
+            return self.factory.makeGitRefs(paths=paths, **kwargs)[0]
         else:
             kwargs["product"] = product
             kwargs["stacked_on"] = stacked_on
+            kwargs["name"] = name
             return self.factory.makeProductBranch(**kwargs)
 
     def makeBranchMergeProposal(
@@ -1297,16 +1300,17 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
         self.assertCorrectDelivery(expected_payload, hook, delivery)
         self.assertCorrectLogging(expected_redacted_payload, hook, logger)
 
-    def test_create_triggers_webhooks(self):
+    def test_create_triggers_webhooks(self, ref_pattern=None):
         # When a merge proposal is created, any relevant webhooks are
         # triggered.
         logger = self.useFixture(FakeLogger())
         source = self.makeBranch()
-        target = self.makeBranch(same_target_as=source)
+        target = self.makeBranch(same_target_as=source, name="foo-bar")
         registrant = self.factory.makePerson()
         hook = self.factory.makeWebhook(
             target=self.getWebhookTarget(target),
             event_types=["merge-proposal:0.1"],
+            git_ref_pattern=ref_pattern,
         )
         proposal = source.addLandingTarget(
             registrant, target, needs_review=True
@@ -1325,12 +1329,12 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
         self.assertCorrectDelivery(expected_payload, hook, delivery)
         self.assertCorrectLogging(expected_redacted_payload, hook, logger)
 
-    def test_modify_triggers_webhooks(self):
+    def test_modify_triggers_webhooks(self, ref_pattern=None):
         logger = self.useFixture(FakeLogger())
         # When an existing merge proposal is modified, any relevant webhooks
         # are triggered.
         source = self.makeBranch()
-        target = self.makeBranch(same_target_as=source)
+        target = self.makeBranch(same_target_as=source, name="foo-bar")
         registrant = self.factory.makePerson()
         proposal = source.addLandingTarget(
             registrant, target, needs_review=True
@@ -1338,6 +1342,7 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
         hook = self.factory.makeWebhook(
             target=self.getWebhookTarget(target),
             event_types=["merge-proposal:0.1"],
+            git_ref_pattern=ref_pattern,
         )
         login_person(target.owner)
         expected_payload = {
@@ -1364,12 +1369,12 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
         self.assertCorrectDelivery(expected_payload, hook, delivery)
         self.assertCorrectLogging(expected_redacted_payload, hook, logger)
 
-    def test_delete_triggers_webhooks(self):
+    def test_delete_triggers_webhooks(self, ref_pattern=None):
         # When an existing merge proposal is deleted, any relevant webhooks
         # are triggered.
         logger = self.useFixture(FakeLogger())
         source = self.makeBranch()
-        target = self.makeBranch(same_target_as=source)
+        target = self.makeBranch(same_target_as=source, name="foo-bar")
         registrant = self.factory.makePerson()
         proposal = source.addLandingTarget(
             registrant, target, needs_review=True
@@ -1377,6 +1382,7 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
         hook = self.factory.makeWebhook(
             target=self.getWebhookTarget(target),
             event_types=["merge-proposal:0.1"],
+            git_ref_pattern=ref_pattern,
         )
         login_person(target.owner)
         expected_payload = {
@@ -1394,6 +1400,77 @@ class TestMergeProposalWebhooks(WithVCSScenarios, TestCaseWithFactory):
         self.assertCorrectLogging(expected_redacted_payload, hook, logger)
 
 
+class TestGitMergeProposalWebhooks(TestMergeProposalWebhooks):
+    # Override scenarios to only have git related tests in this class
+    scenarios = [
+        ("git", {"git": True}),
+    ]
+
+    def test_create_triggers_webhooks_with_matching_ref_pattern(self):
+        self.test_create_triggers_webhooks("*foo*")
+
+    def test_modify_triggers_webhooks_with_matching_ref_pattern(self):
+        self.test_modify_triggers_webhooks("*foo*")
+
+    def test_delete_triggers_webhooks_with_matching_ref_pattern(self):
+        self.test_delete_triggers_webhooks("*foo*")
+
+    def test_create_doesnt_trigger_webhooks_without_matching_ref_pattern(self):
+        source = self.makeBranch()
+        target = self.makeBranch(same_target_as=source, name="foo-bar")
+        registrant = self.factory.makePerson()
+        hook = self.factory.makeWebhook(
+            target=self.getWebhookTarget(target),
+            event_types=["merge-proposal:0.1"],
+            git_ref_pattern="not-matching-test",
+        )
+        source.addLandingTarget(registrant, target, needs_review=True)
+        with admin_logged_in():
+            self.assertEqual(0, len(list(hook.deliveries)))
+
+    def test_modify_doesnt_trigger_webhooks_without_matching_ref_pattern(self):
+        source = self.makeBranch()
+        target = self.makeBranch(same_target_as=source, name="foo-bar")
+        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"],
+            git_ref_pattern="not-matching-test",
+        )
+
+        with person_logged_in(
+            target.owner
+        ), BranchMergeProposalNoPreviewDiffDelta.monitor(proposal):
+            proposal.setStatus(
+                BranchMergeProposalStatus.CODE_APPROVED, user=target.owner
+            )
+            proposal.description = "An excellent proposal."
+
+        with admin_logged_in():
+            self.assertEqual(0, len(list(hook.deliveries)))
+
+    def test_delete_doesnt_trigger_webhooks_without_matching_ref_pattern(self):
+        source = self.makeBranch()
+        target = self.makeBranch(same_target_as=source, name="foo-bar")
+        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"],
+            git_ref_pattern="not-matching-test",
+        )
+        with person_logged_in(target.owner):
+            proposal.deleteProposal()
+
+        with admin_logged_in():
+            self.assertEqual(0, len(list(hook.deliveries)))
+
+
 class TestGetAddress(TestCaseWithFactory):
     """Test that the address property gives expected results."""
 
diff --git a/lib/lp/code/model/tests/test_gitjob.py b/lib/lp/code/model/tests/test_gitjob.py
index 28f4a5f..36b26a2 100644
--- a/lib/lp/code/model/tests/test_gitjob.py
+++ b/lib/lp/code/model/tests/test_gitjob.py
@@ -230,6 +230,71 @@ class TestGitRefScanJob(TestCaseWithFactory):
                 ),
             )
 
+    def test_git_ref_pattern_match_triggers_webhooks(self):
+        # Jobs trigger any relevant webhooks if their `git_ref_pattern` matches
+        # at least one of the git references
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRefs(
+            repository, paths=["refs/heads/master", "refs/tags/1.0"]
+        )
+        hook = self.factory.makeWebhook(
+            target=repository,
+            event_types=["git:push:0.1"],
+            git_ref_pattern="*tags/1.0",
+        )
+        job = GitRefScanJob.create(repository)
+        paths = ("refs/heads/master", "refs/tags/2.0")
+        self.useFixture(GitHostingFixture(refs=self.makeFakeRefs(paths)))
+        with dbuser("branchscanner"):
+            JobRunner([job]).runAll()
+        delivery = hook.deliveries.one()
+        sha1 = lambda s: hashlib.sha1(s).hexdigest()
+        payload_matcher = MatchesDict(
+            {
+                "git_repository": Equals("/" + repository.unique_name),
+                "git_repository_path": Equals(repository.unique_name),
+                "ref_changes": Equals(
+                    {
+                        "refs/tags/1.0": {
+                            "old": {"commit_sha1": sha1(b"refs/tags/1.0")},
+                            "new": None,
+                        },
+                        "refs/tags/2.0": {
+                            "old": None,
+                            "new": {"commit_sha1": sha1(b"refs/tags/2.0")},
+                        },
+                    }
+                ),
+            }
+        )
+        self.assertThat(
+            delivery,
+            MatchesStructure(
+                event_type=Equals("git:push:0.1"), payload=payload_matcher
+            ),
+        )
+
+    def test_git_ref_pattern_doesnt_match_doesnt_trigger_webhooks(self):
+        # Jobs are not triggered if `git_ref_pattern` doesn't match any of the
+        # git references.
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRefs(
+            repository, paths=["refs/heads/master", "refs/tags/1.0"]
+        )
+        hook = self.factory.makeWebhook(
+            target=repository,
+            event_types=["git:push:0.1"],
+            git_ref_pattern="*foo",
+        )
+        job = GitRefScanJob.create(repository)
+        paths = ("refs/heads/master", "refs/tags/2.0")
+        self.useFixture(GitHostingFixture(refs=self.makeFakeRefs(paths)))
+        with dbuser("branchscanner"):
+            JobRunner([job]).runAll()
+
+        with person_logged_in(repository.owner):
+            self.assertEqual(0, len(list(hook.deliveries)))
+
     def test_triggers_webhooks_with_oci_project_as_repository_target(self):
         # Jobs trigger any relevant webhooks when they're enabled.
         logger = self.useFixture(FakeLogger())
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 057c80a..89a44f5 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -4172,13 +4172,15 @@ class TestGitRepositoryRequestCIBuilds(TestCaseWithFactory):
         )
         self.assertEqual("", logger.getLogBuffer())
 
-    def test_triggers_webhooks(self):
+    def test_triggers_webhooks(self, git_ref_pattern=None):
         # Requesting CI builds triggers any relevant webhooks.
         self.useFixture(FeatureFixture({CI_WEBHOOKS_FEATURE_FLAG: "on"}))
         logger = self.useFixture(FakeLogger())
         repository = self.factory.makeGitRepository()
         hook = self.factory.makeWebhook(
-            target=repository, event_types=["ci:build:0.1"]
+            target=repository,
+            event_types=["ci:build:0.1"],
+            git_ref_pattern=git_ref_pattern,
         )
         ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
         distroseries = self.factory.makeDistroSeries(distribution=ubuntu)
@@ -4249,6 +4251,59 @@ class TestGitRepositoryRequestCIBuilds(TestCaseWithFactory):
                 ),
             )
 
+    def test_triggers_webhooks_when_git_ref_pattern_matches(self):
+        self.test_triggers_webhooks(git_ref_pattern="*test")
+
+    def test_doesnt_trigger_webhooks_when_git_ref_pattern_doesnt_matches(self):
+        # Requesting CI builds doesnt trigger relevant webhooks when git ref
+        # pattern doesn't match.
+        self.useFixture(FeatureFixture({CI_WEBHOOKS_FEATURE_FLAG: "on"}))
+        repository = self.factory.makeGitRepository()
+        hook = self.factory.makeWebhook(
+            target=repository,
+            event_types=["ci:build:0.1"],
+            git_ref_pattern="non_matching_pattern",
+        )
+        ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        distroseries = self.factory.makeDistroSeries(distribution=ubuntu)
+        das = self.factory.makeBuildableDistroArchSeries(
+            distroseries=distroseries
+        )
+        configuration = dedent(
+            """\
+            pipeline: [test]
+            jobs:
+                test:
+                    series: {series}
+                    architectures: [{architecture}]
+            """.format(
+                series=distroseries.name, architecture=das.architecturetag
+            )
+        ).encode()
+        new_commit = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
+        self.useFixture(
+            GitHostingFixture(
+                commits=[
+                    {
+                        "sha1": new_commit,
+                        "blobs": {".launchpad.yaml": configuration},
+                    },
+                ]
+            )
+        )
+        with dbuser("branchscanner"):
+            repository.createOrUpdateRefs(
+                {
+                    "refs/heads/test": {
+                        "sha1": new_commit,
+                        "type": GitObjectType.COMMIT,
+                    }
+                }
+            )
+
+        getUtility(ICIBuildSet).findByGitRepository(repository)
+        self.assertIsNone(hook.deliveries.one())
+
 
 class TestGitRepositoryGetBlob(TestCaseWithFactory):
     """Tests for retrieving files from a Git repository."""
diff --git a/lib/lp/code/subscribers/branchmergeproposal.py b/lib/lp/code/subscribers/branchmergeproposal.py
index 4eb94dc..32da5a5 100644
--- a/lib/lp/code/subscribers/branchmergeproposal.py
+++ b/lib/lp/code/subscribers/branchmergeproposal.py
@@ -54,8 +54,19 @@ def _trigger_webhook(merge_proposal, payload):
         target = merge_proposal.target_branch
     else:
         target = merge_proposal.target_git_repository
+
+    git_refs = []
+    if "new" in payload:
+        git_refs.append(payload["new"]["target_git_path"])
+    if "old" in payload:
+        git_refs.append(payload["old"]["target_git_path"])
+
     getUtility(IWebhookSet).trigger(
-        target, "merge-proposal:0.1", payload, context=merge_proposal
+        target,
+        "merge-proposal:0.1",
+        payload,
+        context=merge_proposal,
+        git_refs=git_refs,
     )
 
 
diff --git a/lib/lp/code/subscribers/cibuild.py b/lib/lp/code/subscribers/cibuild.py
index 83d3363..90aa556 100644
--- a/lib/lp/code/subscribers/cibuild.py
+++ b/lib/lp/code/subscribers/cibuild.py
@@ -25,7 +25,10 @@ def _trigger_ci_build_webhook(build: ICIBuild, action: str) -> None:
             )
         )
         getUtility(IWebhookSet).trigger(
-            build.git_repository, "ci:build:0.1", payload
+            build.git_repository,
+            "ci:build:0.1",
+            payload,
+            git_refs=build.git_refs,
         )
 
 
diff --git a/lib/lp/services/webhooks/browser.py b/lib/lp/services/webhooks/browser.py
index 991d7ef..9b9104e 100644
--- a/lib/lp/services/webhooks/browser.py
+++ b/lib/lp/services/webhooks/browser.py
@@ -19,6 +19,7 @@ from lp.app.browser.launchpadform import (
     action,
 )
 from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
+from lp.code.interfaces.gitrepository import IGitRepository
 from lp.services.propertycache import cachedproperty
 from lp.services.webapp import (
     LaunchpadView,
@@ -100,7 +101,14 @@ class WebhookBreadcrumb(Breadcrumb):
 
 class WebhookEditSchema(Interface):
     use_template(
-        IWebhook, include=["delivery_url", "event_types", "active", "secret"]
+        IWebhook,
+        include=[
+            "delivery_url",
+            "event_types",
+            "active",
+            "secret",
+            "git_ref_pattern",
+        ],
     )
 
 
@@ -111,6 +119,14 @@ class WebhookAddView(LaunchpadFormView):
     custom_widget_event_types = LabeledMultiCheckBoxWidget
     next_url = None
 
+    def initialize(self):
+        # Don't show `git_ref_pattern` in webhook creation page unless target
+        # is Git Repository
+        self.field_names = ["delivery_url", "event_types", "active", "secret"]
+        if IGitRepository.providedBy(self.context):
+            self.field_names.append("git_ref_pattern")
+        super().initialize()
+
     @property
     def inside_breadcrumb(self):
         return WebhooksBreadcrumb(self.context)
@@ -143,11 +159,16 @@ class WebhookView(LaunchpadEditFormView):
     label = "Manage webhook"
 
     schema = WebhookEditSchema
-    # XXX wgrant 2015-08-04: Need custom widget for secret.
-    field_names = ["delivery_url", "event_types", "active"]
     custom_widget_event_types = LabeledMultiCheckBoxWidget
 
     def initialize(self):
+        # Don't show `git_ref_pattern` in webhook edit page unless target
+        # is Git Repository
+        # XXX wgrant 2015-08-04: Need custom widget for secret.
+        self.field_names = ["delivery_url", "event_types", "active"]
+        if IGitRepository.providedBy(self.context.target):
+            self.field_names.append("git_ref_pattern")
+
         super().initialize()
         cache = IJSONRequestCache(self.request)
         cache.objects["deliveries"] = list(self.deliveries.batch)
diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
index 72c2e12..f3309e0 100644
--- a/lib/lp/services/webhooks/interfaces.py
+++ b/lib/lp/services/webhooks/interfaces.py
@@ -240,7 +240,7 @@ class IWebhookSet(Interface):
     def findByTarget(target):
         """Find all webhooks for the given target."""
 
-    def trigger(target, event_type, payload, context=None):
+    def trigger(target, event_type, payload, context=None, git_refs=None):
         """Trigger subscribed webhooks to deliver a payload."""
 
 
diff --git a/lib/lp/services/webhooks/model.py b/lib/lp/services/webhooks/model.py
index 605d5c9..ec3e283 100644
--- a/lib/lp/services/webhooks/model.py
+++ b/lib/lp/services/webhooks/model.py
@@ -13,6 +13,7 @@ import re
 import socket
 from datetime import datetime, timedelta, timezone
 from fnmatch import fnmatch
+from typing import List
 from urllib.parse import urlsplit
 
 import iso8601
@@ -328,7 +329,28 @@ class WebhookSet:
             )
         )
 
-    def trigger(self, target, event_type, payload, context=None):
+    def _checkGitRefs(self, webhook: Webhook, git_refs: List[str]):
+        """Check if any of the `git_refs` matches the webhook's
+        `git_ref_pattern`.
+
+        :return: True if any of the git_refs match, if there are no git_refs,
+        or if there is no git_ref_pattern.
+            otherwise False.
+        """
+        if not webhook.git_ref_pattern or git_refs is None:
+            return True
+
+        for git_ref in git_refs:
+            if webhook.checkGitRefPattern(git_ref):
+                return True
+        return False
+
+    def trigger(
+        self, target, event_type, payload, context=None, git_refs=None
+    ):
+        print("!!!!!!!!!!!!!!")
+        print(git_refs)
+
         if context is None:
             context = target
         user = removeSecurityProxy(target).owner
@@ -338,7 +360,11 @@ class WebhookSet:
         # 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:
+            if (
+                webhook.active
+                and event_type in webhook.event_types
+                and self._checkGitRefs(webhook, git_refs)
+            ):
                 WebhookDeliveryJob.create(webhook, event_type, payload)
 
 
diff --git a/lib/lp/services/webhooks/tests/test_browser.py b/lib/lp/services/webhooks/tests/test_browser.py
index f8a76a7..a218a96 100644
--- a/lib/lp/services/webhooks/tests/test_browser.py
+++ b/lib/lp/services/webhooks/tests/test_browser.py
@@ -14,6 +14,7 @@ from lp.charms.interfaces.charmrecipe import (
     CHARM_RECIPE_ALLOW_CREATE,
     CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
 )
+from lp.code.interfaces.gitrepository import IGitRepository
 from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
 from lp.services.features.testing import FeatureFixture
 from lp.services.webapp.interfaces import IPlacelessAuthUtility
@@ -418,19 +419,37 @@ class TestWebhookAddViewBase(WebhookTargetViewTestHelpers):
             ),
         )
 
-    def test_creates(self):
-        view = self.makeView(
-            "+new-webhook",
-            method="POST",
-            form={
-                "field.delivery_url": "http://example.com/test";,
-                "field.active": "on",
-                "field.event_types-empty-marker": "1",
-                "field.event_types": self.event_type,
-                "field.secret": "secret code",
-                "field.actions.new": "Add webhook",
-            },
-        )
+    def test_rendering_check_git_ref_pattern_field(self):
+        # Verify that `git_ref_pattern` field exists in webhook's forms for git
+        # repositories, but not for other webhook forms
+        if IGitRepository.providedBy(self.target):
+            self.assertThat(
+                self.makeView("+new-webhook")(),
+                soupmatchers.HTMLContains(
+                    soupmatchers.TagWithId("field.git_ref_pattern", count=1)
+                ),
+            )
+        else:
+            self.assertThat(
+                self.makeView("+new-webhook")(),
+                soupmatchers.HTMLContains(
+                    soupmatchers.TagWithId("field.git_ref_pattern", count=0)
+                ),
+            )
+
+    def test_creates(self, ref_pattern_input=None, expected_ref_pattern=None):
+        form_data = {
+            "field.delivery_url": "http://example.com/test";,
+            "field.active": "on",
+            "field.event_types-empty-marker": "1",
+            "field.event_types": self.event_type,
+            "field.secret": "secret code",
+            "field.actions.new": "Add webhook",
+        }
+        if ref_pattern_input:
+            form_data.update({"field.git_ref_pattern": ref_pattern_input})
+
+        view = self.makeView("+new-webhook", method="POST", form=form_data)
         self.assertEqual([], view.errors)
         hook = self.target.webhooks.one()
         self.assertThat(
@@ -442,9 +461,23 @@ class TestWebhookAddViewBase(WebhookTargetViewTestHelpers):
                 active=True,
                 event_types=[self.event_type],
                 secret="secret code",
+                git_ref_pattern=expected_ref_pattern,
             ),
         )
 
+    def test_creates_with_git_ref_pattern_field(self):
+        # Adding webhook with `git_ref_pattern` field works for git repository
+        # webhooks, but not for other webhooks (creation is successful, but
+        # `git_ref_pattern` param is ignored)
+        if IGitRepository.providedBy(self.target):
+            self.test_creates(
+                ref_pattern_input="*test*", expected_ref_pattern="*test*"
+            )
+        else:
+            self.test_creates(
+                ref_pattern_input="*test*", expected_ref_pattern=None
+            )
+
     def test_rejects_bad_scheme(self):
         transaction.commit()
         view = self.makeView(
@@ -603,16 +636,37 @@ class TestWebhookViewBase(WebhookViewTestHelpers):
             ),
         )
 
-    def test_saves(self):
+    def test_rendering_check_git_ref_pattern_field(self):
+        # Verify that `git_ref_pattern` field exists in webhook's forms for git
+        # repositories, but not for other webhook forms
+        if IGitRepository.providedBy(self.target):
+            self.assertThat(
+                self.makeView("+index")(),
+                soupmatchers.HTMLContains(
+                    soupmatchers.TagWithId("field.git_ref_pattern", count=1)
+                ),
+            )
+        else:
+            self.assertThat(
+                self.makeView("+index")(),
+                soupmatchers.HTMLContains(
+                    soupmatchers.TagWithId("field.git_ref_pattern", count=0)
+                ),
+            )
+
+    def test_saves(self, ref_pattern_input=None, expected_ref_pattern=None):
+        form_data = {
+            "field.delivery_url": "http://example.com/edited";,
+            "field.active": "off",
+            "field.event_types-empty-marker": "1",
+            "field.actions.save": "Save webhook",
+        }
+        if ref_pattern_input:
+            form_data.update({"field.git_ref_pattern": ref_pattern_input})
         view = self.makeView(
             "+index",
             method="POST",
-            form={
-                "field.delivery_url": "http://example.com/edited";,
-                "field.active": "off",
-                "field.event_types-empty-marker": "1",
-                "field.actions.save": "Save webhook",
-            },
+            form=form_data,
         )
         self.assertEqual([], view.errors)
         self.assertThat(
@@ -621,9 +675,23 @@ class TestWebhookViewBase(WebhookViewTestHelpers):
                 delivery_url="http://example.com/edited";,
                 active=False,
                 event_types=[],
+                git_ref_pattern=expected_ref_pattern,
             ),
         )
 
+    def test_saves_with_git_ref_pattern_field(self):
+        # Editing `git_ref_pattern` field works for git repository webhooks,
+        # but not for other webhooks (edit is successful, but `git_ref_pattern`
+        # param is ignored)
+        if IGitRepository.providedBy(self.target):
+            self.test_saves(
+                ref_pattern_input="*test*", expected_ref_pattern="*test*"
+            )
+        else:
+            self.test_saves(
+                ref_pattern_input="*test*", expected_ref_pattern=None
+            )
+
     def test_rejects_bad_scheme(self):
         transaction.commit()
         view = self.makeView(
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index a7af4de..8514437 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -6884,6 +6884,7 @@ class LaunchpadObjectFactory(ObjectFactory):
         status=BuildStatus.NEEDSBUILD,
         builder=None,
         duration=None,
+        git_refs=None,
     ):
         """Make a new `CIBuild`."""
         if git_repository is None:
@@ -6900,6 +6901,7 @@ class LaunchpadObjectFactory(ObjectFactory):
             distro_arch_series,
             stages,
             date_created=date_created,
+            git_refs=git_refs,
         )
         if duration is not None:
             removeSecurityProxy(build).updateStatus(