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) *push* - head reference updated (pushed to branch)
*status* - status set on commit
A ``pull_request_review`` event will A ``pull_request_review`` event will
have associated action(s) to trigger from. The supported actions are: 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 strings each of which is matched to the review state, which can be one of
``approved``, ``comment``, or ``request_changes``. ``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** **ref**
This is only used for ``push`` events. This field is treated as a regular 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 expression and multiple refs may be listed. Github always sends full ref

View File

@ -758,10 +758,9 @@ class FakeGithubPullRequest(object):
repo = self._getRepo() repo = self._getRepo()
return repo.references[self._getPRReference()].commit.hexsha 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 # Since we're bypassing github API, which would require a user, we
# hard set the user as 'zuul' here. # hard set the user as 'zuul' here.
user = 'zuul'
# insert the status at the top of the list, to simulate that it # insert the status at the top of the list, to simulate that it
# is the most recent set status # is the most recent set status
self.statuses[sha].insert(0, ({ self.statuses[sha].insert(0, ({
@ -805,6 +804,21 @@ class FakeGithubPullRequest(object):
} }
return (name, data) 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): class FakeGithubConnection(githubconnection.GithubConnection):
log = logging.getLogger("zuul.test.FakeGithubConnection") log = logging.getLogger("zuul.test.FakeGithubConnection")
@ -878,6 +892,13 @@ class FakeGithubConnection(githubconnection.GithubConnection):
} }
return data 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): def getPullFileNames(self, project, number):
pr = self.pull_requests[number - 1] pr = self.pull_requests[number - 1]
return pr.files return pr.files

View File

@ -13,11 +13,34 @@
github: github:
comment: true 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: - job:
name: project1-pipeline name: project1-pipeline
- job:
name: project2-trigger
- project: - project:
name: org/project1 name: org/project1
pipeline: pipeline:
jobs: jobs:
- project1-pipeline - project1-pipeline
- project:
name: org/project2
trigger:
jobs:
- project2-trigger

View File

@ -43,3 +43,39 @@ class TestGithubRequirements(ZuulTestCase):
self.waitUntilSettled() self.waitUntilSettled()
self.assertEqual(len(self.history), 1) self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, 'project1-pipeline') 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') event.action = body.get('action')
return event 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): def _issue_to_pull_request(self, body):
number = body.get('issue').get('number') number = body.get('issue').get('number')
project_name = body.get('repository').get('full_name') project_name = body.get('repository').get('full_name')
@ -377,6 +397,30 @@ class GithubConnection(BaseConnection):
# For now, just send back a True value. # For now, just send back a True value.
return True 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): def getPullFileNames(self, project, number):
owner, proj = project.name.split('/') owner, proj = project.name.split('/')
filenames = [f.filename for f in filenames = [f.filename for f in
@ -453,20 +497,27 @@ class GithubConnection(BaseConnection):
seen = [] seen = []
statuses = [] statuses = []
for status in self.getCommitStatuses(project.name, sha): for status in self.getCommitStatuses(project.name, sha):
# creator can be None if the user has been removed. stuple = _status_as_tuple(status)
creator = status.get('creator') if "%s:%s" % (stuple[0], stuple[1]) not in seen:
if not creator: statuses.append("%s:%s:%s" % stuple)
continue seen.append("%s:%s" % (stuple[0], stuple[1]))
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))
return statuses 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): def log_rate_limit(log, github):
try: try:
rate_limit = github.rate_limit() rate_limit = github.rate_limit()

View File

@ -58,7 +58,7 @@ class GithubTriggerEvent(TriggerEvent):
class GithubEventFilter(EventFilter): class GithubEventFilter(EventFilter):
def __init__(self, trigger, types=[], branches=[], refs=[], def __init__(self, trigger, types=[], branches=[], refs=[],
comments=[], actions=[], labels=[], unlabels=[], comments=[], actions=[], labels=[], unlabels=[],
states=[], ignore_deletes=True): states=[], statuses=[], ignore_deletes=True):
EventFilter.__init__(self, trigger) EventFilter.__init__(self, trigger)
@ -74,6 +74,7 @@ class GithubEventFilter(EventFilter):
self.labels = labels self.labels = labels
self.unlabels = unlabels self.unlabels = unlabels
self.states = states self.states = states
self.statuses = statuses
self.ignore_deletes = ignore_deletes self.ignore_deletes = ignore_deletes
def __repr__(self): def __repr__(self):
@ -97,6 +98,8 @@ class GithubEventFilter(EventFilter):
ret += ' unlabels: %s' % ', '.join(self.unlabels) ret += ' unlabels: %s' % ', '.join(self.unlabels)
if self.states: if self.states:
ret += ' states: %s' % ', '.join(self.states) ret += ' states: %s' % ', '.join(self.states)
if self.statuses:
ret += ' statuses: %s' % ', '.join(self.statuses)
ret += '>' ret += '>'
return ret return ret
@ -160,6 +163,10 @@ class GithubEventFilter(EventFilter):
if self.states and event.state not in self.states: if self.states and event.state not in self.states:
return False return False
# statuses are ORed
if self.statuses and event.status not in self.statuses:
return False
return True return True

View File

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