Files
zuul/zuul/driver/github/githubmodel.py
T
Tobias Henkel abf973e324 Fix dynamic layout with regex approval filters
In case the layout includes some regex approval filters (like
username) the dynamic layout creation fails during deepcopy of the
pipelines [1]. This is caused by Github/GerritApprovalFilter overwriting
stuff in the original data structure. Fix this by using the already
existing deepcopied data structures.

To test this an unused pipeline with approval filters for Github and
Gerrit is added to the dynamic layout test cases. This triggers the
deepcopy error without the fix in every test_dynamic_* test case.

[1] Traceback (most recent call last):
    File "/usr/lib/python3.6/site-packages/zuul/manager/__init__.py", line 453, in _loadDynamicLayout
      include_config_projects=False)
    File "/usr/lib/python3.6/site-packages/zuul/configloader.py", line 1478, in createDynamicLayout
      config = tenant.config_projects_config.copy()
    File "/usr/lib/python3.6/site-packages/zuul/model.py", line 2150, in copy
      r.pipelines = copy.deepcopy(self.pipelines)
    File "/usr/lib/python3.6/copy.py", line 150, in deepcopy
      y = copier(x, memo)
  ...
    File "/usr/lib/python3.6/copy.py", line 240, in _deepcopy_dict
      y[deepcopy(key, memo)] = deepcopy(value, memo)
    File "/usr/lib/python3.6/copy.py", line 161, in deepcopy
      y = copier(memo)
  TypeError: cannot deepcopy this pattern object

Change-Id: I4f7f74787aa91e938d0e2f07bd15b4e21d49cb88
2017-07-28 10:36:09 +02:00

358 lines
12 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 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 (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 GithubCommonFilter(object):
def __init__(self, required_reviews=[], required_statuses=[],
reject_reviews=[]):
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
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. Write or admin permission is different.
if v != 'read':
if review['permission'] != v:
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 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:
if not hasattr(change, 'number'):
# not a PR, no status
return False
if set(change.status).isdisjoint(set(self.required_statuses)):
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 and event.status not in self.statuses:
return False
if not self.matchesRequiredStatuses(change):
return False
return True
class GithubRefFilter(RefFilter, GithubCommonFilter):
def __init__(self, connection_name, statuses=[], required_reviews=[],
reject_reviews=[], open=None, current_patchset=None,
labels=[]):
RefFilter.__init__(self, connection_name)
GithubCommonFilter.__init__(self, required_reviews=required_reviews,
reject_reviews=reject_reviews,
required_statuses=statuses)
self.statuses = statuses
self.open = open
self.current_patchset = current_patchset
self.labels = labels
def __repr__(self):
ret = '<GithubRefFilter'
ret += ' connection_name: %s' % self.connection_name
if self.statuses:
ret += ' statuses: %s' % ', '.join(self.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.current_patchset:
ret += ' current-patchset: %s' % self.current_patchset
if self.labels:
ret += ' labels: %s' % self.labels
ret += '>'
return ret
def matches(self, change):
if not self.matchesRequiredStatuses(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.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
return True