Add trigger capability on github pr review

A github pull request review is a new type of event that happens when
user provides a review of a pull request
https://developer.github.com/v3/pulls/reviews

It will create an event
https://developer.github.com/v3/activity/events/types/#pullrequestreviewevent
sent to the webhook. Allow filtering on submitted or dismissed actions
and the state of the review of approved, comment, or request_changes.

Signed-off-by: Jesse Keating <omgjlk@us.ibm.com>
Change-Id: If94c2fe8adbe18fa7f426d5462559ba24963424c
diff --git a/doc/source/triggers.rst b/doc/source/triggers.rst
index 07b18ab..f73ad2f 100644
--- a/doc/source/triggers.rst
+++ b/doc/source/triggers.rst
@@ -109,7 +109,10 @@
 following options.
 
   **event**
-  The pull request event from github. A ``pull_request`` event will
+  The event from github. Supported events are ``pull_request``,
+  ``pull_request_review``,  and ``push``.
+
+  A ``pull_request`` event will
   have associated action(s) to trigger from. The supported actions are:
 
     *opened* - pull request opened
@@ -126,32 +129,46 @@
 
     *unlabeled* - label removed from pull request
 
+    *review* - review added on pull request
+
     *push* - head reference updated (pushed to branch)
 
+  A ``pull_request_review`` event will
+  have associated action(s) to trigger from. The supported actions are:
+
+    *submitted* - pull request review added
+
+    *dismissed* - pull request review removed
+
   **branch**
   The branch associated with the event. Example: ``master``.  This
   field is treated as a regular expression, and multiple branches may
-  be listed. Used for ``pull-request`` events.
+  be listed. Used for ``pull_request`` and ``pull_request_review`` events.
 
   **comment**
-  This is only used for ``pull_request`` ``comment`` events.  It accepts a list
-  of regexes that are searched for in the comment string. If any of these
+  This is only used for ``pull_request`` ``comment`` actions.  It accepts a
+  list of regexes that are searched for in the comment string. If any of these
   regexes matches a portion of the comment string the trigger is matched.
   ``comment: retrigger`` will match when comments containing 'retrigger'
   somewhere in the comment text are added to a pull request.
 
   **label**
-  This is only used for ``labeled`` and ``unlabeled`` actions. It accepts a list
-  of strings each of which matches the label name in the event literally.
-  ``label: recheck`` will match a ``labeled`` action when pull request is
-  labeled with a ``recheck`` label. ``label: 'do not test'`` will match a
-  ``unlabeled`` action when a label with name ``do not test`` is removed from
-  the pull request.
+  This is only used for ``labeled`` and ``unlabeled`` ``pull_request`` actions.
+  It accepts a list of strings each of which matches the label name in the
+  event literally.  ``label: recheck`` will match a ``labeled`` action when
+  pull request is labeled with a ``recheck`` label. ``label: 'do not test'``
+  will match a ``unlabeled`` action when a label with name ``do not test`` is
+  removed from the pull request.
 
-  Additionally a ``push`` event can be configured, with an ``ref`` field. This
-  field is treated as a regular expression and multiple refs may be listed.
-  Github always sends full ref name, eg. ``refs/tags/bar`` and this string is
-  matched against the regexp.
+  **state**
+  This is only used for ``pull_request_review`` events.  It accepts a list of
+  strings each of which is matched to the review state, which can be one of
+  ``approved``, ``comment``, or ``request_changes``.
+
+  **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
+  name, eg. ``refs/tags/bar`` and this string is matched against the regexp.
 
 GitHub Configuration
 ~~~~~~~~~~~~~~~~~~~~
diff --git a/tests/base.py b/tests/base.py
index 937d60f..2fbdb88 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -617,6 +617,36 @@
         }
         return (name, data)
 
+    def getReviewAddedEvent(self, review):
+        name = 'pull_request_review'
+        data = {
+            'action': 'submitted',
+            'pull_request': {
+                'number': self.number,
+                'title': self.subject,
+                'updated_at': self.updated_at,
+                'base': {
+                    'ref': self.branch,
+                    'repo': {
+                        'full_name': self.project
+                    }
+                },
+                'head': {
+                    'sha': self.head_sha
+                }
+            },
+            'review': {
+                'state': review
+            },
+            'repository': {
+                'full_name': self.project
+            },
+            'sender': {
+                'login': 'ghuser'
+            }
+        }
+        return (name, data)
+
     def addLabel(self, name):
         if name not in self.labels:
             self.labels.append(name)
