Merge "Adds github triggering from status updates" into feature/zuulv3
diff --git a/doc/source/triggers.rst b/doc/source/triggers.rst
index f73ad2f..41a56a0 100644
--- a/doc/source/triggers.rst
+++ b/doc/source/triggers.rst
@@ -133,6 +133,8 @@
*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 @@
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
diff --git a/tests/base.py b/tests/base.py
index 27479b0..3719eb6 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -758,10 +758,9 @@
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 @@
}
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 @@
}
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
diff --git a/tests/fixtures/layouts/requirements-github.yaml b/tests/fixtures/layouts/requirements-github.yaml
index cacc54f..6bbf0c8 100644
--- a/tests/fixtures/layouts/requirements-github.yaml
+++ b/tests/fixtures/layouts/requirements-github.yaml
@@ -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
diff --git a/tests/unit/test_github_requirements.py b/tests/unit/test_github_requirements.py
index bb9993e..a3831ff 100644
--- a/tests/unit/test_github_requirements.py
+++ b/tests/unit/test_github_requirements.py
@@ -43,3 +43,39 @@
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)
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index 2513569..7eff7bb 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -162,6 +162,26 @@
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 @@
# 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 @@
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()
diff --git a/zuul/driver/github/githubmodel.py b/zuul/driver/github/githubmodel.py
index 22f549f..98b5ee0 100644
--- a/zuul/driver/github/githubmodel.py
+++ b/zuul/driver/github/githubmodel.py
@@ -58,7 +58,7 @@
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 @@
self.labels = labels
self.unlabels = unlabels
self.states = states
+ self.statuses = statuses
self.ignore_deletes = ignore_deletes
def __repr__(self):
@@ -97,6 +98,8 @@
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 @@
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
diff --git a/zuul/driver/github/githubtrigger.py b/zuul/driver/github/githubtrigger.py
index f0bd2f4..3269c36 100644
--- a/zuul/driver/github/githubtrigger.py
+++ b/zuul/driver/github/githubtrigger.py
@@ -41,7 +41,8 @@
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 @@
'label': toList(str),
'unlabel': toList(str),
'state': toList(str),
+ 'status': toList(str)
}
return github_trigger