Merge "Add Gerrit pipeline trigger requirements"

This commit is contained in:
Zuul 2023-04-29 21:20:01 +00:00 committed by Gerrit Code Review
commit bbdbe81790
9 changed files with 845 additions and 281 deletions

View File

@ -239,6 +239,10 @@ be able to invoke the ``gerrit stream-events`` command over SSH.
.. attr:: require-approval
.. warning:: This is deprecated and will be removed in a future
version. Use :attr:`pipeline.trigger.<gerrit
source>.require` instead.
This may be used for any event. It requires that a certain kind
of approval be present for the current patchset of the change
(the approval could be added by the event in question). It
@ -246,13 +250,40 @@ be able to invoke the ``gerrit stream-events`` command over SSH.
source>.approval`. For each specified criteria there must exist
a matching approval.
This is ignored if the :attr:`pipeline.trigger.<gerrit
source>.require` attribute is present.
.. attr:: reject-approval
.. warning:: This is deprecated and will be removed in a future
version. Use :attr:`pipeline.trigger.<gerrit
source>.reject` instead.
This takes a list of approvals in the same format as
:attr:`pipeline.trigger.<gerrit source>.require-approval` but
the item will fail to enter the pipeline if there is a matching
approval.
This is ignored if the :attr:`pipeline.trigger.<gerrit
source>.reject` attribute is present.
.. attr:: require
This may be used for any event. It describes conditions that
must be met by the change 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:`gerrit_requirements`.
.. attr:: reject
This may be used for any event and is the mirror of
:attr:`pipeline.trigger.<gerrit source>.require`. It describes
conditions that when met by the change cause the trigger event
not to match. Those conditions may be satisfied by the event in
question. It follows the same syntax as
:ref:`gerrit_requirements`.
Reporter Configuration
----------------------
@ -284,6 +315,8 @@ with an HTTP password, in which case the HTTP API is used.
A :ref:`connection<connections>` that uses the gerrit driver must be
supplied to the trigger.
.. _gerrit_requirements:
Requirements Configuration
--------------------------
@ -366,7 +399,7 @@ order to be enqueued into the pipeline.
.. attr:: status
A string value that corresponds with the status of the change
reported by the trigger.
reported by Gerrit.
.. attr:: pipeline.reject.<gerrit source>
@ -376,10 +409,12 @@ order to be enqueued into the pipeline.
.. attr:: approval
This takes an approval or a list of approvals. If an approval
matches the provided criteria the change can not be entered
into the pipeline. It follows the same syntax as
:attr:`pipeline.require.<gerrit source>.approval`.
This requires that a certain kind of approval not be present for the
current patchset of the change (the approval could be added by
the event in question). Approval is a dictionary or a list of
dictionaries with attributes listed below, all of which are
optional and are combined together so that there must be no approvals
matching all specified requirements.
Example to reject a change with any negative vote:
@ -390,6 +425,55 @@ order to be enqueued into the pipeline.
approval:
- Code-Review: [-1, -2]
.. attr:: username
If present, an approval from this username is required. It is
treated as a regular expression.
.. attr:: email
If present, an approval with this email address is required. It is
treated as a regular expression.
.. attr:: older-than
If present, the approval must be older than this amount of time
to match. Provide a time interval as a number with a suffix of
"w" (weeks), "d" (days), "h" (hours), "m" (minutes), "s"
(seconds). Example ``48h`` or ``2d``.
.. attr:: newer-than
If present, the approval must be newer than this amount
of time to match. Same format as "older-than".
Any other field is interpreted as a review category and value
pair. For example ``Verified: 1`` would require that the
approval be for a +1 vote in the "Verified" column. The value
may either be a single value or a list: ``Verified: [1, 2]``
would match either a +1 or +2 vote.
.. attr:: open
A boolean value (``true`` or ``false``) that indicates whether
the change must be open or closed in order to be rejected.
.. attr:: current-patchset
A boolean value (``true`` or ``false``) that indicates whether the
change must be the current patchset in order to be rejected.
.. attr:: wip
A boolean value (``true`` or ``false``) that indicates whether the
change must be wip or not wip in order to be rejected.
.. attr:: status
A string value that corresponds with the status of the change
reported by Gerrit.
Reference Pipelines Configuration
---------------------------------

View File

@ -0,0 +1,22 @@
---
features:
- |
Gerrit 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.
- |
All Gerrit "requires" filters are now available as "reject"
filters as well.
deprecations:
- |
The `require-approval` and `reject-approval` Gerrit trigger
attributes are deprecated. Use :attr:`pipeline.trigger.<gerrit
source>.require` and :attr:`pipeline.trigger.<gerrit
source>.reject` instead.

View File

@ -950,6 +950,7 @@ class FakeGerritChange(object):
if self.fail_merge:
return
self.data['status'] = 'MERGED'
self.data['open'] = False
self.open = False
path = os.path.join(self.upstream_root, self.project)

View File

@ -0,0 +1,162 @@
- pipeline:
name: require-open
manager: independent
trigger:
gerrit:
- event: comment-added
comment: test require-open
require:
open: true
success:
gerrit:
Verified: 1
- pipeline:
name: reject-open
manager: independent
trigger:
gerrit:
- event: comment-added
comment: test reject-open
reject:
open: true
success:
gerrit:
Verified: 1
- pipeline:
name: require-wip
manager: independent
trigger:
gerrit:
- event: comment-added
comment: test require-wip
require:
wip: true
success:
gerrit:
Verified: 1
- pipeline:
name: reject-wip
manager: independent
trigger:
gerrit:
- event: comment-added
comment: test reject-wip
reject:
wip: true
success:
gerrit:
Verified: 1
- pipeline:
name: require-current-patchset
manager: independent
trigger:
gerrit:
- event: comment-added
comment: test require-current-patchset
require:
current-patchset: true
success:
gerrit:
Verified: 1
- pipeline:
name: reject-current-patchset
manager: independent
trigger:
gerrit:
- event: comment-added
comment: test reject-current-patchset
reject:
current-patchset: true
success:
gerrit:
Verified: 1
- pipeline:
name: require-status
manager: independent
trigger:
gerrit:
- event: comment-added
comment: test require-status
require:
status: MERGED
success:
gerrit:
Verified: 1
- pipeline:
name: reject-status
manager: independent
trigger:
gerrit:
- event: comment-added
comment: test reject-status
reject:
status: MERGED
success:
gerrit:
Verified: 1
- pipeline:
name: require-approval
manager: independent
trigger:
gerrit:
- event: comment-added
comment: test require-approval
require:
approval:
username: zuul
Verified: 1
success:
gerrit:
Verified: 1
- pipeline:
name: reject-approval
manager: independent
trigger:
gerrit:
- event: comment-added
comment: test reject-approval
reject:
approval:
username: zuul
Verified: 1
success:
gerrit:
Verified: 1
- job:
name: base
parent: null
run: playbooks/base.yaml
- job: {name: require-open}
- job: {name: reject-open}
- job: {name: require-wip}
- job: {name: reject-wip}
- job: {name: require-current-patchset}
- job: {name: reject-current-patchset}
- job: {name: require-status}
- job: {name: reject-status}
- job: {name: require-approval}
- job: {name: reject-approval}
- project:
name: org/project
require-open: {jobs: [require-open]}
reject-open: {jobs: [reject-open]}
require-wip: {jobs: [require-wip]}
reject-wip: {jobs: [reject-wip]}
require-current-patchset: {jobs: [require-current-patchset]}
reject-current-patchset: {jobs: [reject-current-patchset]}
require-status: {jobs: [require-status]}
reject-status: {jobs: [reject-status]}
require-approval: {jobs: [require-approval]}
reject-approval: {jobs: [reject-approval]}

View File

@ -14,7 +14,7 @@
import time
from tests.base import ZuulTestCase
from tests.base import ZuulTestCase, simple_layout
class TestRequirementsApprovalNewerThan(ZuulTestCase):
@ -490,3 +490,222 @@ class TestRequirementsTrustedCheck(ZuulTestCase):
self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertHistory([])
class TestGerritTriggerRequirements(ZuulTestCase):
scheduler_count = 1
@simple_layout('layouts/gerrit-trigger-requirements.yaml')
def test_require_open(self):
# Test trigger require-open
jobname = 'require-open'
project = 'org/project'
A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
# A comment event that we will keep submitting to trigger
comment = A.getChangeCommentEvent(1, f'test {jobname}')
# It's open, so it should be enqueued
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
# Not open, so should be ignored
A.setMerged()
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
@simple_layout('layouts/gerrit-trigger-requirements.yaml')
def test_reject_open(self):
# Test trigger reject-open
jobname = 'reject-open'
project = 'org/project'
A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
# A comment event that we will keep submitting to trigger
comment = A.getChangeCommentEvent(1, f'test {jobname}')
# It's open, so it should not be enqueued
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 0)
# Not open, so should be enqueued
A.setMerged()
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
@simple_layout('layouts/gerrit-trigger-requirements.yaml')
def test_require_wip(self):
# Test trigger require-wip
jobname = 'require-wip'
project = 'org/project'
A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
# A comment event that we will keep submitting to trigger
comment = A.getChangeCommentEvent(1, f'test {jobname}')
# It's not WIP, so it should be ignored
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 0)
# WIP, so should be enqueued
A.setWorkInProgress(True)
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
@simple_layout('layouts/gerrit-trigger-requirements.yaml')
def test_reject_wip(self):
# Test trigger reject-wip
jobname = 'reject-wip'
project = 'org/project'
A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
# A comment event that we will keep submitting to trigger
comment = A.getChangeCommentEvent(1, f'test {jobname}')
# It's not WIP, so it should be enqueued
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
# WIP, so should be ignored
A.setWorkInProgress(True)
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
@simple_layout('layouts/gerrit-trigger-requirements.yaml')
def test_require_current_patchset(self):
# Test trigger require-current_patchset
jobname = 'require-current-patchset'
project = 'org/project'
A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
# A comment event that we will keep submitting to trigger
comment = A.getChangeCommentEvent(1, f'test {jobname}')
# It's current, so it should be enqueued
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
# Not current, so should be ignored
A.addPatchset()
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
@simple_layout('layouts/gerrit-trigger-requirements.yaml')
def test_reject_current_patchset(self):
# Test trigger reject-current_patchset
jobname = 'reject-current-patchset'
project = 'org/project'
A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
# A comment event that we will keep submitting to trigger
comment = A.getChangeCommentEvent(1, f'test {jobname}')
# It's current, so it should be ignored
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 0)
# Not current, so should be enqueued
A.addPatchset()
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
@simple_layout('layouts/gerrit-trigger-requirements.yaml')
def test_require_status(self):
# Test trigger require-status
jobname = 'require-status'
project = 'org/project'
A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
# A comment event that we will keep submitting to trigger
comment = A.getChangeCommentEvent(1, f'test {jobname}')
# It's not merged, so it should be ignored
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 0)
# Merged, so should be enqueued
A.setMerged()
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
@simple_layout('layouts/gerrit-trigger-requirements.yaml')
def test_reject_status(self):
# Test trigger reject-status
jobname = 'reject-status'
project = 'org/project'
A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
# A comment event that we will keep submitting to trigger
comment = A.getChangeCommentEvent(1, f'test {jobname}')
# It's not merged, so it should be enqueued
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
# Merged, so should be ignored
A.setMerged()
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
@simple_layout('layouts/gerrit-trigger-requirements.yaml')
def test_require_approval(self):
# Test trigger require-approval
jobname = 'require-approval'
project = 'org/project'
A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
# A comment event that we will keep submitting to trigger
comment = A.getChangeCommentEvent(1, f'test {jobname}')
# Missing approval, so it should be ignored
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 0)
# Has approval, so it should be enqueued
A.addApproval('Verified', 1, username='zuul')
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
@simple_layout('layouts/gerrit-trigger-requirements.yaml')
def test_reject_approval(self):
# Test trigger reject-approval
jobname = 'reject-approval'
project = 'org/project'
A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
# A comment event that we will keep submitting to trigger
comment = A.getChangeCommentEvent(1, f'test {jobname}')
# Missing approval, so it should be enqueued
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)
# Has approval, so it should be ignored
A.addApproval('Verified', 1, username='zuul')
self.fake_gerrit.addEvent(comment)
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, jobname)

View File

@ -19,8 +19,8 @@ import urllib.parse
import dateutil.parser
from zuul.model import EventFilter, RefFilter
from zuul.model import Change, TriggerEvent
from zuul.driver.util import time_to_seconds
from zuul.model import Change, TriggerEvent, FalseWithReason
from zuul.driver.util import time_to_seconds, to_list
from zuul import exceptions
EMPTY_GIT_REF = '0' * 40 # git sha of all zeros, used during creates/deletes
@ -247,13 +247,313 @@ class GerritTriggerEvent(TriggerEvent):
return 'change-abandoned' == self.type
class GerritApprovalFilter(object):
def __init__(self, required_approvals=[], reject_approvals=[]):
class GerritEventFilter(EventFilter):
def __init__(self, connection_name, trigger, types=[], branches=[],
refs=[], event_approvals={}, comments=[], emails=[],
usernames=[], required_approvals=[], reject_approvals=[],
uuid=None, scheme=None, ignore_deletes=True,
require=None, reject=None):
EventFilter.__init__(self, connection_name, trigger)
# TODO: Backwards compat, remove after 9.x:
if required_approvals and require is None:
require = {'approval': required_approvals}
if reject_approvals and reject is None:
reject = {'approval': reject_approvals}
if require:
self.require_filter = GerritRefFilter.requiresFromConfig(
connection_name, require)
else:
self.require_filter = None
if reject:
self.reject_filter = GerritRefFilter.rejectFromConfig(
connection_name, reject)
else:
self.reject_filter = None
self._types = types
self._branches = branches
self._refs = refs
self._comments = comments
self._emails = emails
self._usernames = usernames
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.emails = [re.compile(x) for x in emails]
self.usernames = [re.compile(x) for x in usernames]
self.event_approvals = event_approvals
self.uuid = uuid
self.scheme = scheme
self.ignore_deletes = ignore_deletes
def __repr__(self):
ret = '<GerritEventFilter'
ret += ' connection: %s' % self.connection_name
if self._types:
ret += ' types: %s' % ', '.join(self._types)
if self.uuid:
ret += ' uuid: %s' % (self.uuid,)
if self.scheme:
ret += ' scheme: %s' % (self.scheme,)
if self._branches:
ret += ' branches: %s' % ', '.join(self._branches)
if self._refs:
ret += ' refs: %s' % ', '.join(self._refs)
if self.ignore_deletes:
ret += ' ignore_deletes: %s' % self.ignore_deletes
if self.event_approvals:
ret += ' event_approvals: %s' % ', '.join(
['%s:%s' % a for a in self.event_approvals.items()])
if self._comments:
ret += ' comments: %s' % ', '.join(self._comments)
if self._emails:
ret += ' emails: %s' % ', '.join(self._emails)
if self._usernames:
ret += ' usernames: %s' % ', '.join(self._usernames)
if self.require_filter:
ret += ' require: %s' % repr(self.require_filter)
if self.reject_filter:
ret += ' reject: %s' % repr(self.reject_filter)
ret += '>'
return ret
def matches(self, event, change):
if not super().matches(event, change):
return False
# event types are ORed
matches_type = False
for etype in self.types:
if etype.match(event.type):
matches_type = True
if self.types and not matches_type:
return FalseWithReason("Types %s do not match %s" % (
self.types, event.type))
if event.type == 'pending-check':
if self.uuid and event.uuid != self.uuid:
return False
if self.scheme and event.uuid.split(':')[0] != self.scheme:
return False
# branches are ORed
matches_branch = False
for branch in self.branches:
if branch.match(event.branch):
matches_branch = True
if self.branches and not matches_branch:
return FalseWithReason("Branches %s do not match %s" % (
self.branches, event.branch))
# refs are ORed
matches_ref = False
if event.ref is not None:
for ref in self.refs:
if ref.match(event.ref):
matches_ref = True
if self.refs and not matches_ref:
return FalseWithReason(
"Refs %s do not match %s" % (self.refs, event.ref))
if self.ignore_deletes and event.newrev == EMPTY_GIT_REF:
# If the updated ref has an empty git sha (all 0s),
# then the ref is being deleted
return FalseWithReason("Ref deletion events are ignored")
# comments are ORed
matches_comment_re = False
for comment_re in self.comments:
if (event.comment is not None and
comment_re.search(event.comment)):
matches_comment_re = True
if event.patchsetcomments is not None:
for comment in event.patchsetcomments:
if (comment is not None and
comment_re.search(comment)):
matches_comment_re = True
if self.comments and not matches_comment_re:
return FalseWithReason("Comments %s do not match %s" % (
self.comments, event.patchsetcomments))
# We better have an account provided by Gerrit to do
# email filtering.
if event.account is not None:
account_email = event.account.get('email')
# emails are ORed
matches_email_re = False
for email_re in self.emails:
if (account_email is not None and
email_re.search(account_email)):
matches_email_re = True
if self.emails and not matches_email_re:
return FalseWithReason("Username %s does not match %s" % (
self.emails, account_email))
# usernames are ORed
account_username = event.account.get('username')
matches_username_re = False
for username_re in self.usernames:
if (account_username is not None and
username_re.search(account_username)):
matches_username_re = True
if self.usernames and not matches_username_re:
return FalseWithReason("Username %s does not match %s" % (
self.usernames, account_username))
# approvals are ANDed
for category, value in self.event_approvals.items():
matches_approval = False
for eapp in event.approvals:
if (eapp['description'] == category and
int(eapp['value']) == int(value)):
matches_approval = True
if not matches_approval:
return FalseWithReason("Approvals %s do not match %s" % (
self.event_approvals, event.approvals))
if self.require_filter:
require_filter_result = self.require_filter.matches(change)
if not require_filter_result:
return require_filter_result
if self.reject_filter:
reject_filter_result = self.reject_filter.matches(change)
if not reject_filter_result:
return reject_filter_result
return True
class GerritRefFilter(RefFilter):
def __init__(self, connection_name,
open=None, reject_open=None,
current_patchset=None, reject_current_patchset=None,
wip=None, reject_wip=None,
statuses=[], reject_statuses=[],
required_approvals=[], reject_approvals=[]):
RefFilter.__init__(self, connection_name)
self._required_approvals = copy.deepcopy(required_approvals)
self.required_approvals = self._tidy_approvals(
self._required_approvals)
self._reject_approvals = copy.deepcopy(reject_approvals)
self.reject_approvals = self._tidy_approvals(self._reject_approvals)
self.statuses = statuses
self.reject_statuses = reject_statuses
if reject_open is not None:
self.open = not reject_open
else:
self.open = open
if reject_wip is not None:
self.wip = not reject_wip
else:
self.wip = wip
if reject_current_patchset is not None:
self.current_patchset = not reject_current_patchset
else:
self.current_patchset = current_patchset
@classmethod
def requiresFromConfig(cls, connection_name, config):
return cls(
connection_name=connection_name,
open=config.get('open'),
current_patchset=config.get('current-patchset'),
wip=config.get('wip'),
statuses=to_list(config.get('status')),
required_approvals=to_list(config.get('approval')),
)
@classmethod
def rejectFromConfig(cls, connection_name, config):
return cls(
connection_name=connection_name,
reject_open=config.get('open'),
reject_current_patchset=config.get('current-patchset'),
reject_wip=config.get('wip'),
reject_statuses=to_list(config.get('status')),
reject_approvals=to_list(config.get('approval')),
)
def __repr__(self):
ret = '<GerritRefFilter'
ret += ' connection_name: %s' % self.connection_name
if self.open is not None:
ret += ' open: %s' % self.open
if self.wip is not None:
ret += ' wip: %s' % self.wip
if self.current_patchset is not None:
ret += ' current-patchset: %s' % self.current_patchset
if self.statuses:
ret += ' statuses: %s' % ', '.join(self.statuses)
if self.reject_statuses:
ret += ' reject-statuses: %s' % ', '.join(self.reject_statuses)
if self.required_approvals:
ret += (' required-approvals: %s' %
str(self.required_approvals))
if self.reject_approvals:
ret += (' reject-approvals: %s' %
str(self.reject_approvals))
ret += '>'
return ret
def matches(self, change):
if self.open is not None:
# if a "change" has no number, it's not a change, but a push
# and cannot possibly pass this test.
if hasattr(change, 'number'):
if self.open != change.open:
return FalseWithReason(
"Change does not match open requirement")
else:
return FalseWithReason("Ref is not a Change")
if self.current_patchset is not None:
# if a "change" has no number, it's not a change, but a push
# and cannot possibly pass this test.
if hasattr(change, 'number'):
if self.current_patchset != change.is_current_patchset:
return FalseWithReason(
"Change does not match current patchset requirement")
else:
return FalseWithReason("Ref is not a Change")
if self.wip is not None:
# if a "change" has no number, it's not a change, but a push
# and cannot possibly pass this test.
if hasattr(change, 'number'):
if self.wip != change.wip:
return FalseWithReason(
"Change does not match WIP requirement")
else:
return FalseWithReason("Ref is not a Change")
if self.statuses:
if change.status not in self.statuses:
return FalseWithReason(
"Required statuses %s do not match %s" % (
self.statuses, change.status))
if self.reject_statuses:
if change.status in self.reject_statuses:
return FalseWithReason(
"Reject statuses %s match %s" % (
self.reject_statuses, change.status))
# required approvals are ANDed (reject approvals are ORed)
matches_approvals_result = self.matchesApprovals(change)
if not matches_approvals_result:
return matches_approvals_result
return True
def _tidy_approvals(self, approvals):
for a in approvals:
@ -301,10 +601,11 @@ class GerritApprovalFilter(object):
if self.required_approvals or self.reject_approvals:
if not hasattr(change, 'number'):
# Not a change, no reviews
return False
return FalseWithReason("Ref is not a Change")
if self.required_approvals and not change.approvals:
# A change with no approvals can not match
return False
return FalseWithReason("Approvals %s does not match %s" % (
self.required_approvals, change.approvals))
# TODO(jhesketh): If we wanted to optimise this slightly we could
# analyse both the REQUIRE and REJECT filters by looping over the
@ -324,7 +625,9 @@ class GerritApprovalFilter(object):
matches_rapproval = True
break
if not matches_rapproval:
return False
return FalseWithReason(
"Required approvals %s do not match %s" % (
self.required_approvals, change.approvals))
return True
def matchesNoRejectApprovals(self, change):
@ -334,236 +637,8 @@ class GerritApprovalFilter(object):
if self._match_approval_required_approval(rapproval, approval):
# A reject approval has been matched, so we reject
# immediately
return False
return FalseWithReason("Reject approvals %s match %s" % (
self.reject_approvals, change.approvals))
# To get here no rejects can have been matched so we should be good to
# queue
return True
class GerritEventFilter(EventFilter, GerritApprovalFilter):
def __init__(self, connection_name, trigger, types=[], branches=[],
refs=[], event_approvals={}, comments=[], emails=[],
usernames=[], required_approvals=[], reject_approvals=[],
uuid=None, scheme=None, ignore_deletes=True):
EventFilter.__init__(self, connection_name, trigger)
GerritApprovalFilter.__init__(self,
required_approvals=required_approvals,
reject_approvals=reject_approvals)
self._types = types
self._branches = branches
self._refs = refs
self._comments = comments
self._emails = emails
self._usernames = usernames
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.emails = [re.compile(x) for x in emails]
self.usernames = [re.compile(x) for x in usernames]
self.event_approvals = event_approvals
self.uuid = uuid
self.scheme = scheme
self.ignore_deletes = ignore_deletes
def __repr__(self):
ret = '<GerritEventFilter'
ret += ' connection: %s' % self.connection_name
if self._types:
ret += ' types: %s' % ', '.join(self._types)
if self.uuid:
ret += ' uuid: %s' % (self.uuid,)
if self.scheme:
ret += ' scheme: %s' % (self.scheme,)
if self._branches:
ret += ' branches: %s' % ', '.join(self._branches)
if self._refs:
ret += ' refs: %s' % ', '.join(self._refs)
if self.ignore_deletes:
ret += ' ignore_deletes: %s' % self.ignore_deletes
if self.event_approvals:
ret += ' event_approvals: %s' % ', '.join(
['%s:%s' % a for a in self.event_approvals.items()])
if self.required_approvals:
ret += ' required_approvals: %s' % ', '.join(
['%s' % a for a in self._required_approvals])
if self.reject_approvals:
ret += ' reject_approvals: %s' % ', '.join(
['%s' % a for a in self._reject_approvals])
if self._comments:
ret += ' comments: %s' % ', '.join(self._comments)
if self._emails:
ret += ' emails: %s' % ', '.join(self._emails)
if self._usernames:
ret += ' usernames: %s' % ', '.join(self._usernames)
ret += '>'
return ret
def matches(self, event, change):
if not super().matches(event, change):
return False
# event types are ORed
matches_type = False
for etype in self.types:
if etype.match(event.type):
matches_type = True
if self.types and not matches_type:
return False
if event.type == 'pending-check':
if self.uuid and event.uuid != self.uuid:
return False
if self.scheme and event.uuid.split(':')[0] != self.scheme:
return False
# branches are ORed
matches_branch = False
for branch in self.branches:
if branch.match(event.branch):
matches_branch = True
if self.branches and not matches_branch:
return False
# refs are ORed
matches_ref = False
if event.ref is not None:
for ref in self.refs:
if ref.match(event.ref):
matches_ref = True
if self.refs and not matches_ref:
return False
if self.ignore_deletes and event.newrev == EMPTY_GIT_REF:
# If the updated ref has an empty git sha (all 0s),
# then the ref is being deleted
return False
# comments are ORed
matches_comment_re = False
for comment_re in self.comments:
if (event.comment is not None and
comment_re.search(event.comment)):
matches_comment_re = True
if event.patchsetcomments is not None:
for comment in event.patchsetcomments:
if (comment is not None and
comment_re.search(comment)):
matches_comment_re = True
if self.comments and not matches_comment_re:
return False
# We better have an account provided by Gerrit to do
# email filtering.
if event.account is not None:
account_email = event.account.get('email')
# emails are ORed
matches_email_re = False
for email_re in self.emails:
if (account_email is not None and
email_re.search(account_email)):
matches_email_re = True
if self.emails and not matches_email_re:
return False
# usernames are ORed
account_username = event.account.get('username')
matches_username_re = False
for username_re in self.usernames:
if (account_username is not None and
username_re.search(account_username)):
matches_username_re = True
if self.usernames and not matches_username_re:
return False
# approvals are ANDed
for category, value in self.event_approvals.items():
matches_approval = False
for eapp in event.approvals:
if (eapp['description'] == category and
int(eapp['value']) == int(value)):
matches_approval = True
if not matches_approval:
return False
# required approvals are ANDed (reject approvals are ORed)
if not self.matchesApprovals(change):
return False
return True
class GerritRefFilter(RefFilter, GerritApprovalFilter):
def __init__(self, connection_name, open=None, current_patchset=None,
wip=None, statuses=[], required_approvals=[],
reject_approvals=[]):
RefFilter.__init__(self, connection_name)
GerritApprovalFilter.__init__(self,
required_approvals=required_approvals,
reject_approvals=reject_approvals)
self.open = open
self.wip = wip
self.current_patchset = current_patchset
self.statuses = statuses
def __repr__(self):
ret = '<GerritRefFilter'
ret += ' connection_name: %s' % self.connection_name
if self.open is not None:
ret += ' open: %s' % self.open
if self.current_patchset is not None:
ret += ' current-patchset: %s' % self.current_patchset
if self.statuses:
ret += ' statuses: %s' % ', '.join(self.statuses)
if self.required_approvals:
ret += (' required-approvals: %s' %
str(self.required_approvals))
if self.reject_approvals:
ret += (' reject-approvals: %s' %
str(self.reject_approvals))
ret += '>'
return ret
def matches(self, change):
filters = [
{
"required": self.open,
"value": change.open
},
{
"required": self.current_patchset,
"value": change.is_current_patchset
},
{
"required": self.wip,
"value": change.wip
},
]
configured = filter(lambda x: x["required"] is not None, filters)
# if a "change" has no number, it's not a change, but a push
# and cannot possibly pass this test.
if hasattr(change, 'number'):
if any(map(lambda x: x["required"] != x["value"], configured)):
return False
elif configured:
return False
if self.statuses:
if change.status not in self.statuses:
return False
# required approvals are ANDed (reject approvals are ORed)
if not self.matchesApprovals(change):
return False
return True

View File

@ -20,7 +20,7 @@ from urllib.parse import urlparse
from zuul.source import BaseSource
from zuul.model import Project
from zuul.driver.gerrit.gerritmodel import GerritRefFilter
from zuul.driver.util import scalar_or_list, to_list
from zuul.driver.util import scalar_or_list
from zuul.lib.dependson import find_dependency_headers
from zuul.zk.change_cache import ChangeKey
@ -209,21 +209,15 @@ class GerritSource(BaseSource):
return self.connection._getGitwebUrl(project, sha)
def getRequireFilters(self, config):
f = GerritRefFilter(
connection_name=self.connection.connection_name,
open=config.get('open'),
current_patchset=config.get('current-patchset'),
wip=config.get('wip'),
statuses=to_list(config.get('status')),
required_approvals=to_list(config.get('approval')),
)
f = GerritRefFilter.requiresFromConfig(
self.connection.connection_name,
config)
return [f]
def getRejectFilters(self, config):
f = GerritRefFilter(
connection_name=self.connection.connection_name,
reject_approvals=to_list(config.get('approval')),
)
f = GerritRefFilter.rejectFromConfig(
self.connection.connection_name,
config)
return [f]
def getRefForChange(self, change):
@ -247,11 +241,13 @@ def getRequireSchema():
'current-patchset': bool,
'wip': bool,
'status': scalar_or_list(str)}
return require
def getRejectSchema():
reject = {'approval': scalar_or_list(approval)}
reject = {'approval': scalar_or_list(approval),
'open': bool,
'current-patchset': bool,
'wip': bool,
'status': scalar_or_list(str)}
return reject

View File

@ -16,6 +16,7 @@ import logging
import voluptuous as v
from zuul.trigger import BaseTrigger
from zuul.driver.gerrit.gerritmodel import GerritEventFilter
from zuul.driver.gerrit import gerritsource
from zuul.driver.util import scalar_or_list, to_list
@ -59,7 +60,9 @@ class GerritTrigger(BaseTrigger):
),
uuid=trigger.get('uuid'),
scheme=trigger.get('scheme'),
ignore_deletes=ignore_deletes
ignore_deletes=ignore_deletes,
require=trigger.get('require'),
reject=trigger.get('reject'),
)
efilters.append(f)
@ -101,6 +104,8 @@ def getSchema():
'approval': scalar_or_list(variable_dict),
'require-approval': scalar_or_list(approval),
'reject-approval': scalar_or_list(approval),
'require': gerritsource.getRequireSchema(),
'reject': gerritsource.getRejectSchema(),
}
return gerrit_trigger

View File

@ -255,7 +255,7 @@ class GithubEventFilter(EventFilter):
if etype.match(event.type):
matches_type = True
if self.types and not matches_type:
return FalseWithReason("Types %s doesn't match %s" % (
return FalseWithReason("Types %s do not match %s" % (
self.types, event.type))
# branches are ORed
@ -264,7 +264,7 @@ class GithubEventFilter(EventFilter):
if branch.match(event.branch):
matches_branch = True
if self.branches and not matches_branch:
return FalseWithReason("Branches %s doesn't match %s" % (
return FalseWithReason("Branches %s do not match %s" % (
self.branches, event.branch))
# refs are ORed
@ -275,11 +275,11 @@ class GithubEventFilter(EventFilter):
matches_ref = True
if self.refs and not matches_ref:
return FalseWithReason(
"Refs %s doesn't match %s" % (self.refs, event.ref))
"Refs %s do not match %s" % (self.refs, event.ref))
if self.ignore_deletes and event.newrev == EMPTY_GIT_REF:
# If the updated ref has an empty git sha (all 0s),
# then the ref is being deleted
return FalseWithReason("Ref deletion are ignored")
return FalseWithReason("Ref deletion events are ignored")
# comments are ORed
matches_comment_re = False
@ -288,7 +288,7 @@ class GithubEventFilter(EventFilter):
comment_re.search(event.comment)):
matches_comment_re = True
if self.comments and not matches_comment_re:
return FalseWithReason("Comments %s doesn't match %s" % (
return FalseWithReason("Comments %s do not match %s" % (
self.comments, event.comment))
# actions are ORed
@ -297,7 +297,7 @@ class GithubEventFilter(EventFilter):
if (event.action == action):
matches_action = True
if self.actions and not matches_action:
return FalseWithReason("Actions %s doesn't match %s" % (
return FalseWithReason("Actions %s do not match %s" % (
self.actions, event.action))
# check_runs are ORed
@ -308,22 +308,22 @@ class GithubEventFilter(EventFilter):
check_run_found = True
break
if not check_run_found:
return FalseWithReason("Check_runs %s doesn't match %s" % (
return FalseWithReason("Check runs %s do not match %s" % (
self.check_runs, event.check_run))
# labels are ORed
if self.labels and event.label not in self.labels:
return FalseWithReason("Labels %s doesn't match %s" % (
return FalseWithReason("Labels %s do not match %s" % (
self.labels, event.label))
# unlabels are ORed
if self.unlabels and event.unlabel not in self.unlabels:
return FalseWithReason("Unlabels %s doesn't match %s" % (
return FalseWithReason("Unlabels %s do not match %s" % (
self.unlabels, event.unlabel))
# states are ORed
if self.states and event.state not in self.states:
return FalseWithReason("States %s doesn't match %s" % (
return FalseWithReason("States %s do not match %s" % (
self.states, event.state))
# statuses are ORed
@ -334,7 +334,7 @@ class GithubEventFilter(EventFilter):
status_found = True
break
if not status_found:
return FalseWithReason("Statuses %s doesn't match %s" % (
return FalseWithReason("Statuses %s do not match %s" % (
self.statuses, event.status))
if self.require_filter:
@ -498,7 +498,7 @@ class GithubRefFilter(RefFilter):
if self.required_reviews and not change.reviews:
# No reviews means no matching of required bits
# having reject reviews but no reviews on the change is okay
return FalseWithReason("Reviews %s does not match %s" % (
return FalseWithReason("Reviews %s do not match %s" % (
self.required_reviews, change.reviews))
return (self.matchesRequiredReviews(change) and
@ -514,7 +514,7 @@ class GithubRefFilter(RefFilter):
break
if not matches_review:
return FalseWithReason(
"Required reviews %s does not match %s" % (
"Required reviews %s do not match %s" % (
self.required_reviews, change.reviews))
return True
@ -523,7 +523,7 @@ class GithubRefFilter(RefFilter):
for review in change.reviews:
if self._match_review_required_review(rreview, review):
# A review matched, we can reject right away
return FalseWithReason("Reject reviews %s matches %s" % (
return FalseWithReason("Reject reviews %s match %s" % (
self.reject_reviews, change.reviews))
return True
@ -531,10 +531,10 @@ class GithubRefFilter(RefFilter):
if self.required_statuses or self.reject_statuses:
if not hasattr(change, 'number'):
# not a PR, no status
return FalseWithReason("Can't match statuses without PR")
return FalseWithReason("Can not match statuses without PR")
if self.required_statuses and not change.status:
return FalseWithReason(
"Required statuses %s does not match %s" % (
"Required statuses %s do not match %s" % (
self.required_statuses, change.status))
required_statuses_results = self.matchesRequiredStatuses(change)
if not required_statuses_results:
@ -551,7 +551,7 @@ class GithubRefFilter(RefFilter):
for status in change.status:
if re2.fullmatch(required_status, status):
return True
return FalseWithReason("RequiredStatuses %s does not match %s" % (
return FalseWithReason("Required statuses %s do not match %s" % (
self.required_statuses, change.status))
return True
@ -561,7 +561,7 @@ class GithubRefFilter(RefFilter):
for rstatus in self.reject_statuses:
for status in change.status:
if re2.fullmatch(rstatus, status):
return FalseWithReason("NoRejectStatuses %s matches %s" % (
return FalseWithReason("Reject statuses %s match %s" % (
self.reject_statuses, change.status))
return True
@ -569,7 +569,7 @@ class GithubRefFilter(RefFilter):
if self.required_labels or self.reject_labels:
if not hasattr(change, 'number'):
# not a PR, no label
return FalseWithReason("Can't match labels without PR")
return FalseWithReason("Can not match labels without PR")
if self.required_labels and not change.labels:
# No labels means no matching of required bits
# having reject labels but no labels on the change is okay
@ -582,14 +582,14 @@ class GithubRefFilter(RefFilter):
def matchesRequiredLabels(self, change):
for label in self.required_labels:
if label not in change.labels:
return FalseWithReason("Labels %s does not match %s" % (
return FalseWithReason("Labels %s do not match %s" % (
self.required_labels, change.labels))
return True
def matchesNoRejectLabels(self, change):
for label in self.reject_labels:
if label in change.labels:
return FalseWithReason("NoRejectLabels %s matches %s" % (
return FalseWithReason("Reject labels %s match %s" % (
self.reject_labels, change.labels))
return True