Merge pull requests from github reporter

Github reporter can be configured to merge pull reqeusts.

When there are multiple merges called at the same time, it leads to a
situation when github returns 405 MethodNotAllowed error becuase github
is checking the branch mergeability.
When we encounter this situation, we try to wait a bit (2 seconds for
now) and try to merge again.

Pre-release version of Github3.py has to be used, because the latest
released version 9.4 has a bug in merge method. Furthermore the newest
merge method supports specifying exact sha to be merged, which is
desirable to ensure that the exact commit that went through the pipeline
gets merged.

Both are already fixed in the stable branch, but not yet released on
PyPi. See:
https://github.com/sigmavirus24/github3.py/commit/90c6b7c2653d65ce686cf4346f9aea9cb9c5c836
https://github.com/sigmavirus24/github3.py/commit/6ef02cb33ff21257eeaf9cab186419ca45ef5806

Change-Id: I0c3abbcce476774a5ba8981c171382eaa4fe0abf
diff --git a/doc/source/reporters.rst b/doc/source/reporters.rst
index cf4f6e4..ced3b78 100644
--- a/doc/source/reporters.rst
+++ b/doc/source/reporters.rst
@@ -50,6 +50,11 @@
   to ``true``.
   ``comment: false``
 
+  **merge**
+  Boolean value (``true`` or ``false``) that determines if the reporter should
+  merge the pull reqeust. Defaults to ``false``.
+  ``merge=true``
+
 SMTP
 ----
 
diff --git a/requirements.txt b/requirements.txt
index 44cef95..2fe6963 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,6 @@
 pbr>=1.1.0
 
-Github3.py
+Github3.py==1.0.0a2
 PyYAML>=3.1.0
 Paste
 WebOb>=1.2.3
diff --git a/tests/base.py b/tests/base.py
index 0ad1ec1..d7bf467 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -70,6 +70,7 @@
 import zuul.merger.server
 import zuul.nodepool
 import zuul.zk
+from zuul.exceptions import MergeFailure
 
 FIXTURE_DIR = os.path.join(os.path.dirname(__file__),
                            'fixtures')
@@ -559,6 +560,7 @@
         self.statuses = {}
         self.updated_at = None
         self.head_sha = None
+        self.is_merged = False
         self._createPRRef()
         self._addCommitToRepo()
         self._updateTimeStamp()
@@ -693,6 +695,8 @@
         self.pr_number = 0
         self.pull_requests = []
         self.upstream_root = upstream_root
+        self.merge_failure = False
+        self.merge_not_allowed_count = 0
 
     def openFakePullRequest(self, project, branch):
         self.pr_number += 1
@@ -762,6 +766,16 @@
         pull_request = self.pull_requests[pr_number - 1]
         pull_request.addComment(message)
 
+    def mergePull(self, project, pr_number, sha=None):
+        pull_request = self.pull_requests[pr_number - 1]
+        if self.merge_failure:
+            raise Exception('Pull request was not merged')
+        if self.merge_not_allowed_count > 0:
+            self.merge_not_allowed_count -= 1
+            raise MergeFailure('Merge was not successful due to mergeability'
+                               ' conflict')
+        pull_request.is_merged = True
+
     def setCommitStatus(self, project, sha, state,
                         url='', description='', context=''):
         owner, proj = project.split('/')
diff --git a/tests/fixtures/layouts/merging-github.yaml b/tests/fixtures/layouts/merging-github.yaml
new file mode 100644
index 0000000..4e13063
--- /dev/null
+++ b/tests/fixtures/layouts/merging-github.yaml
@@ -0,0 +1,19 @@
+- pipeline:
+    name: merge
+    description: Pipeline for merging the pull request
+    manager: independent
+    trigger:
+      github:
+        - event: pull_request
+          action: comment
+          comment: 'merge me'
+    success:
+      github:
+        merge: true
+        comment: false
+
+- project:
+    name: org/project
+    merge:
+      jobs:
+        - noop
diff --git a/tests/unit/test_github_driver.py b/tests/unit/test_github_driver.py
index 9017ce9..409d966 100644
--- a/tests/unit/test_github_driver.py
+++ b/tests/unit/test_github_driver.py
@@ -165,3 +165,35 @@
         self.waitUntilSettled()
         self.assertNotIn('reporting', pr.statuses)
         self.assertEqual(2, len(pr.comments))
