The GithubTriggerEvents currently don't log any detail of the event itself. This makes it hard to analize issues using the log. We should add more details about the action, project and pull request if available. Change-Id: Id07aa2c0bcfe743a0bfa34d635e77e9ecac4dc48
438 lines
15 KiB
Python
438 lines
15 KiB
Python
# 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 copy
|
|
import re
|
|
import re2
|
|
import time
|
|
|
|
from zuul.model import Change, TriggerEvent, EventFilter, RefFilter
|
|
from zuul.driver.util import time_to_seconds
|
|
|
|
|
|
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.project = None
|
|
self.pr = None
|
|
self.updated_at = None
|
|
self.title = None
|
|
self.reviews = []
|
|
self.files = []
|
|
self.labels = []
|
|
|
|
def isUpdateOf(self, other):
|
|
if (self.project == other.project and
|
|
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
|
|
self.action = None
|
|
self.delivery = 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
|
|
|
|
def _repr(self):
|
|
r = [super(GithubTriggerEvent, self)._repr()]
|
|
if self.action:
|
|
r.append(self.action)
|
|
r.append(self.canonical_project_name)
|
|
if self.change_number:
|
|
r.append('%s,%s' % (self.change_number, self.patch_number))
|
|
if self.delivery:
|
|
r.append('delivery: %s' % self.delivery)
|
|
return ' '.join(r)
|
|
|
|
|
|
class GithubCommonFilter(object):
|
|
def __init__(self, required_reviews=[], required_statuses=[],
|
|
reject_reviews=[], reject_statuses=[]):
|
|
self._required_reviews = copy.deepcopy(required_reviews)
|
|
self._reject_reviews = copy.deepcopy(reject_reviews)
|
|
self.required_reviews = self._tidy_reviews(self._required_reviews)
|
|
self.reject_reviews = self._tidy_reviews(self._reject_reviews)
|
|
self.required_statuses = required_statuses
|
|
self.reject_statuses = reject_statuses
|
|
|
|
def _tidy_reviews(self, reviews):
|
|
for r in reviews:
|
|
for k, v in r.items():
|
|
if k == 'username':
|
|
r['username'] = re.compile(v)
|
|
elif k == 'email':
|
|
r['email'] = re.compile(v)
|
|
elif k == 'newer-than':
|
|
r[k] = time_to_seconds(v)
|
|
elif k == 'older-than':
|
|
r[k] = time_to_seconds(v)
|
|
return reviews
|
|
|
|
def _match_review_required_review(self, rreview, review):
|
|
# Check if the required review and review match
|
|
now = time.time()
|
|
by = review.get('by', {})
|
|
for k, v in rreview.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 (review['grantedOn'] < t):
|
|
return False
|
|
elif k == 'older-than':
|
|
t = now - v
|
|
if (review['grantedOn'] >= t):
|
|
return False
|
|
elif k == 'type':
|
|
if review['type'] != v:
|
|
return False
|
|
elif k == 'permission':
|
|
# If permission is read, we've matched. You must have read
|
|
# to provide a review.
|
|
if v != 'read':
|
|
# Admins have implicit write.
|
|
if v == 'write':
|
|
if review['permission'] not in ('write', 'admin'):
|
|
return False
|
|
elif v == 'admin':
|
|
if review['permission'] != 'admin':
|
|
return False
|
|
return True
|
|
|
|
def matchesReviews(self, change):
|
|
if self.required_reviews or self.reject_reviews:
|
|
if not hasattr(change, 'number'):
|
|
# not a PR, no reviews
|
|
return False
|
|
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 False
|
|
|
|
return (self.matchesRequiredReviews(change) and
|
|
self.matchesNoRejectReviews(change))
|
|
|
|
def matchesRequiredReviews(self, change):
|
|
for rreview in self.required_reviews:
|
|
matches_review = False
|
|
for review in change.reviews:
|
|
if self._match_review_required_review(rreview, review):
|
|
# Consider matched if any review matches
|
|
matches_review = True
|
|
break
|
|
if not matches_review:
|
|
return False
|
|
return True
|
|
|
|
def matchesNoRejectReviews(self, change):
|
|
for rreview in self.reject_reviews:
|
|
for review in change.reviews:
|
|
if self._match_review_required_review(rreview, review):
|
|
# A review matched, we can reject right away
|
|
return False
|
|
return True
|
|
|
|
def matchesStatuses(self, change):
|
|
if self.required_statuses or self.reject_statuses:
|
|
if not hasattr(change, 'number'):
|
|
# not a PR, no status
|
|
return False
|
|
if self.required_statuses and not change.status:
|
|
return False
|
|
return (self.matchesRequiredStatuses(change) and
|
|
self.matchesNoRejectStatuses(change))
|
|
|
|
def matchesRequiredStatuses(self, change):
|
|
# statuses are ORed
|
|
# A PR head can have multiple statuses on it. If the change
|
|
# statuses and the filter statuses are a null intersection, there
|
|
# are no matches and we return false
|
|
if self.required_statuses:
|
|
for required_status in self.required_statuses:
|
|
for status in change.status:
|
|
if re2.fullmatch(required_status, status):
|
|
return True
|
|
return False
|
|
return True
|
|
|
|
def matchesNoRejectStatuses(self, change):
|
|
# statuses are ANDed
|
|
# If any of the rejected statusses are present, we return false
|
|
for rstatus in self.reject_statuses:
|
|
for status in change.status:
|
|
if re2.fullmatch(rstatus, status):
|
|
return False
|
|
return True
|
|
|
|
|
|
class GithubEventFilter(EventFilter, GithubCommonFilter):
|
|
def __init__(self, trigger, types=[], branches=[], refs=[],
|
|
comments=[], actions=[], labels=[], unlabels=[],
|
|
states=[], statuses=[], required_statuses=[],
|
|
ignore_deletes=True):
|
|
|
|
EventFilter.__init__(self, trigger)
|
|
|
|
GithubCommonFilter.__init__(self, required_statuses=required_statuses)
|
|
|
|
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.required_statuses = required_statuses
|
|
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)
|
|
if self.statuses:
|
|
ret += ' statuses: %s' % ', '.join(self.statuses)
|
|
if self.required_statuses:
|
|
ret += ' required_statuses: %s' % ', '.join(self.required_statuses)
|
|
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
|
|
|
|
# statuses are ORed
|
|
if self.statuses:
|
|
status_found = False
|
|
for status in self.statuses:
|
|
if re2.fullmatch(status, event.status):
|
|
status_found = True
|
|
break
|
|
if not status_found:
|
|
return False
|
|
|
|
if not self.matchesStatuses(change):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
class GithubRefFilter(RefFilter, GithubCommonFilter):
|
|
def __init__(self, connection_name, statuses=[], required_reviews=[],
|
|
reject_reviews=[], open=None, merged=None,
|
|
current_patchset=None, reject_open=None, reject_merged=None,
|
|
reject_current_patchset=None, labels=[], reject_labels=[],
|
|
reject_statuses=[]):
|
|
RefFilter.__init__(self, connection_name)
|
|
|
|
GithubCommonFilter.__init__(self, required_reviews=required_reviews,
|
|
reject_reviews=reject_reviews,
|
|
required_statuses=statuses,
|
|
reject_statuses=reject_statuses)
|
|
self.statuses = statuses
|
|
if reject_open is not None:
|
|
self.open = not reject_open
|
|
else:
|
|
self.open = open
|
|
if reject_merged is not None:
|
|
self.merged = not reject_merged
|
|
else:
|
|
self.merged = merged
|
|
if reject_current_patchset is not None:
|
|
self.current_patchset = not reject_current_patchset
|
|
else:
|
|
self.current_patchset = current_patchset
|
|
self.labels = labels
|
|
self.reject_labels = reject_labels
|
|
|
|
def __repr__(self):
|
|
ret = '<GithubRefFilter'
|
|
|
|
ret += ' connection_name: %s' % self.connection_name
|
|
if self.statuses:
|
|
ret += ' statuses: %s' % ', '.join(self.statuses)
|
|
if self.reject_statuses:
|
|
ret += ' reject-statuses: %s' % ', '.join(self.reject_statuses)
|
|
if self.required_reviews:
|
|
ret += (' required-reviews: %s' %
|
|
str(self.required_reviews))
|
|
if self.reject_reviews:
|
|
ret += (' reject-reviews: %s' %
|
|
str(self.reject_reviews))
|
|
if self.open:
|
|
ret += ' open: %s' % self.open
|
|
if self.merged:
|
|
ret += ' merged: %s' % self.merged
|
|
if self.current_patchset:
|
|
ret += ' current-patchset: %s' % self.current_patchset
|
|
if self.labels:
|
|
ret += ' labels: %s' % self.labels
|
|
if self.reject_labels:
|
|
ret += ' reject-labels: %s' % self.reject_labels
|
|
|
|
ret += '>'
|
|
|
|
return ret
|
|
|
|
def matches(self, change):
|
|
if not self.matchesStatuses(change):
|
|
return False
|
|
|
|
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 False
|
|
else:
|
|
return False
|
|
|
|
if self.merged 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.merged != change.is_merged:
|
|
return False
|
|
else:
|
|
return False
|
|
|
|
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 False
|
|
else:
|
|
return False
|
|
|
|
# required reviews are ANDed (reject reviews are ORed)
|
|
if not self.matchesReviews(change):
|
|
return False
|
|
|
|
# required labels are ANDed
|
|
for label in self.labels:
|
|
if label not in change.labels:
|
|
return False
|
|
|
|
# rejected reviews are OR'd
|
|
for label in self.reject_labels:
|
|
if label in change.labels:
|
|
return False
|
|
|
|
return True
|