diff --git a/tests/fixtures/layouts/reviews-github.yaml b/tests/fixtures/layouts/reviews-github.yaml
new file mode 100644
index 0000000..1cc887a
--- /dev/null
+++ b/tests/fixtures/layouts/reviews-github.yaml
@@ -0,0 +1,21 @@
+- pipeline:
+    name: reviews
+    manager: independent
+    trigger:
+      github:
+        - event: pull_request_review
+          action: submitted
+          state: 'approve'
+    success:
+      github:
+        label:
+          - 'tests passed'
+
+- job:
+    name: project-reviews
+
+- project:
+    name: org/project
+    reviews:
+      jobs:
+        - project-reviews
diff --git a/tests/unit/test_github_driver.py b/tests/unit/test_github_driver.py
index a1a05e0..f2a6e5b 100644
--- a/tests/unit/test_github_driver.py
+++ b/tests/unit/test_github_driver.py
@@ -177,6 +177,21 @@
         self.assertEqual(2, len(self.history))
         self.assertEqual(['other label'], C.labels)
 
+    @simple_layout('layouts/reviews-github.yaml', driver='github')
+    def test_review_event(self):
+        A = self.fake_github.openFakePullRequest('org/project', 'master', 'A')
+        self.fake_github.emitEvent(A.getReviewAddedEvent('approve'))
+        self.waitUntilSettled()
+        self.assertEqual(1, len(self.history))
+        self.assertEqual('project-reviews', self.history[0].name)
+        self.assertEqual(['tests passed'], A.labels)
+
+        # test_review_unmatched_event
+        B = self.fake_github.openFakePullRequest('org/project', 'master', 'B')
+        self.fake_github.emitEvent(B.getReviewAddedEvent('comment'))
+        self.waitUntilSettled()
+        self.assertEqual(1, len(self.history))
+
     @simple_layout('layouts/dequeue-github.yaml', driver='github')
     def test_dequeue_pull_synchronized(self):
         self.executor_server.hold_jobs_in_build = True
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index d715222..b7fb05d 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -143,6 +143,24 @@
         event.action = 'comment'
         return event
 
+    def _event_pull_request_review(self, request):
+        """Handles pull request reviews"""
+        body = request.json_body
+        pr_body = body.get('pull_request')
+        if pr_body is None:
+            return
+
+        review = body.get('review')
+        if review is None:
+            return
+
+        event = self._pull_request_to_event(pr_body)
+        event.state = review.get('state')
+        event.account = self._get_sender(body)
+        event.type = 'pull_request_review'
+        event.action = body.get('action')
+        return event
+
     def _issue_to_pull_request(self, body):
         number = body.get('issue').get('number')
         project_name = body.get('repository').get('full_name')
diff --git a/zuul/driver/github/githubtrigger.py b/zuul/driver/github/githubtrigger.py
index 541c783..b9c1026 100644
--- a/zuul/driver/github/githubtrigger.py
+++ b/zuul/driver/github/githubtrigger.py
@@ -40,7 +40,8 @@
                 refs=toList(trigger.get('ref')),
                 comments=toList(trigger.get('comment')),
                 labels=toList(trigger.get('label')),
-                unlabels=toList(trigger.get('unlabel'))
+                unlabels=toList(trigger.get('unlabel')),
+                states=toList(trigger.get('state'))
             )
             efilters.append(f)
 
@@ -57,6 +58,7 @@
     github_trigger = {
         v.Required('event'):
             toList(v.Any('pull_request',
+                         'pull_request_review',
                          'push')),
         'action': toList(str),
         'branch': toList(str),
@@ -64,6 +66,7 @@
         'comment': toList(str),
         'label': toList(str),
         'unlabel': toList(str),
+        'state': toList(str),
     }
 
     return github_trigger
diff --git a/zuul/model.py b/zuul/model.py
index db49e4e..fdccc26 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -1899,6 +1899,7 @@
         self.comment = None
         self.label = None
         self.unlabel = None
+        self.state = None
         # ref-updated
         self.ref = None
         self.oldrev = None
@@ -2050,7 +2051,7 @@
     def __init__(self, trigger, types=[], branches=[], refs=[],
                  event_approvals={}, comments=[], emails=[], usernames=[],
                  timespecs=[], required_approvals=[], reject_approvals=[],
-                 pipelines=[], actions=[], labels=[], unlabels=[],
+                 pipelines=[], actions=[], labels=[], unlabels=[], states=[],
                  ignore_deletes=True):
         super(EventFilter, self).__init__(
             required_approvals=required_approvals,
@@ -2075,6 +2076,7 @@
         self.timespecs = timespecs
         self.labels = labels
         self.unlabels = unlabels
+        self.states = states
         self.ignore_deletes = ignore_deletes
 
     def __repr__(self):
@@ -2113,6 +2115,8 @@
             ret += ' labels: %s' % ', '.join(self.labels)
         if self.unlabels:
             ret += ' unlabels: %s' % ', '.join(self.unlabels)
+        if self.states:
+            ret += ' states: %s' % ', '.join(self.states)
         ret += '>'
 
         return ret
@@ -2225,6 +2229,10 @@
         if self.unlabels and event.unlabel not in self.unlabels:
             return False
 
+        # states are ORed
+        if self.states and event.state not in self.states:
+            return False
+
         return True