+
+    @simple_layout('layouts/merging-github.yaml', driver='github')
+    def test_report_pull_merge(self):
+        # pipeline merges the pull request on success
+        A = self.fake_github.openFakePullRequest('org/project', 'master')
+        self.fake_github.emitEvent(A.getCommentAddedEvent('merge me'))
+        self.waitUntilSettled()
+        self.assertTrue(A.is_merged)
+
+        # pipeline merges the pull request on success after failure
+        self.fake_github.merge_failure = True
+        B = self.fake_github.openFakePullRequest('org/project', 'master')
+        self.fake_github.emitEvent(B.getCommentAddedEvent('merge me'))
+        self.waitUntilSettled()
+        self.assertFalse(B.is_merged)
+        self.fake_github.merge_failure = False
+
+        # pipeline merges the pull request on second run of merge
+        # first merge failed on 405 Method Not Allowed error
+        self.fake_github.merge_not_allowed_count = 1
+        C = self.fake_github.openFakePullRequest('org/project', 'master')
+        self.fake_github.emitEvent(C.getCommentAddedEvent('merge me'))
+        self.waitUntilSettled()
+        self.assertTrue(C.is_merged)
+
+        # pipeline does not merge the pull request
+        # merge failed on 405 Method Not Allowed error - twice
+        self.fake_github.merge_not_allowed_count = 2
+        D = self.fake_github.openFakePullRequest('org/project', 'master')
+        self.fake_github.emitEvent(D.getCommentAddedEvent('merge me'))
+        self.waitUntilSettled()
+        self.assertFalse(D.is_merged)
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index 6604d81..3c1faff 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -21,9 +21,11 @@
 import webob.dec
 import voluptuous as v
 import github3
+from github3.exceptions import MethodNotAllowed
 
 from zuul.connection import BaseConnection
 from zuul.model import PullRequest, Ref, TriggerEvent
+from zuul.exceptions import MergeFailure
 
 
 class GithubWebhookListener():
@@ -283,6 +285,17 @@
         pull_request = repository.issue(pr_number)
         pull_request.create_comment(message)
 
+    def mergePull(self, project, pr_number, sha=None):
+        owner, proj = project.split('/')
+        pull_request = self.github.pull_request(owner, proj, pr_number)
+        try:
+            result = pull_request.merge(sha=sha)
+        except MethodNotAllowed as e:
+            raise MergeFailure('Merge was not successful due to mergeability'
+                               ' conflict, original error is %s' % e)
+        if not result:
+            raise Exception('Pull request was not merged')
+
     def setCommitStatus(self, project, sha, state, url='', description='',
                         context=''):
         owner, proj = project.split('/')
diff --git a/zuul/driver/github/githubreporter.py b/zuul/driver/github/githubreporter.py
index ecbb486..80ab3c7 100644
--- a/zuul/driver/github/githubreporter.py
+++ b/zuul/driver/github/githubreporter.py
@@ -14,8 +14,10 @@
 
 import logging
 import voluptuous as v
+import time
 
 from zuul.reporter import BaseReporter
+from zuul.exceptions import MergeFailure
 
 
 class GithubReporter(BaseReporter):
@@ -28,6 +30,7 @@
         super(GithubReporter, self).__init__(driver, connection, config)
         self._commit_status = self.config.get('status', None)
         self._create_comment = self.config.get('comment', True)
+        self._merge = self.config.get('merge', False)
 
     def report(self, source, pipeline, item):
         """Comment on PR and set commit status."""
@@ -37,6 +40,9 @@
             hasattr(item.change, 'patchset') and
             item.change.patchset is not None):
             self.setPullStatus(pipeline, item)
+        if (self._merge and
+            hasattr(item.change, 'number')):
+            self.mergePull(item)
 
     def addPullComment(self, pipeline, item):
         message = self._formatItemReport(pipeline, item)
@@ -68,10 +74,25 @@
         self.connection.setCommitStatus(
             project, sha, state, url, description, context)
 
+    def mergePull(self, item):
+        project = item.change.project.name
+        pr_number = item.change.number
+        sha = item.change.patchset
+        self.log.debug('Reporting change %s, params %s, merging via API' %
+                       (item.change, self.config))
+        try:
+            self.connection.mergePull(project, pr_number, sha)
+        except MergeFailure:
+            time.sleep(2)
+            self.log.debug('Trying to merge change %s again...' % item.change)
+            self.connection.mergePull(project, pr_number, sha)
+        item.change.is_merged = True
+
 
 def getSchema():
     github_reporter = v.Schema({
         'status': v.Any('pending', 'success', 'failure'),
-        'comment': bool
+        'comment': bool,
+        'merge': bool
     })
     return github_reporter