From 1a4ec7e9266989207f879786a1c19b6d18180eb2 Mon Sep 17 00:00:00 2001 From: "James E. Blair" Date: Tue, 21 Feb 2023 14:54:10 -0800 Subject: [PATCH] Add GitHub pipeline trigger requirements This mimics a useful feature of the Gerrit driver and allows users to configure pipelines that trigger on events but only if certain conditions of the PR are met. Unlike the Gerrit driver, this embeds the entire require/reject filter within the trigger filter (the trigger filter has-a require or reject filter). This makes the code simpler and is easier for users to configure. If we like this approach, we should migrate the gerrit driver as well, and perhaps the other drivers. The "require-status" attribute already existed, but was undocumented. This documents it, adds backwards-compat handling for it, and deprecates it. Some documentation typos are also corrected. Change-Id: I4b6dd8c970691b1e74ffd5a96c2be4b8075f1a87 --- doc/source/drivers/gerrit.rst | 3 +- doc/source/drivers/github.rst | 36 +- ...ithub-trigger-status-948e81b9f45418f1.yaml | 17 + .../layouts/github-trigger-requirements.yaml | 112 ++++ tests/unit/test_github_requirements.py | 178 ++++++ zuul/driver/github/githubmodel.py | 539 ++++++++++-------- zuul/driver/github/githubsource.py | 28 +- zuul/driver/github/githubtrigger.py | 7 +- 8 files changed, 658 insertions(+), 262 deletions(-) create mode 100644 releasenotes/notes/github-trigger-status-948e81b9f45418f1.yaml create mode 100644 tests/fixtures/layouts/github-trigger-requirements.yaml diff --git a/doc/source/drivers/gerrit.rst b/doc/source/drivers/gerrit.rst index 4e7fc7cea4..4b9c930442 100644 --- a/doc/source/drivers/gerrit.rst +++ b/doc/source/drivers/gerrit.rst @@ -250,7 +250,8 @@ be able to invoke the ``gerrit stream-events`` command over SSH. This takes a list of approvals in the same format as :attr:`pipeline.trigger..require-approval` but - will fail to enter the pipeline if there is a matching approval. + the item will fail to enter the pipeline if there is a matching + approval. Reporter Configuration ---------------------- diff --git a/doc/source/drivers/github.rst b/doc/source/drivers/github.rst index 148c6f9769..7cacf45ac2 100644 --- a/doc/source/drivers/github.rst +++ b/doc/source/drivers/github.rst @@ -339,7 +339,7 @@ the following options. format of ``user:context:status``. For example, ``zuul_github_ci_bot:check_pipeline:success``. - .. attr: check + .. attr:: check This is only used for ``check_run`` events. It works similar to the ``status`` attribute and accepts a list of strings each of @@ -363,6 +363,38 @@ the following options. always sends full ref name, eg. ``refs/tags/bar`` and this string is matched against the regular expression. + .. attr:: require-status + + .. warning:: This is deprecated and will be removed in a future + version. Use :attr:`pipeline.trigger..require` instead. + + This may be used for any event. It requires that a certain kind + of status be present for the PR (the status could be added by + the event in question). It follows the same syntax as + :attr:`pipeline.require..status`. For each + specified criteria there must exist a matching status. + + This is ignored if the :attr:`pipeline.trigger..require` attribute is present. + + .. attr:: require + + This may be used for any event. It describes conditions that + must be met by the PR in order for the trigger event to match. + Those conditions may be satisfied by the event in question. It + follows the same syntax as :ref:`github_requirements`. + + .. attr:: reject + + This may be used for any event and is the mirror of + :attr:`pipeline.trigger..require`. It describes + conditions that when met by the PR cause the trigger event not + to match. Those conditions may be satisfied by the event in + question. It follows the same syntax as + :ref:`github_requirements`. + + Reporter Configuration ---------------------- Zuul reports back to GitHub via GitHub API. Available reports include a PR @@ -462,6 +494,8 @@ itself. Status name, description, and context is taken from the pipeline. .. _Github App: https://developer.github.com/apps/ +.. _github_requirements: + Requirements Configuration -------------------------- diff --git a/releasenotes/notes/github-trigger-status-948e81b9f45418f1.yaml b/releasenotes/notes/github-trigger-status-948e81b9f45418f1.yaml new file mode 100644 index 0000000000..19cac86423 --- /dev/null +++ b/releasenotes/notes/github-trigger-status-948e81b9f45418f1.yaml @@ -0,0 +1,17 @@ +--- +features: + - | + GitHub pipeline triggers now support embedded require and reject + filters in order to match. Any conditions set for the pipeline in + require or reject filters may also be set for event trigger + filters. + + This can be used to construct pipelines which trigger based on + certain events but only if certain other conditions are met. It + is distinct from pipeline requirements in that it only affects + items that are directly enqueued whereas pipeline requirements + affect dependencies as well. +deprecations: + - | + The `require-status` GitHub trigger attribute is deprecated. + Use :attr:`pipeline.trigger..require` instead. diff --git a/tests/fixtures/layouts/github-trigger-requirements.yaml b/tests/fixtures/layouts/github-trigger-requirements.yaml new file mode 100644 index 0000000000..5014df3bbf --- /dev/null +++ b/tests/fixtures/layouts/github-trigger-requirements.yaml @@ -0,0 +1,112 @@ +- pipeline: + name: require-status + manager: independent + trigger: + github: + - event: pull_request + action: comment + comment: test require-status + require: + status: + - zuul:tenant-one/check:success + success: + github: + comment: true + +- pipeline: + name: reject-status + manager: independent + trigger: + github: + - event: pull_request + action: comment + comment: test reject-status + reject: + status: + - zuul:tenant-one/check:failure + success: + github: + comment: true + +- pipeline: + name: require-review + manager: independent + trigger: + github: + - event: pull_request + action: comment + comment: test require-review + require: + review: + - type: approved + permission: write + success: + github: + comment: true + +- pipeline: + name: reject-review + manager: independent + trigger: + github: + - event: pull_request + action: comment + comment: test reject-review + reject: + review: + - type: changes_requested + permission: write + success: + github: + comment: true + +- pipeline: + name: require-label + manager: independent + trigger: + github: + - event: pull_request + action: comment + comment: test require-label + require: + label: + - approved + success: + github: + comment: true + +- pipeline: + name: reject-label + manager: independent + trigger: + github: + - event: pull_request + action: comment + comment: test reject-label + reject: + label: + - rejected + success: + github: + comment: true + +- job: + name: base + parent: null + run: playbooks/base.yaml + +- job: {name: require-status} +- job: {name: reject-status} +- job: {name: require-review} +- job: {name: reject-review} +- job: {name: require-label} +- job: {name: reject-label} + +- project: + name: org/project + require-status: {jobs: [require-status]} + reject-status: {jobs: [reject-status]} + require-review: {jobs: [require-review]} + reject-review: {jobs: [reject-review]} + require-label: {jobs: [require-label]} + reject-label: {jobs: [reject-label]} diff --git a/tests/unit/test_github_requirements.py b/tests/unit/test_github_requirements.py index ef1f759441..f3021d41de 100644 --- a/tests/unit/test_github_requirements.py +++ b/tests/unit/test_github_requirements.py @@ -678,3 +678,181 @@ class TestGithubAppRequirements(ZuulGithubAppTestCase): self.fake_github.emitEvent(comment) self.waitUntilSettled() self.assertEqual(len(self.history), 1) + + +class TestGithubTriggerRequirements(ZuulTestCase): + """Test pipeline and trigger requirements""" + config_file = 'zuul-github-driver.conf' + scheduler_count = 1 + + @simple_layout('layouts/github-trigger-requirements.yaml', driver='github') + def test_require_status(self): + # Test trigger require-status + jobname = 'require-status' + project = 'org/project' + A = self.fake_github.openFakePullRequest(project, 'master', 'A') + # A comment event that we will keep submitting to trigger + comment = A.getCommentAddedEvent(f'test {jobname}') + + # No status from zuul so should not be enqueued + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 0) + + # An error status should not cause it to be enqueued + self.fake_github.setCommitStatus(project, A.head_sha, 'error', + context='tenant-one/check') + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 0) + + # A success status goes in + self.fake_github.setCommitStatus(project, A.head_sha, 'success', + context='tenant-one/check') + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 1) + self.assertEqual(self.history[0].name, jobname) + + @simple_layout('layouts/github-trigger-requirements.yaml', driver='github') + def test_reject_status(self): + # Test trigger reject-status + jobname = 'reject-status' + project = 'org/project' + A = self.fake_github.openFakePullRequest(project, 'master', 'A') + # A comment event that we will keep submitting to trigger + comment = A.getCommentAddedEvent(f'test {jobname}') + + # No status from zuul so should be enqueued + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 1) + self.assertEqual(self.history[0].name, jobname) + + # A failure status should not cause it to be enqueued + self.fake_github.setCommitStatus(project, A.head_sha, 'failure', + context='tenant-one/check') + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 1) + + # A success status goes in + self.fake_github.setCommitStatus(project, A.head_sha, 'success', + context='tenant-one/check') + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 2) + self.assertEqual(self.history[1].name, jobname) + + @simple_layout('layouts/github-trigger-requirements.yaml', driver='github') + def test_require_review(self): + # Test trigger require-review + jobname = 'require-review' + project = 'org/project' + A = self.fake_github.openFakePullRequest(project, 'master', 'A') + A.writers.extend(('maintainer',)) + # A comment event that we will keep submitting to trigger + comment = A.getCommentAddedEvent(f'test {jobname}') + + # No review so should not be enqueued + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 0) + + # An changes requested review should not cause it to be enqueued + A.addReview('maintainer', 'CHANGES_REQUESTED') + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 0) + + # A positive review goes in + A.addReview('maintainer', 'APPROVED') + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 1) + self.assertEqual(self.history[0].name, jobname) + + @simple_layout('layouts/github-trigger-requirements.yaml', driver='github') + def test_reject_review(self): + # Test trigger reject-review + jobname = 'reject-review' + project = 'org/project' + A = self.fake_github.openFakePullRequest(project, 'master', 'A') + A.writers.extend(('maintainer',)) + # A comment event that we will keep submitting to trigger + comment = A.getCommentAddedEvent(f'test {jobname}') + + # No review so should be enqueued + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 1) + self.assertEqual(self.history[0].name, jobname) + + # An changes requested review should not cause it to be enqueued + A.addReview('maintainer', 'CHANGES_REQUESTED') + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 1) + + # A positive review goes in + A.addReview('maintainer', 'APPROVED') + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 2) + self.assertEqual(self.history[1].name, jobname) + + @simple_layout('layouts/github-trigger-requirements.yaml', driver='github') + def test_require_label(self): + # Test trigger require-label + jobname = 'require-label' + project = 'org/project' + A = self.fake_github.openFakePullRequest(project, 'master', 'A') + # A comment event that we will keep submitting to trigger + comment = A.getCommentAddedEvent(f'test {jobname}') + + # No label so should not be enqueued + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 0) + + # A random should not cause it to be enqueued + A.addLabel('foobar') + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 0) + + # An approved label goes in + A.addLabel('approved') + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 1) + self.assertEqual(self.history[0].name, jobname) + + @simple_layout('layouts/github-trigger-requirements.yaml', driver='github') + def test_reject_label(self): + # Test trigger reject-label + jobname = 'reject-label' + project = 'org/project' + A = self.fake_github.openFakePullRequest(project, 'master', 'A') + # A comment event that we will keep submitting to trigger + comment = A.getCommentAddedEvent(f'test {jobname}') + + # No label so should be enqueued + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 1) + self.assertEqual(self.history[0].name, jobname) + + # A rejected label should not cause it to be enqueued + A.addLabel('rejected') + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 1) + + # Any other label, it goes in + A.removeLabel('rejected') + A.addLabel('okay') + self.fake_github.emitEvent(comment) + self.waitUntilSettled() + self.assertEqual(len(self.history), 2) + self.assertEqual(self.history[1].name, jobname) diff --git a/zuul/driver/github/githubmodel.py b/zuul/driver/github/githubmodel.py index 30610cf4e0..536cce9031 100644 --- a/zuul/driver/github/githubmodel.py +++ b/zuul/driver/github/githubmodel.py @@ -21,7 +21,7 @@ import time from zuul.model import Change, TriggerEvent, EventFilter, RefFilter from zuul.model import FalseWithReason -from zuul.driver.util import time_to_seconds +from zuul.driver.util import time_to_seconds, to_list EMPTY_GIT_REF = '0' * 40 # git sha of all zeros, used during creates/deletes @@ -170,15 +170,277 @@ class GithubTriggerEvent(TriggerEvent): return ' '.join(r) -class GithubCommonFilter(object): - def __init__(self, required_reviews=[], required_statuses=[], - reject_reviews=[], reject_statuses=[]): - self._required_reviews = copy.deepcopy(required_reviews) +class GithubEventFilter(EventFilter): + def __init__(self, connection_name, trigger, types=[], + branches=[], refs=[], comments=[], actions=[], + labels=[], unlabels=[], states=[], statuses=[], + required_statuses=[], check_runs=[], + ignore_deletes=True, + require=None, reject=None): + + EventFilter.__init__(self, connection_name, trigger) + + # TODO: Backwards compat, remove after 9.x: + if required_statuses and require is None: + require = {'status': required_statuses} + + if require: + self.require_filter = GithubRefFilter.requiresFromConfig( + connection_name, require) + else: + self.require_filter = None + + if reject: + self.reject_filter = GithubRefFilter.rejectFromConfig( + connection_name, reject) + else: + self.reject_filter = None + + self._types = types + self._branches = branches + self._refs = refs + self._comments = comments + self.types = [re.compile(x) for x in types] + self.branches = [re.compile(x) for x in branches] + self.refs = [re.compile(x) for x in refs] + self.comments = [re.compile(x) for x in comments] + self.actions = actions + self.labels = labels + self.unlabels = unlabels + self.states = states + self.statuses = statuses + self.check_runs = check_runs + self.ignore_deletes = ignore_deletes + + def __repr__(self): + ret = '