Support for github commit status

Github reporter sets commit status of pull request based on the result
of the pipeline.

Change-Id: Id95bf0dbaa710c555e3a1838d3430e18ac9501aa
Co-Authored-By: Jesse Keating <omgjlk@us.ibm.com>
diff --git a/doc/source/reporters.rst b/doc/source/reporters.rst
index 4972e66..cf4f6e4 100644
--- a/doc/source/reporters.rst
+++ b/doc/source/reporters.rst
@@ -32,7 +32,23 @@
 ------
 
 Zuul reports back to GitHub pull requests via GitHub API.
-It will create a comment containing the job status.
+On success and failure, it creates a comment containing the build results.
+It also sets the status on start, success and failure. Status name and
+description is taken from the pipeline.
+
+A :ref:`connection` that uses the github driver must be supplied to the
+reporter. It has the following options:
+
+  **status**
+  String value (``pending``, ``success``, ``failure``) that the reporter should
+  set as the commit status on github.
+  ``status: 'success'``
+
+  **comment**
+  Boolean value (``true`` or ``false``) that determines if the reporter should
+  add a comment to the pipeline status to the github pull request. Defaults
+  to ``true``.
+  ``comment: false``
 
 SMTP
 ----
diff --git a/tests/base.py b/tests/base.py
index 56b1269..0ad1ec1 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -556,6 +556,7 @@
         self.branch = branch
         self.upstream_root = upstream_root
         self.comments = []
+        self.statuses = {}
         self.updated_at = None
         self.head_sha = None
         self._createPRRef()
@@ -566,11 +567,13 @@
         """Adds a commit on top of the actual PR head."""
         self._addCommitToRepo()
         self._updateTimeStamp()
+        self._clearStatuses()
 
     def forcePush(self):
         """Clears actual commits and add a commit on top of the base."""
         self._addCommitToRepo(reset=True)
         self._updateTimeStamp()
+        self._clearStatuses()
 
     def getPullRequestOpenedEvent(self):
         return self._getPullRequestEvent('opened')
@@ -644,6 +647,16 @@
         repo = self._getRepo()
         return repo.references[self._getPRReference()].commit.hexsha
 
+    def setStatus(self, state, url, description, context):
+        self.statuses[context] = {
+            'state': state,
+            'url': url,
+            'description': description
+        }
+
+    def _clearStatuses(self):
+        self.statuses = {}
+
     def _getPRReference(self):
         return '%s/head' % self.number
 
@@ -745,10 +758,19 @@
         # just returns master for now
         return ['master']
 
-    def report(self, project, pr_number, message, params=None):
+    def commentPull(self, project, pr_number, message):
         pull_request = self.pull_requests[pr_number - 1]
         pull_request.addComment(message)
 
+    def setCommitStatus(self, project, sha, state,
+                        url='', description='', context=''):
+        owner, proj = project.split('/')
+        for pr in self.pull_requests:
+            pr_owner, pr_project = pr.project.split('/')
+            if (pr_owner == owner and pr_project == proj and
+                pr.head_sha == sha):
+                pr.setStatus(state, url, description, context)
+
 
 class BuildHistory(object):
     def __init__(self, **kw):
diff --git a/tests/fixtures/layouts/reporting-github.yaml b/tests/fixtures/layouts/reporting-github.yaml
new file mode 100644
index 0000000..bcbac1b
--- /dev/null
+++ b/tests/fixtures/layouts/reporting-github.yaml
@@ -0,0 +1,45 @@
+- pipeline:
+    name: check
+    description: Standard check
+    manager: independent
+    trigger:
+      github:
+        - event: pull_request
+          action: opened
+    start:
+      github:
+        status: 'pending'
+        comment: false
+    success:
+      github:
+        status: 'success'
+
+- pipeline:
+    name: reporting
+    description: Uncommon reporting
+    manager: independent
+    trigger:
+      github:
+        - event: pull_request
+          action: comment
+          comment: 'reporting check'
+    start:
+      github: {}
+    success:
+      github:
+        comment: false
+    failure:
+      github:
+        comment: false
+
+- job:
+    name: project-test1
+
+- project:
+    name: org/project
+    check:
+      jobs:
+        - project-test1
+    reporting:
+      jobs:
+        - project-test1
diff --git a/tests/fixtures/zuul-github-driver.conf b/tests/fixtures/zuul-github-driver.conf
index b4a85f7..58c7cd5 100644
--- a/tests/fixtures/zuul-github-driver.conf
+++ b/tests/fixtures/zuul-github-driver.conf
@@ -3,6 +3,7 @@
 
 [zuul]
 job_name_in_report=true
+status_url=http://zuul.example.com/status
 
 [merger]
 git_dir=/tmp/zuul-test/git
diff --git a/tests/unit/test_github_driver.py b/tests/unit/test_github_driver.py
index 1e5f6a6..9017ce9 100644
--- a/tests/unit/test_github_driver.py
+++ b/tests/unit/test_github_driver.py
@@ -13,6 +13,8 @@
 # under the License.
 
 import logging
+import re
+from testtools.matchers import MatchesRegex
 
 from tests.base import ZuulTestCase, simple_layout, random_sha1
 
@@ -122,3 +124,44 @@
         """Test that git_ssh option gives git url with ssh"""
         url = self.fake_github_ssh.real_getGitUrl('org/project')
         self.assertEqual('ssh://git@github.com/org/project.git', url)
