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>
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 b1ef3c9..1d36694 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