Add driver-specific pipeline requirements

As we expand the Github driver, we're seeing a need to specify driver-specific
pipeline requirements.  To accomplish this, bump the require/reject pipeline
keywords down a level underneath connection names.  This lets users specify
per-source pipeline requirements.

This adds new API methods for sources to create the new pipeline filters
(by returning instances or subclasses of RefFilter, which used to be called
ChangeishFilter).

This change also creates and/or moves driver-specific subclasses of EventFilter
and TriggerEvent in(to) their respective drivers.

Change-Id: Ia56c254e3aa591a688103db5b04b3dddae7b2da4
This commit is contained in:
James E. Blair 2017-05-18 14:11:29 -07:00
parent 14b315d556
commit aad3ae2fe1
34 changed files with 928 additions and 534 deletions

View File

@ -54,7 +54,7 @@ Changes
Filters
~~~~~~~
.. autoclass:: zuul.model.ChangeishFilter
.. autoclass:: zuul.model.RefFilter
.. autoclass:: zuul.model.EventFilter

View File

@ -11,8 +11,9 @@
gerrit:
verified: -1
require:
approval:
- email: jenkins@example.com
gerrit:
approval:
- email: jenkins@example.com
- pipeline:
name: trigger

View File

@ -11,9 +11,10 @@
gerrit:
verified: -1
require:
approval:
- username: jenkins
newer-than: 48h
gerrit:
approval:
- username: jenkins
newer-than: 48h
- pipeline:
name: trigger

View File

@ -11,9 +11,10 @@
gerrit:
verified: -1
require:
approval:
- username: jenkins
older-than: 48h
gerrit:
approval:
- username: jenkins
older-than: 48h
- pipeline:
name: trigger

View File

@ -2,8 +2,9 @@
name: pipeline
manager: independent
reject:
approval:
- username: jenkins
gerrit:
approval:
- username: jenkins
trigger:
gerrit:
- event: comment-added

View File

@ -2,16 +2,18 @@
name: pipeline
manager: independent
require:
approval:
- username: jenkins
verified:
- 1
- 2
gerrit:
approval:
- username: jenkins
verified:
- 1
- 2
reject:
approval:
- verified:
- -1
- -2
gerrit:
approval:
- verified:
- -1
- -2
trigger:
gerrit:
- event: comment-added

View File

@ -2,7 +2,8 @@
name: current-check
manager: independent
require:
current-patchset: true
gerrit:
current-patchset: true
trigger:
gerrit:
- event: patchset-created
@ -18,7 +19,8 @@
name: open-check
manager: independent
require:
open: true
gerrit:
open: true
trigger:
gerrit:
- event: patchset-created
@ -34,7 +36,8 @@
name: status-check
manager: independent
require:
status: NEW
gerrit:
status: NEW
trigger:
gerrit:
- event: patchset-created

View File

@ -11,8 +11,9 @@
gerrit:
verified: -1
require:
approval:
- username: ^(jenkins|zuul)$
gerrit:
approval:
- username: ^(jenkins|zuul)$
- pipeline:
name: trigger

View File

@ -2,9 +2,10 @@
name: pipeline
manager: independent
require:
approval:
- username: jenkins
verified: 1
gerrit:
approval:
- username: jenkins
verified: 1
trigger:
gerrit:
- event: comment-added

View File

@ -2,11 +2,12 @@
name: pipeline
manager: independent
require:
approval:
- username: jenkins
verified:
- 1
- 2
gerrit:
approval:
- username: jenkins
verified:
- 1
- 2
trigger:
gerrit:
- event: comment-added

View File

@ -2,8 +2,9 @@
name: check
manager: independent
require:
approval:
- verified: -1
gerrit:
approval:
- verified: -1
trigger:
gerrit:
- event: patchset-created
@ -21,8 +22,9 @@
name: gate
manager: dependent
require:
approval:
- verified: 1
gerrit:
approval:
- verified: 1
trigger:
gerrit:
- event: comment-added

View File