+
+    @simple_layout('layouts/reporting-github.yaml', driver='github')
+    def test_reporting(self):
+        # pipeline reports pull status both on start and success
+        self.executor_server.hold_jobs_in_build = True
+        pr = self.fake_github.openFakePullRequest('org/project', 'master')
+        self.fake_github.emitEvent(pr.getPullRequestOpenedEvent())
+        self.waitUntilSettled()
+        self.assertIn('check', pr.statuses)
+        check_status = pr.statuses['check']
+        self.assertEqual('Standard check', check_status['description'])
+        self.assertEqual('pending', check_status['state'])
+        self.assertEqual('http://zuul.example.com/status', check_status['url'])
+        self.assertEqual(0, len(pr.comments))
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
+        check_status = pr.statuses['check']
+        self.assertEqual('Standard check', check_status['description'])
+        self.assertEqual('success', check_status['state'])
+        self.assertEqual('http://zuul.example.com/status', check_status['url'])
+        self.assertEqual(1, len(pr.comments))
+        self.assertThat(pr.comments[0],
+                        MatchesRegex('.*Build succeeded.*', re.DOTALL))
+
+        # pipeline does not report any status but does comment
+        self.executor_server.hold_jobs_in_build = True
+        self.fake_github.emitEvent(
+            pr.getCommentAddedEvent('reporting check'))
+        self.waitUntilSettled()
+        self.assertNotIn('reporting', pr.statuses)
+        # comments increased by one for the start message
+        self.assertEqual(2, len(pr.comments))
+        self.assertThat(pr.comments[1],
+                        MatchesRegex('.*Starting reporting jobs.*', re.DOTALL))
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
+        self.assertNotIn('reporting', pr.statuses)
+        self.assertEqual(2, len(pr.comments))
diff --git a/zuul/driver/github/__init__.py b/zuul/driver/github/__init__.py
index 4e54f11..2d6829d 100644
--- a/zuul/driver/github/__init__.py
+++ b/zuul/driver/github/__init__.py
@@ -34,7 +34,7 @@
         return githubsource.GithubSource(self, connection)
 
     def getReporter(self, connection, config=None):
-        return githubreporter.GithubReporter(self, connection)
+        return githubreporter.GithubReporter(self, connection, config)
 
     def getTriggerSchema(self):
         return githubtrigger.getSchema()
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index 7085bf6..6604d81 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -277,12 +277,18 @@
         owner, proj = project_name.split('/')
         return self.github.pull_request(owner, proj, number).as_dict()
 
-    def report(self, project, pr_number, message):
-        owner, proj = project.name.split('/')
+    def commentPull(self, project, pr_number, message):
+        owner, proj = project.split('/')
         repository = self.github.repository(owner, proj)
         pull_request = repository.issue(pr_number)
         pull_request.create_comment(message)
 
+    def setCommitStatus(self, project, sha, state, url='', description='',
+                        context=''):
+        owner, proj = project.split('/')
+        repository = self.github.repository(owner, proj)
+        repository.create_status(sha, state, url, description, context)
+
     def _ghTimestampToDate(self, timestamp):
         return time.strptime(timestamp, '%Y-%m-%dT%H:%M:%SZ')
 
diff --git a/zuul/driver/github/githubreporter.py b/zuul/driver/github/githubreporter.py
index 685c60e..ecbb486 100644
--- a/zuul/driver/github/githubreporter.py
+++ b/zuul/driver/github/githubreporter.py
@@ -24,15 +24,54 @@
     name = 'github'
     log = logging.getLogger("zuul.GithubReporter")
 
+    def __init__(self, driver, connection, config=None):
+        super(GithubReporter, self).__init__(driver, connection, config)
+        self._commit_status = self.config.get('status', None)
+        self._create_comment = self.config.get('comment', True)
+
     def report(self, source, pipeline, item):
-        """Comment on PR with test status."""
+        """Comment on PR and set commit status."""
+        if self._create_comment:
+            self.addPullComment(pipeline, item)
+        if (self._commit_status is not None and
+            hasattr(item.change, 'patchset') and
+            item.change.patchset is not None):
+            self.setPullStatus(pipeline, item)
+
+    def addPullComment(self, pipeline, item):
         message = self._formatItemReport(pipeline, item)
         project = item.change.project.name
         pr_number = item.change.number
+        self.log.debug(
+            'Reporting change %s, params %s, message: %s' %
+            (item.change, self.config, message))
+        self.connection.commentPull(project, pr_number, message)
 
-        self.connection.report(project, pr_number, message)
+    def setPullStatus(self, pipeline, item):
+        project = item.change.project.name
+        sha = item.change.patchset
+        context = pipeline.name
+        state = self._commit_status
+        url = ''
+        if self.connection.sched.config.has_option('zuul', 'status_url'):
+            url = self.connection.sched.config.get('zuul', 'status_url')
+        description = ''
+        if pipeline.description:
+            description = pipeline.description
+
+        self.log.debug(
+            'Reporting change %s, params %s, status:\n'
+            'context: %s, state: %s, description: %s, url: %s' %
+            (item.change, self.config, context, state,
+             description, url))
+
+        self.connection.setCommitStatus(
+            project, sha, state, url, description, context)
 
 
 def getSchema():
-    github_reporter = v.Any(str, v.Schema({}, extra=True))
+    github_reporter = v.Schema({
+        'status': v.Any('pending', 'success', 'failure'),
+        'comment': bool
+    })
     return github_reporter