Adds github triggering from status updates

This adds support for triggering on github status updates.

Config schema for the github trigger has been updated to accept a list
of statuses, in the "github_user:context:status" format.

Change-Id: I15aef35716ddbcd1e66f84a73d27ca2689c936e4
Co-Authored-By: Jesse Keating <omgjlk@us.ibm.com>
Signed-off-by: Adam Gandelman <adamg@ubuntu.com>
This commit is contained in:
Adam Gandelman 2017-01-23 16:31:06 -08:00 committed by Jesse Keating
parent d96e5887b8
commit 8c6eeb5e8b
7 changed files with 162 additions and 14 deletions

View File

@ -133,6 +133,8 @@ following options.
*push* - head reference updated (pushed to branch)
*status* - status set on commit
A ``pull_request_review`` event will
have associated action(s) to trigger from. The supported actions are:
@ -165,6 +167,12 @@ following options.
strings each of which is matched to the review state, which can be one of
``approved``, ``comment``, or ``request_changes``.
**status**
This is only used for ``status`` actions. It accepts a list of strings each of
which matches the user setting the status, the status context, and the status
itself in the format of ``user:context:status``. For example,
``zuul_github_ci_bot:check_pipeline:success``.
**ref**
This is only used for ``push`` events. This field is treated as a regular
expression and multiple refs may be listed. Github always sends full ref

View File

@ -758,10 +758,9 @@ class FakeGithubPullRequest(object):
repo = self._getRepo()
return repo.references[self._getPRReference()].commit.hexsha
def setStatus(self, sha, state, url, description, context):
def setStatus(self, sha, state, url, description, context, user='zuul'):
# Since we're bypassing github API, which would require a user, we
# hard set the user as 'zuul' here.
user = 'zuul'
# insert the status at the top of the list, to simulate that it
# is the most recent set status
self.statuses[sha].insert(0, ({
@ -805,6 +804,21 @@ class FakeGithubPullRequest(object):
}
return (name, data)
def getCommitStatusEvent(self, context, state='success', user='zuul'):
name = 'status'
data = {
'state': state,
'sha': self.head_sha,
'description': 'Test results for %s: %s' % (self.head_sha, state),
'target_url': 'http://zuul/%s' % self.head_sha,
'branches': [],
'context': context,
'sender': {
'login': user
}
}
return (name, data)
class FakeGithubConnection(githubconnection.GithubConnection):
log = logging.getLogger("zuul.test.FakeGithubConnection")
@ -878,6 +892,13 @@ class FakeGithubConnection(githubconnection.GithubConnection):
}
return data
def getPullBySha(self, sha):
prs = list(set([p for p in self.pull_requests if sha == p.head_sha]))
if len(prs) > 1:
raise Exception('Multiple pulls found with head sha: %s' % sha)
pr = prs[0]
return self.getPull(pr.project, pr.number)
def getPullFileNames(self, project, number):
pr = self.pull_requests[number - 1]
return pr.files

View File

@ -13,11 +13,34 @@
github:
comment: true
- pipeline:
name: trigger
manager: independent
trigger:
github:
- event: pull_request
action: status
status: 'zuul:check:success'
success:
github:
status: 'success'
failure:
github:
status: 'failure'
- job:
name: project1-pipeline
- job:
name: project2-trigger
- project:
name: org/project1
pipeline:
jobs:
- project1-pipeline
- project:
name: org/project2
trigger:
jobs:
- project2-trigger

View File

@ -43,3 +43,39 @@ class TestGithubRequirements(ZuulTestCase):
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, 'project1-pipeline')
@simple_layout('layouts/requirements-github.yaml', driver='github')
def test_trigger_require_status(self):
"Test trigger requirement: status"
A = self.fake_github.openFakePullRequest('org/project2', 'master', 'A')
# An error status should not cause it to be enqueued
A.setStatus(A.head_sha, 'error', 'null', 'null', 'check')
self.fake_github.emitEvent(A.getCommitStatusEvent('check',
state='error'))
self.waitUntilSettled()
self.assertEqual(len(self.history), 0)
# An success status from unknown user should not cause it to be
# enqueued
A.setStatus(A.head_sha, 'success', 'null', 'null', 'check', user='foo')
self.fake_github.emitEvent(A.getCommitStatusEvent('check',
state='success',
user='foo'))
self.waitUntilSettled()
self.assertEqual(len(self.history), 0)
# A success status goes in
A.setStatus(A.head_sha, 'success', 'null', 'null', 'check')
self.fake_github.emitEvent(A.getCommitStatusEvent('check'))
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, 'project2-trigger')
# An error status for a different context should not cause it to be
# enqueued
A.setStatus(A.head_sha, 'error', 'null', 'null', 'gate')
self.fake_github.emitEvent(A.getCommitStatusEvent('gate',
state='error'))
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)