@ -605,6 +605,8 @@ class PipelineParser(object):
methods = {
'trigger': 'getTriggerSchema',
'reporter': 'getReporterSchema',
'require': 'getRequireSchema',
'reject': 'getRejectSchema',
}
schema = {}
@ -666,6 +668,10 @@ class PipelineParser(object):
'_source_context': model.SourceContext,
'_start_mark': yaml.Mark,
}
pipeline['require'] = PipelineParser.getDriverSchema('require',
connections)
pipeline['reject'] = PipelineParser.getDriverSchema('reject',
connections)
pipeline['trigger'] = vs.Required(
PipelineParser.getDriverSchema('trigger', connections))
for action in ['start', 'success', 'failure', 'merge-failure',
@ -742,24 +748,21 @@ class PipelineParser(object):
pipeline.setManager(manager)
layout.pipelines[conf['name']] = pipeline
if 'require' in conf or 'reject' in conf:
require = conf.get('require', {})
reject = conf.get('reject', {})
f = model.ChangeishFilter(
open=require.get('open'),
current_patchset=require.get('current-patchset'),
statuses=as_list(require.get('status')),
required_approvals=as_list(require.get('approval')),
reject_approvals=as_list(reject.get('approval'))
)
manager.changeish_filters.append(f)
for source_name, require_config in conf.get('require', {}).items():
source = connections.getSource(source_name)
manager.changeish_filters.extend(
source.getRequireFilters(require_config))
for source_name, reject_config in conf.get('reject', {}).items():
source = connections.getSource(source_name)
manager.changeish_filters.extend(
source.getRejectFilters(reject_config))
for trigger_name, trigger_config in conf.get('trigger').items():
trigger = connections.getTrigger(trigger_name, trigger_config)
pipeline.triggers.append(trigger)
manager.event_filters += trigger.getEventFilters(
conf['trigger'][trigger_name])
manager.event_filters.extend(
trigger.getEventFilters(conf['trigger'][trigger_name]))
return pipeline

View File

@ -191,6 +191,30 @@ class SourceInterface(object):
"""
pass
@abc.abstractmethod
def getRequireSchema(self):
"""Get the schema for this driver's pipeline requirement filter.
This method is required by the interface.
:returns: A voluptuous schema.
:rtype: dict or Schema
"""
pass
@abc.abstractmethod
def getRejectSchema(self):
"""Get the schema for this driver's pipeline reject filter.
This method is required by the interface.
:returns: A voluptuous schema.
:rtype: dict or Schema
"""
pass
@six.add_metaclass(abc.ABCMeta)
class ReporterInterface(object):

View File

@ -41,3 +41,9 @@ class GerritDriver(Driver, ConnectionInterface, TriggerInterface,
def getReporterSchema(self):
return gerritreporter.getSchema()
def getRequireSchema(self):
return gerritsource.getRequireSchema()
def getRejectSchema(self):
return gerritsource.getRejectSchema()

View File

@ -26,8 +26,9 @@ import pprint
import voluptuous as v
from zuul.connection import BaseConnection
from zuul.model import TriggerEvent, Change, Ref
from zuul.model import Ref
from zuul import exceptions
from zuul.driver.gerrit.gerritmodel import GerritChange, GerritTriggerEvent
# Walk the change dependency tree to find a cycle
@ -72,7 +73,7 @@ class GerritEventConnector(threading.Thread):
# should always be a constant number of seconds behind Gerrit.
now = time.time()
time.sleep(max((ts + self.delay) - now, 0.0))
event = TriggerEvent()
event = GerritTriggerEvent()
event.type = data.get('type')
event.trigger_name = 'gerrit'
change = data.get('change')
@ -316,7 +317,7 @@ class GerritConnection(BaseConnection):
if change and not refresh:
return change
if not change:
change = Change(None)
change = GerritChange(None)
change.number = number
change.patchset = patchset
key = '%s,%s' % (change.number, change.patchset)

View File

@ -0,0 +1,343 @@
# Copyright 2017 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import copy
import re
import time
from zuul.model import EventFilter, RefFilter
from zuul.model import Change, TriggerEvent
from zuul.driver.util import time_to_seconds
EMPTY_GIT_REF = '0' * 40 # git sha of all zeros, used during creates/deletes
def normalize_category(name):
name = name.lower()
return re.sub(' ', '-', name)
class GerritChange(Change):
def __init__(self, project):
super(GerritChange, self).__init__(project)
self.approvals = []
class GerritTriggerEvent(TriggerEvent):
"""Incoming event from an external system."""
def __init__(self):
super(GerritTriggerEvent, self).__init__()
self.approvals = []
def __repr__(self):
ret = '<GerritTriggerEvent %s %s' % (self.type,
self.canonical_project_name)
if self.branch:
ret += " %s" % self.branch
if self.change_number:
ret += " %s,%s" % (self.change_number, self.patch_number)
if self.approvals:
ret += ' ' + ', '.join(
['%s:%s' % (a['type'], a['value']) for a in self.approvals])
ret += '>'
return ret
def isPatchsetCreated(self):
return 'patchset-created' == self.type
def isChangeAbandoned(self):
return 'change-abandoned' == self.type
class GerritApprovalFilter(object):
def __init__(self, required_approvals=[], reject_approvals=[]):
self._required_approvals = copy.deepcopy(required_approvals)
self.required_approvals = self._tidy_approvals(required_approvals)
self._reject_approvals = copy.deepcopy(reject_approvals)
self.reject_approvals = self._tidy_approvals(reject_approvals)
def _tidy_approvals(self, approvals):
for a in approvals:
for k, v in a.items():
if k == 'username':
a['username'] = re.compile(v)
elif k in ['email', 'email-filter']:
a['email'] = re.compile(v)
elif k == 'newer-than':
a[k] = time_to_seconds(v)
elif k == 'older-than':
a[k] = time_to_seconds(v)
if 'email-filter' in a:
del a['email-filter']
return approvals
def _match_approval_required_approval(self, rapproval, approval):
# Check if the required approval and approval match
if 'description' not in approval:
return False
now = time.time()
by = approval.get('by', {})
for k, v in rapproval.items():
if k == 'username':
if (not v.search(by.get('username', ''))):
return False
elif k == 'email':
if (not v.search(by.get('email', ''))):
return False
elif k == 'newer-than':
t = now - v
if (approval['grantedOn'] < t):
return False
elif k == 'older-than':
t = now - v
if (approval['grantedOn'] >= t):
return False
else:
if not isinstance(v, list):
v = [v]
if (normalize_category(approval['description']) != k or
int(approval['value']) not in v):
return False
return True
def matchesApprovals(self, change):
if (self.required_approvals and not change.approvals
or self.reject_approvals and not change.approvals):
# A change with no approvals can not match
return False
# TODO(jhesketh): If we wanted to optimise this slightly we could
# analyse both the REQUIRE and REJECT filters by looping over the
# approvals on the change and keeping track of what we have checked
# rather than needing to loop on the change approvals twice
return (self.matchesRequiredApprovals(change) and
self.matchesNoRejectApprovals(change))
def matchesRequiredApprovals(self, change):
# Check if any approvals match the requirements
for rapproval in self.required_approvals:
matches_rapproval = False
for approval in change.approvals:
if self._match_approval_required_approval(rapproval, approval):
# We have a matching approval so this requirement is
# fulfilled
matches_rapproval = True
break
if not matches_rapproval:
return False
return True
def matchesNoRejectApprovals(self, change):
# Check to make sure no approvals match a reject criteria
for rapproval in self.reject_approvals:
for approval in change.approvals:
if self._match_approval_required_approval(rapproval, approval):
# A reject approval has been matched, so we reject
# immediately
return False
# 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, trigger, types=[], branches=[], refs=[],
event_approvals={}, comments=[], emails=[], usernames=[],
required_approvals=[], reject_approvals=[],
ignore_deletes=True):
EventFilter.__init__(self, 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.ignore_deletes = ignore_deletes
def __repr__(self):
ret = '<GerritEventFilter'
if self._types:
ret += ' types: %s' % ', '.join(self._types)
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):
# 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
# 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 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 (normalize_category(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, open=None, current_patchset=None,
statuses=[], required_approvals=[],
reject_approvals=[]):
RefFilter.__init__(self)
GerritApprovalFilter.__init__(self,
required_approvals=required_approvals,
reject_approvals=reject_approvals)
self.open = open
self.current_patchset = current_patchset
self.statuses = statuses
def __repr__(self):
ret = '<GerritRefFilter'
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):
if self.open is not None:
if self.open != change.open:
return False
if self.current_patchset is not None:
if self.current_patchset != change.is_current_patchset:
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

@ -13,8 +13,11 @@
# under the License.
import logging
import voluptuous as vs
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
class GerritSource(BaseSource):
@ -59,3 +62,41 @@ class GerritSource(BaseSource):
def _getGitwebUrl(self, project, sha=None):
return self.connection._getGitwebUrl(project, sha)
def getRequireFilters(self, config):
f = GerritRefFilter(
open=config.get('open'),
current_patchset=config.get('current-patchset'),
statuses=to_list(config.get('status')),
required_approvals=to_list(config.get('approval')),
)
return [f]
def getRejectFilters(self, config):
f = GerritRefFilter(
reject_approvals=to_list(config.get('approval')),
)
return [f]
approval = vs.Schema({'username': str,
'email-filter': str,
'email': str,
'older-than': str,
'newer-than': str,
}, extra=vs.ALLOW_EXTRA)
def getRequireSchema():
require = {'approval': scalar_or_list(approval),
'open': bool,
'current-patchset': bool,
'status': scalar_or_list(str)}
return require
def getRejectSchema():
reject = {'approval': scalar_or_list(approval)}
return reject

View File

@ -14,8 +14,9 @@
import logging
import voluptuous as v
from zuul.model import EventFilter
from zuul.trigger import BaseTrigger
from zuul.driver.gerrit.gerritmodel import GerritEventFilter
from zuul.driver.util import scalar_or_list, to_list
class GerritTrigger(BaseTrigger):
@ -23,43 +24,36 @@ class GerritTrigger(BaseTrigger):
log = logging.getLogger("zuul.GerritTrigger")
def getEventFilters(self, trigger_conf):
def toList(item):
if not item:
return []
if isinstance(item, list):
return item
return [item]
efilters = []
for trigger in toList(trigger_conf):
for trigger in to_list(trigger_conf):
approvals = {}
for approval_dict in toList(trigger.get('approval')):
for approval_dict in to_list(trigger.get('approval')):
for key, val in approval_dict.items():
approvals[key] = val
# Backwards compat for *_filter versions of these args
comments = toList(trigger.get('comment'))
comments = to_list(trigger.get('comment'))
if not comments:
comments = toList(trigger.get('comment_filter'))
emails = toList(trigger.get('email'))
comments = to_list(trigger.get('comment_filter'))
emails = to_list(trigger.get('email'))
if not emails:
emails = toList(trigger.get('email_filter'))
usernames = toList(trigger.get('username'))
emails = to_list(trigger.get('email_filter'))
usernames = to_list(trigger.get('username'))
if not usernames:
usernames = toList(trigger.get('username_filter'))
usernames = to_list(trigger.get('username_filter'))
ignore_deletes = trigger.get('ignore-deletes', True)
f = EventFilter(
f = GerritEventFilter(
trigger=self,
types=toList(trigger['event']),
branches=toList(trigger.get('branch')),
refs=toList(trigger.get('ref')),
types=to_list(trigger['event']),
branches=to_list(trigger.get('branch')),
refs=to_list(trigger.get('ref')),
event_approvals=approvals,
comments=comments,
emails=emails,
usernames=usernames,
required_approvals=(
toList(trigger.get('require-approval'))
to_list(trigger.get('require-approval'))
),
reject_approvals=toList(
reject_approvals=to_list(
trigger.get('reject-approval')
),
ignore_deletes=ignore_deletes
@ -80,8 +74,6 @@ def validate_conf(trigger_conf):
def getSchema():
def toList(x):
return v.Any([x], x)
variable_dict = v.Schema(dict)
approval = v.Schema({'username': str,
@ -93,25 +85,25 @@ def getSchema():
gerrit_trigger = {
v.Required('event'):
toList(v.Any('patchset-created',
'draft-published',
'change-abandoned',
'change-restored',
'change-merged',
'comment-added',
'ref-updated')),
'comment_filter': toList(str),
'comment': toList(str),
'email_filter': toList(str),
'email': toList(str),
'username_filter': toList(str),
'username': toList(str),
'branch': toList(str),
'ref': toList(str),
scalar_or_list(v.Any('patchset-created',
'draft-published',
'change-abandoned',
'change-restored',
'change-merged',
'comment-added',
'ref-updated')),
'comment_filter': scalar_or_list(str),
'comment': scalar_or_list(str),
'email_filter': scalar_or_list(str),
'email': scalar_or_list(str),
'username_filter': scalar_or_list(str),
'username': scalar_or_list(str),
'branch': scalar_or_list(str),
'ref': scalar_or_list(str),
'ignore-deletes': bool,
'approval': toList(variable_dict),
'require-approval': toList(approval),
'reject-approval': toList(approval),
'approval': scalar_or_list(variable_dict),
'require-approval': scalar_or_list(approval),
'reject-approval': scalar_or_list(approval),
}
return gerrit_trigger

View File

@ -25,3 +25,9 @@ class GitDriver(Driver, ConnectionInterface, SourceInterface):
def getSource(self, connection):
return gitsource.GitSource(self, connection)
def getRequireSchema(self):
return {}
def getRejectSchema(self):
return {}

View File

@ -53,3 +53,9 @@ class GitSource(BaseSource):
def getProjectOpenChanges(self, project):
raise NotImplemented()
def getRequireFilters(self, config):
return []
def getRejectFilters(self, config):
return []

View File

@ -41,3 +41,9 @@ class GithubDriver(Driver, ConnectionInterface, TriggerInterface,
def getReporterSchema(self):
return githubreporter.getSchema()
def getRequireSchema(self):
return githubsource.getRequireSchema()
def getRejectSchema(self):
return githubsource.getRejectSchema()

View File

@ -25,8 +25,9 @@ import github3
from github3.exceptions import MethodNotAllowed
from zuul.connection import BaseConnection
from zuul.model import PullRequest, Ref, GithubTriggerEvent
from zuul.model import Ref
from zuul.exceptions import MergeFailure
from zuul.driver.github.githubmodel import PullRequest, GithubTriggerEvent
class GithubWebhookListener():

View File

@ -0,0 +1,163 @@
# Copyright 2015 Hewlett-Packard Development Company, L.P.
# Copyright 2017 IBM Corp.
# Copyright 2017 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import re
from zuul.model import Change, TriggerEvent, EventFilter
EMPTY_GIT_REF = '0' * 40 # git sha of all zeros, used during creates/deletes
class PullRequest(Change):
def __init__(self, project):
super(PullRequest, self).__init__(project)
self.updated_at = None
self.title = None
def isUpdateOf(self, other):
if (hasattr(other, 'number') and self.number == other.number and
hasattr(other, 'patchset') and self.patchset != other.patchset and
hasattr(other, 'updated_at') and
self.updated_at > other.updated_at):
return True
return False
class GithubTriggerEvent(TriggerEvent):
def __init__(self):
super(GithubTriggerEvent, self).__init__()
self.title = None
self.label = None
self.unlabel = None
def isPatchsetCreated(self):
if self.type == 'pull_request':
return self.action in ['opened', 'changed']
return False
def isChangeAbandoned(self):
if self.type == 'pull_request':
return 'closed' == self.action
return False
class GithubEventFilter(EventFilter):
def __init__(self, trigger, types=[], branches=[], refs=[],
comments=[], actions=[], labels=[], unlabels=[],
states=[], ignore_deletes=True):
EventFilter.__init__(self, trigger)
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.ignore_deletes = ignore_deletes
def __repr__(self):
ret = '<GithubEventFilter'
if self._types:
ret += ' types: %s' % ', '.join(self._types)
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._comments:
ret += ' comments: %s' % ', '.join(self._comments)
if self.actions:
ret += ' actions: %s' % ', '.join(self.actions)
if self.labels:
ret += ' labels: %s' % ', '.join(self.labels)
if self.unlabels:
ret += ' unlabels: %s' % ', '.join(self.unlabels)
if self.states:
ret += ' states: %s' % ', '.join(self.states)
ret += '>'
return ret
def matches(self, event, change):
# 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
# 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 self.comments and not matches_comment_re:
return False
# actions are ORed
matches_action = False
for action in self.actions:
if (event.action == action):
matches_action = True
if self.actions and not matches_action:
return False
# labels are ORed
if self.labels and event.label not in self.labels:
return False
# unlabels are ORed
if self.unlabels and event.unlabel not in self.unlabels:
return False
# states are ORed
if self.states and event.state not in self.states:
return False
return True

View File

@ -90,3 +90,17 @@ class GithubSource(BaseSource):
def _ghTimestampToDate(self, timestamp):
return time.strptime(timestamp, '%Y-%m-%dT%H:%M:%SZ')
def getRequireFilters(self, config):
return []
def getRejectFilters(self, config):
return []
def getRequireSchema():
return {}
def getRejectSchema():
return {}

View File

@ -14,8 +14,8 @@
import logging
import voluptuous as v
from zuul.model import EventFilter
from zuul.trigger import BaseTrigger
from zuul.driver.github.githubmodel import GithubEventFilter
class GithubTrigger(BaseTrigger):
@ -32,7 +32,7 @@ class GithubTrigger(BaseTrigger):
efilters = []
for trigger in toList(trigger_config):
f = EventFilter(
f = GithubEventFilter(
trigger=self,
types=toList(trigger['event']),
actions=toList(trigger.get('action')),

View File

@ -20,8 +20,8 @@ from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from zuul.driver import Driver, TriggerInterface
from zuul.model import TriggerEvent
from zuul.driver.timer import timertrigger
from zuul.driver.timer.timermodel import TimerTriggerEvent
class TimerDriver(Driver, TriggerInterface):
@ -81,7 +81,7 @@ class TimerDriver(Driver, TriggerInterface):
def _onTrigger(self, tenant, pipeline_name, timespec):
for project_name in tenant.layout.project_configs.keys():
project_hostname, project_name = project_name.split('/', 1)
event = TriggerEvent()
event = TimerTriggerEvent()
event.type = 'timer'
event.timespec = timespec
event.forced_pipeline = pipeline_name

View File

@ -0,0 +1,62 @@
# Copyright 2017 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import re
from zuul.model import EventFilter, TriggerEvent
class TimerEventFilter(EventFilter):
def __init__(self, trigger, types=[], timespecs=[]):
EventFilter.__init__(self, trigger)
self._types = types
self.types = [re.compile(x) for x in types]
self.timespecs = timespecs
def __repr__(self):
ret = '<TimerEventFilter'
if self._types:
ret += ' types: %s' % ', '.join(self._types)
if self.timespecs:
ret += ' timespecs: %s' % ', '.join(self.timespecs)
ret += '>'
return ret
def matches(self, event, change):
# 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
# timespecs are ORed
matches_timespec = False
for timespec in self.timespecs:
if (event.timespec == timespec):
matches_timespec = True
if self.timespecs and not matches_timespec:
return False
return True
class TimerTriggerEvent(TriggerEvent):
def __init__(self):
super(TimerTriggerEvent, self).__init__()
self.timespec = None

View File

@ -15,26 +15,20 @@
import voluptuous as v
from zuul.model import EventFilter
from zuul.trigger import BaseTrigger
from zuul.driver.timer.timermodel import TimerEventFilter
from zuul.driver.util import to_list
class TimerTrigger(BaseTrigger):
name = 'timer'
def getEventFilters(self, trigger_conf):
def toList(item):
if not item:
return []
if isinstance(item, list):
return item
return [item]
efilters = []
for trigger in toList(trigger_conf):
f = EventFilter(trigger=self,
types=['timer'],
timespecs=toList(trigger['time']))
for trigger in to_list(trigger_conf):
f = TimerEventFilter(trigger=self,
types=['timer'],
timespecs=to_list(trigger['time']))
efilters.append(f)

43
zuul/driver/util.py Normal file
View File

@ -0,0 +1,43 @@
# Copyright 2017 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
# Utility methods to promote consistent configuration among drivers.
import voluptuous as vs
def time_to_seconds(s):
if s.endswith('s'):
return int(s[:-1])
if s.endswith('m'):
return int(s[:-1]) * 60
if s.endswith('h'):
return int(s[:-1]) * 60 * 60
if s.endswith('d'):
return int(s[:-1]) * 24 * 60 * 60
if s.endswith('w'):
return int(s[:-1]) * 7 * 24 * 60 * 60
raise Exception("Unable to parse time value: %s" % s)
def scalar_or_list(x):
return vs.Any([x], x)
def to_list(item):
if not item:
return []
if isinstance(item, list):
return item
return [item]

View File

@ -15,7 +15,7 @@
import logging
from zuul.driver import Driver, TriggerInterface
from zuul.model import TriggerEvent
from zuul.driver.zuul.zuulmodel import ZuulTriggerEvent
from zuul.driver.zuul import zuultrigger
@ -73,7 +73,7 @@ class ZuulDriver(Driver, TriggerInterface):
self._createProjectChangeMergedEvent(open_change)
def _createProjectChangeMergedEvent(self, change):
event = TriggerEvent()
event = ZuulTriggerEvent()
event.type = PROJECT_CHANGE_MERGED
event.trigger_name = self.name
event.project_hostname = change.project.canonical_hostname
@ -94,7 +94,7 @@ class ZuulDriver(Driver, TriggerInterface):
self._createParentChangeEnqueuedEvent(needs, pipeline)
def _createParentChangeEnqueuedEvent(self, change, pipeline):
event = TriggerEvent()
event = ZuulTriggerEvent()
event.type = PARENT_CHANGE_ENQUEUED
event.trigger_name = self.name
event.pipeline_name = pipeline.name

View File

@ -0,0 +1,63 @@
# Copyright 2017 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import re
from zuul.model import EventFilter, TriggerEvent
class ZuulEventFilter(EventFilter):
def __init__(self, trigger, types=[], pipelines=[]):
EventFilter.__init__(self, trigger)
self._types = types
self._pipelines = pipelines
self.types = [re.compile(x) for x in types]
self.pipelines = [re.compile(x) for x in pipelines]
def __repr__(self):
ret = '<ZuulEventFilter'
if self._types:
ret += ' types: %s' % ', '.join(self._types)
if self._pipelines:
ret += ' pipelines: %s' % ', '.join(self._pipelines)
ret += '>'
return ret
def matches(self, event, change):
# 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
# pipelines are ORed
matches_pipeline = False
for epipe in self.pipelines:
if epipe.match(event.pipeline_name):
matches_pipeline = True
if self.pipelines and not matches_pipeline:
return False
return True
class ZuulTriggerEvent(TriggerEvent):
def __init__(self):
super(ZuulTriggerEvent, self).__init__()
self.pipeline_name = None

View File

@ -15,8 +15,9 @@
import logging
import voluptuous as v
from zuul.model import EventFilter
from zuul.trigger import BaseTrigger
from zuul.driver.zuul.zuulmodel import ZuulEventFilter
from zuul.driver.util import scalar_or_list, to_list
class ZuulTrigger(BaseTrigger):
@ -29,25 +30,12 @@ class ZuulTrigger(BaseTrigger):
self._handle_project_change_merged_events = False
def getEventFilters(self, trigger_conf):
def toList(item):
if not item:
return []
if isinstance(item, list):
return item
return [item]
efilters = []
for trigger in toList(trigger_conf):
f = EventFilter(
for trigger in to_list(trigger_conf):
f = ZuulEventFilter(
trigger=self,
types=toList(trigger['event']),
pipelines=toList(trigger.get('pipeline')),
required_approvals=(
toList(trigger.get('require-approval'))
),
reject_approvals=toList(
trigger.get('reject-approval')
),
types=to_list(trigger['event']),
pipelines=to_list(trigger.get('pipeline')),
)
efilters.append(f)
@ -55,9 +43,6 @@ class ZuulTrigger(BaseTrigger):
def getSchema():
def toList(x):
return v.Any([x], x)
approval = v.Schema({'username': str,
'email-filter': str,
'email': str,
@ -67,11 +52,11 @@ def getSchema():
zuul_trigger = {
v.Required('event'):
toList(v.Any('parent-change-enqueued',
'project-change-merged')),
'pipeline': toList(str),
'require-approval': toList(approval),
'reject-approval': toList(approval),
scalar_or_list(v.Any('parent-change-enqueued',
'project-change-merged')),
'pipeline': scalar_or_list(str),
'require-approval': scalar_or_list(approval),
'reject-approval': scalar_or_list(approval),
}
return zuul_trigger

View File

@ -16,7 +16,6 @@ import abc
import copy
import logging
import os
import re
import struct
import time
from uuid import uuid4
@ -28,8 +27,6 @@ OrderedDict = extras.try_imports(['collections.OrderedDict',
'ordereddict.OrderedDict'])
EMPTY_GIT_REF = '0' * 40 # git sha of all zeros, used during creates/deletes
MERGER_MERGE = 1 # "git merge"
MERGER_MERGE_RESOLVE = 2 # "git merge -s resolve"
MERGER_CHERRY_PICK = 3 # "git cherry-pick"
@ -78,25 +75,6 @@ NODE_STATES = set([STATE_BUILDING,
STATE_DELETING])
def time_to_seconds(s):
if s.endswith('s'):
return int(s[:-1])
if s.endswith('m'):
return int(s[:-1]) * 60
if s.endswith('h'):
return int(s[:-1]) * 60 * 60
if s.endswith('d'):
return int(s[:-1]) * 24 * 60 * 60
if s.endswith('w'):
return int(s[:-1]) * 7 * 24 * 60 * 60
raise Exception("Unable to parse time value: %s" % s)
def normalizeCategory(name):
name = name.lower()
return re.sub(' ', '-', name)
class Attributes(object):
"""A class to hold attributes for string formatting."""
@ -1810,7 +1788,6 @@ class Change(Ref):
self.can_merge = False
self.is_merged = False
self.failed_to_merge = False
self.approvals = []
self.open = None
self.status = None
self.owner = None
@ -1863,24 +1840,10 @@ class Change(Ref):
patchset=self.patchset)
class PullRequest(Change):
def __init__(self, project):
super(PullRequest, self).__init__(project)
self.updated_at = None
self.title = None
def isUpdateOf(self, other):
if (hasattr(other, 'number') and self.number == other.number and
hasattr(other, 'patchset') and self.patchset != other.patchset and
hasattr(other, 'updated_at') and
self.updated_at > other.updated_at):
return True
return False
class TriggerEvent(object):
"""Incoming event from an external system."""
def __init__(self):
# TODO(jeblair): further reduce this list
self.data = None
# common
self.type = None
@ -1896,20 +1859,13 @@ class TriggerEvent(object):
self.change_url = None
self.patch_number = None
self.refspec = None
self.approvals = []
self.branch = None
self.comment = None
self.label = None
self.unlabel = None
self.state = None
# ref-updated
self.ref = None
self.oldrev = None
self.newrev = None
# timer
self.timespec = None
# zuultrigger
self.pipeline_name = None
# For events that arrive with a destination pipeline (eg, from
# an admin command, etc):
self.forced_pipeline = None
@ -1918,374 +1874,35 @@ class TriggerEvent(object):
def canonical_project_name(self):
return self.project_hostname + '/' + self.project_name
def __repr__(self):
ret = '<TriggerEvent %s %s' % (self.type, self.canonical_project_name)
if self.branch:
ret += " %s" % self.branch
if self.change_number:
ret += " %s,%s" % (self.change_number, self.patch_number)
if self.approvals:
ret += ' ' + ', '.join(
['%s:%s' % (a['type'], a['value']) for a in self.approvals])
ret += '>'
return ret
def isPatchsetCreated(self):
return 'patchset-created' == self.type
def isChangeAbandoned(self):
return 'change-abandoned' == self.type
class GithubTriggerEvent(TriggerEvent):
def __init__(self):
super(GithubTriggerEvent, self).__init__()
self.title = None
def isPatchsetCreated(self):
if self.type == 'pull_request':
return self.action in ['opened', 'changed']
return False
def isChangeAbandoned(self):
if self.type == 'pull_request':
return 'closed' == self.action
return False
class BaseFilter(object):
"""Base Class for filtering which Changes and Events to process."""
def __init__(self, required_approvals=[], reject_approvals=[]):
self._required_approvals = copy.deepcopy(required_approvals)
self.required_approvals = self._tidy_approvals(required_approvals)
self._reject_approvals = copy.deepcopy(reject_approvals)
self.reject_approvals = self._tidy_approvals(reject_approvals)
def _tidy_approvals(self, approvals):
for a in approvals:
for k, v in a.items():
if k == 'username':
a['username'] = re.compile(v)
elif k in ['email', 'email-filter']:
a['email'] = re.compile(v)
elif k == 'newer-than':
a[k] = time_to_seconds(v)
elif k == 'older-than':
a[k] = time_to_seconds(v)
if 'email-filter' in a:
del a['email-filter']
return approvals
def _match_approval_required_approval(self, rapproval, approval):
# Check if the required approval and approval match
if 'description' not in approval:
return False
now = time.time()
by = approval.get('by', {})
for k, v in rapproval.items():
if k == 'username':
if (not v.search(by.get('username', ''))):
return False
elif k == 'email':
if (not v.search(by.get('email', ''))):
return False
elif k == 'newer-than':
t = now - v
if (approval['grantedOn'] < t):
return False
elif k == 'older-than':
t = now - v
if (approval['grantedOn'] >= t):
return False
else:
if not isinstance(v, list):
v = [v]
if (normalizeCategory(approval['description']) != k or
int(approval['value']) not in v):
return False
return True
def matchesApprovals(self, change):
if (self.required_approvals and not change.approvals
or self.reject_approvals and not change.approvals):
# A change with no approvals can not match
return False
# TODO(jhesketh): If we wanted to optimise this slightly we could
# analyse both the REQUIRE and REJECT filters by looping over the
# approvals on the change and keeping track of what we have checked
# rather than needing to loop on the change approvals twice
return (self.matchesRequiredApprovals(change) and
self.matchesNoRejectApprovals(change))
def matchesRequiredApprovals(self, change):
# Check if any approvals match the requirements
for rapproval in self.required_approvals:
matches_rapproval = False
for approval in change.approvals:
if self._match_approval_required_approval(rapproval, approval):
# We have a matching approval so this requirement is
# fulfilled
matches_rapproval = True
break
if not matches_rapproval:
return False
return True
def matchesNoRejectApprovals(self, change):
# Check to make sure no approvals match a reject criteria
for rapproval in self.reject_approvals:
for approval in change.approvals:
if self._match_approval_required_approval(rapproval, approval):
# A reject approval has been matched, so we reject
# immediately
return False
# To get here no rejects can have been matched so we should be good to
# queue
return True
pass
class EventFilter(BaseFilter):
"""Allows a Pipeline to only respond to certain events."""
def __init__(self, trigger, types=[], branches=[], refs=[],
event_approvals={}, comments=[], emails=[], usernames=[],
timespecs=[], required_approvals=[], reject_approvals=[],
pipelines=[], actions=[], labels=[], unlabels=[], states=[],
ignore_deletes=True):
super(EventFilter, self).__init__(
required_approvals=required_approvals,
reject_approvals=reject_approvals)
def __init__(self, trigger):
super(EventFilter, self).__init__()
self.trigger = trigger
self._types = types
self._branches = branches
self._refs = refs
self._comments = comments
self._emails = emails
self._usernames = usernames
self._pipelines = pipelines
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.pipelines = [re.compile(x) for x in pipelines]
self.actions = actions
self.event_approvals = event_approvals
self.timespecs = timespecs
self.labels = labels
self.unlabels = unlabels
self.states = states
self.ignore_deletes = ignore_deletes
def __repr__(self):
ret = '<EventFilter'
if self._types:
ret += ' types: %s' % ', '.join(self._types)
if self._pipelines:
ret += ' pipelines: %s' % ', '.join(self._pipelines)
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 += ' username_filters: %s' % ', '.join(self._usernames)
if self.timespecs:
ret += ' timespecs: %s' % ', '.join(self.timespecs)
if self.actions:
ret += ' actions: %s' % ', '.join(self.actions)
if self.labels:
ret += ' labels: %s' % ', '.join(self.labels)
if self.unlabels:
ret += ' unlabels: %s' % ', '.join(self.unlabels)
if self.states:
ret += ' states: %s' % ', '.join(self.states)
ret += '>'
return ret
def matches(self, event, change):
# 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
# pipelines are ORed
matches_pipeline = False
for epipe in self.pipelines:
if epipe.match(event.pipeline_name):
matches_pipeline = True
if self.pipelines and not matches_pipeline:
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 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 eapproval in event.approvals:
if (normalizeCategory(eapproval['description']) == category and
int(eapproval['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
# timespecs are ORed
matches_timespec = False
for timespec in self.timespecs:
if (event.timespec == timespec):
matches_timespec = True
if self.timespecs and not matches_timespec:
return False
# actions are ORed
matches_action = False
for action in self.actions:
if (event.action == action):
matches_action = True
if self.actions and not matches_action:
return False
# labels are ORed
if self.labels and event.label not in self.labels:
return False
# unlabels are ORed
if self.unlabels and event.unlabel not in self.unlabels:
return False
# states are ORed
if self.states and event.state not in self.states:
return False
def matches(self, event, ref):
# TODO(jeblair): consider removing ref argument
return True
class ChangeishFilter(BaseFilter):
class RefFilter(BaseFilter):
"""Allows a Manager to only enqueue Changes that meet certain criteria."""
def __init__(self, open=None, current_patchset=None,
statuses=[], required_approvals=[],
reject_approvals=[]):
super(ChangeishFilter, self).__init__(
required_approvals=required_approvals,
reject_approvals=reject_approvals)
self.open = open
self.current_patchset = current_patchset
self.statuses = statuses
def __repr__(self):
ret = '<ChangeishFilter'
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 __init__(self):
super(RefFilter, self).__init__()
def matches(self, change):
if self.open is not None:
if self.open != change.open:
return False
if self.current_patchset is not None:
if self.current_patchset != change.is_current_patchset:
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

@ -69,3 +69,13 @@ class BaseSource(object):
@abc.abstractmethod
def getProjectBranches(self, project):
"""Get branches for a project"""
@abc.abstractmethod
def getRequireFilters(self, config):
"""Return a list of ChangeFilters for the scheduler to match against.
"""
@abc.abstractmethod
def getRejectFilters(self, config):
"""Return a list of ChangeFilters for the scheduler to match against.
"""