diff options
author | Adam Gandelman <adamg@ubuntu.com> | 2017-01-23 16:31:06 -0800 |
---|---|---|
committer | Jesse Keating <omgjlk@us.ibm.com> | 2017-05-23 21:47:45 -0700 |
commit | 8c6eeb5e8bf8201a3b3236613b54d67139242cf9 (patch) | |
tree | 37b836040b225460525ec40285428e39b7ce0d91 | |
parent | d96e5887b8b2de3ab000036c5aa355d53ac86de9 (diff) | |
download | zuul-8c6eeb5e8bf8201a3b3236613b54d67139242cf9.tar.gz |
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>
-rw-r--r-- | doc/source/triggers.rst | 8 | ||||
-rwxr-xr-x | tests/base.py | 25 | ||||
-rw-r--r-- | tests/fixtures/layouts/requirements-github.yaml | 23 | ||||
-rw-r--r-- | tests/unit/test_github_requirements.py | 36 | ||||
-rw-r--r-- | zuul/driver/github/githubconnection.py | 71 | ||||
-rw-r--r-- | zuul/driver/github/githubmodel.py | 9 | ||||
-rw-r--r-- | zuul/driver/github/githubtrigger.py | 4 |
7 files changed, 162 insertions, 14 deletions
diff --git a/doc/source/triggers.rst b/doc/source/triggers.rst index f73ad2f90..41a56a0fd 100644 --- a/doc/source/triggers.rst +++ b/doc/source/triggers.rst @@ -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 diff --git a/tests/base.py b/tests/base.py index b1ef3c9d8..1d3669405 100755 --- a/tests/base.py +++ b/tests/base.py @@ -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 diff --git a/tests/fixtures/layouts/requirements-github.yaml b/tests/fixtures/layouts/requirements-github.yaml index cacc54f29..6bbf0c829 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 bb9993e71..a3831ff95 100644 --- a/tests/unit/test_github_requirements.py +++ b/tests/unit/test_github_requirements.py @@ -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) diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py index 251356957..7eff7bbb0 100644 --- a/zuul/driver/github/githubconnection.py +++ b/zuul/driver/github/githubconnection.py @@ -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() diff --git a/zuul/driver/github/githubmodel.py b/zuul/driver/github/githubmodel.py index 22f549fa9..98b5ee0b4 100644 --- a/zuul/driver/github/githubmodel.py +++ b/zuul/driver/github/githubmodel.py @@ -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 diff --git a/zuul/driver/github/githubtrigger.py b/zuul/driver/github/githubtrigger.py index f0bd2f42e..3269c3658 100644 --- a/zuul/driver/github/githubtrigger.py +++ b/zuul/driver/github/githubtrigger.py @@ -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 |