View File

@ -162,6 +162,26 @@ class GithubWebhookListener():
event.action = body.get('action')
return event
def _event_status(self, request):
body = request.json_body
action = body.get('action')
if action == 'pending':
return
pr_body = self.connection.getPullBySha(body['sha'])
if pr_body is None:
return
event = self._pull_request_to_event(pr_body)
event.account = self._get_sender(body)
event.type = 'pull_request'
event.action = 'status'
# Github API is silly. Webhook blob sets author data in
# 'sender', but API call to get status puts it in 'creator'.
# Duplicate the data so our code can look in one place
body['creator'] = body['sender']
event.status = "%s:%s:%s" % _status_as_tuple(body)
return event
def _issue_to_pull_request(self, body):
number = body.get('issue').get('number')
project_name = body.get('repository').get('full_name')
@ -377,6 +397,30 @@ class GithubConnection(BaseConnection):
# For now, just send back a True value.
return True
def getPullBySha(self, sha):
query = '%s type:pr is:open' % sha
pulls = []
for issue in self.github.search_issues(query=query):
pr_url = issue.pull_request.get('url')
if not pr_url:
continue
# the issue provides no good description of the project :\
owner, project, _, number = pr_url.split('/')[4:]
pr = self.github.pull_request(owner, project, number)
if pr.head.sha != sha:
continue
if pr.as_dict() in pulls:
continue
pulls.append(pr.as_dict())
log_rate_limit(self.log, self.github)
if len(pulls) > 1:
raise Exception('Multiple pulls found with head sha %s' % sha)
if len(pulls) == 0:
return None
return pulls.pop()
def getPullFileNames(self, project, number):
owner, proj = project.name.split('/')
filenames = [f.filename for f in
@ -453,20 +497,27 @@ class GithubConnection(BaseConnection):
seen = []
statuses = []
for status in self.getCommitStatuses(project.name, sha):
# creator can be None if the user has been removed.
creator = status.get('creator')
if not creator:
continue
user = creator.get('login')
context = status.get('context')
state = status.get('state')
if "%s:%s" % (user, context) not in seen:
statuses.append("%s:%s:%s" % (user, context, state))
seen.append("%s:%s" % (user, context))
stuple = _status_as_tuple(status)
if "%s:%s" % (stuple[0], stuple[1]) not in seen:
statuses.append("%s:%s:%s" % stuple)
seen.append("%s:%s" % (stuple[0], stuple[1]))
return statuses
def _status_as_tuple(status):
"""Translate a status into a tuple of user, context, state"""
creator = status.get('creator')
if not creator:
user = "Unknown"
else:
user = creator.get('login')
context = status.get('context')
state = status.get('state')
return (user, context, state)
def log_rate_limit(log, github):
try:
rate_limit = github.rate_limit()

View File

@ -58,7 +58,7 @@ class GithubTriggerEvent(TriggerEvent):
class GithubEventFilter(EventFilter):
def __init__(self, trigger, types=[], branches=[], refs=[],
comments=[], actions=[], labels=[], unlabels=[],
states=[], ignore_deletes=True):
states=[], statuses=[], ignore_deletes=True):
EventFilter.__init__(self, trigger)
@ -74,6 +74,7 @@ class GithubEventFilter(EventFilter):
self.labels = labels
self.unlabels = unlabels
self.states = states
self.statuses = statuses
self.ignore_deletes = ignore_deletes
def __repr__(self):
@ -97,6 +98,8 @@ class GithubEventFilter(EventFilter):
ret += ' unlabels: %s' % ', '.join(self.unlabels)
if self.states:
ret += ' states: %s' % ', '.join(self.states)
if self.statuses:
ret += ' statuses: %s' % ', '.join(self.statuses)
ret += '>'
return ret
@ -160,6 +163,10 @@ class GithubEventFilter(EventFilter):
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
return True

View File

@ -41,7 +41,8 @@ class GithubTrigger(BaseTrigger):
comments=toList(trigger.get('comment')),
labels=toList(trigger.get('label')),
unlabels=toList(trigger.get('unlabel')),
states=toList(trigger.get('state'))
states=toList(trigger.get('state')),
statuses=toList(trigger.get('status'))
)
efilters.append(f)
@ -67,6 +68,7 @@ def getSchema():
'label': toList(str),
'unlabel': toList(str),
'state': toList(str),
'status': toList(str)
}
return github_trigger