support github pull request labels

Allow to match trigger on adding or removing github labels. Also
reporter can add or remove labels.

Change-Id: Id385b92590e252c283ba3ebe1ecfd33b34469a2e
Co-Authored-By: Jesse Keating <omgjlk@us.ibm.com>
diff --git a/doc/source/reporters.rst b/doc/source/reporters.rst
index ced3b78..e3ab947 100644
--- a/doc/source/reporters.rst
+++ b/doc/source/reporters.rst
@@ -55,6 +55,16 @@
   merge the pull reqeust. Defaults to ``false``.
   ``merge=true``
 
+  **label**
+  List of strings each representing an exact label name which should be added
+  to the pull request by reporter.
+  ``label: 'test successful'``
+
+  **unlabel**
+  List of strings each representing an exact label name which should be removed
+  from the pull request by reporter.
+  ``unlabel: 'test failed'``
+
 SMTP
 ----
 
diff --git a/doc/source/triggers.rst b/doc/source/triggers.rst
index cd342c3..d8c7ee8 100644
--- a/doc/source/triggers.rst
+++ b/doc/source/triggers.rst
@@ -122,6 +122,12 @@
 
     *comment* - comment added on pull request
 
+    *labeled* - label added on pull request
+
+    *unlabeled* - label removed from pull request
+
+    *push* - head reference updated (pushed to branch)
+
   **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
@@ -129,6 +135,14 @@
   ``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.
+
   Additionally a ``push`` event can be configured, with an
   associated ``ref`` represented as a regex to match branches or tags.
 
diff --git a/tests/base.py b/tests/base.py
index d7bf467..382a593 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -557,6 +557,7 @@
         self.branch = branch
         self.upstream_root = upstream_root
         self.comments = []
+        self.labels = []
         self.statuses = {}
         self.updated_at = None
         self.head_sha = None
@@ -609,6 +610,64 @@
         }
         return (name, data)
 
+    def addLabel(self, name):
+        if name not in self.labels:
+            self.labels.append(name)
+            self._updateTimeStamp()
+            return self._getLabelEvent(name)
+
+    def removeLabel(self, name):
+        if name in self.labels:
+            self.labels.remove(name)
+            self._updateTimeStamp()
+            return self._getUnlabelEvent(name)
+
+    def _getLabelEvent(self, label):
+        name = 'pull_request'
+        data = {
+            'action': 'labeled',
+            'pull_request': {
+                'number': self.number,
+                'updated_at': self.updated_at,
+                'base': {
+                    'ref': self.branch,
+                    'repo': {
+                        'full_name': self.project
+                    }
+                },
+                'head': {
+                    'sha': self.head_sha
+                }
+            },
+            'label': {
+                'name': label
+            }
+        }
+        return (name, data)
+
+    def _getUnlabelEvent(self, label):
+        name = 'pull_request'
+        data = {
+            'action': 'unlabeled',
+            'pull_request': {
+                'number': self.number,
+                'updated_at': self.updated_at,
+                'base': {
+                    'ref': self.branch,
+                    'repo': {
+                        'full_name': self.project
+                    }
+                },
+                'head': {
+                    'sha': self.head_sha
+                }
+            },
+            'label': {
+                'name': label
+            }
+        }
+        return (name, data)
+
     def _getRepo(self):
         repo_path = os.path.join(self.upstream_root, self.project)
         return git.Repo(repo_path)
@@ -785,6 +844,14 @@
                 pr.head_sha == sha):
                 pr.setStatus(state, url, description, context)
 
+    def labelPull(self, project, pr_number, label):
+        pull_request = self.pull_requests[pr_number - 1]
+        pull_request.addLabel(label)
+
+    def unlabelPull(self, project, pr_number, label):
+        pull_request = self.pull_requests[pr_number - 1]
+        pull_request.removeLabel(label)
+
 
 class BuildHistory(object):
     def __init__(self, **kw):
diff --git a/tests/fixtures/layouts/labeling-github.yaml b/tests/fixtures/layouts/labeling-github.yaml
new file mode 100644
index 0000000..33ce993
--- /dev/null
+++ b/tests/fixtures/layouts/labeling-github.yaml
@@ -0,0 +1,29 @@
+- pipeline:
+    name: labels
+    description: Trigger on labels
+    manager: independent
+    trigger:
+      github:
+        - event: pull_request
+          action: labeled
+          label:
+            - 'test'
+        - event: pull_request
+          action: unlabeled
+          label:
+            - 'do not test'
+    success:
+      github:
+        label:
+          - 'tests passed'
+        unlabel:
+          - 'test'
+
+- job:
+    name: project-labels
+
+- project:
+    name: org/project
+    labels:
+      jobs:
+        - project-labels
diff --git a/tests/unit/test_github_driver.py b/tests/unit/test_github_driver.py
index 409d966..c7c5f3a 100644
--- a/tests/unit/test_github_driver.py
+++ b/tests/unit/test_github_driver.py
@@ -113,6 +113,31 @@
         self.assertEqual('SUCCESS',
                          self.getJobFromHistory('project-post').result)
 
+    @simple_layout('layouts/labeling-github.yaml', driver='github')
+    def test_labels(self):
+        A = self.fake_github.openFakePullRequest('org/project', 'master')
+        self.fake_github.emitEvent(A.addLabel('test'))
+        self.waitUntilSettled()
+        self.assertEqual(1, len(self.history))
+        self.assertEqual('project-labels', self.history[0].name)
+        self.assertEqual(['tests passed'], A.labels)
+
+        # test label removed
+        B = self.fake_github.openFakePullRequest('org/project', 'master')
+        B.addLabel('do not test')
+        self.fake_github.emitEvent(B.removeLabel('do not test'))
+        self.waitUntilSettled()
+        self.assertEqual(2, len(self.history))
+        self.assertEqual('project-labels', self.history[1].name)
+        self.assertEqual(['tests passed'], B.labels)
+
+        # test unmatched label
+        C = self.fake_github.openFakePullRequest('org/project', 'master')
+        self.fake_github.emitEvent(C.addLabel('other label'))
+        self.waitUntilSettled()
+        self.assertEqual(2, len(self.history))
+        self.assertEqual(['other label'], C.labels)
+
     @simple_layout('layouts/basic-github.yaml', driver='github')
     def test_git_https_url(self):
         """Test that git_ssh option gives git url with ssh"""
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index 3c1faff..c73b88e 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -110,6 +110,12 @@
             event.action = 'closed'
         elif action == 'reopened':
             event.action = 'reopened'
+        elif action == 'labeled':
+            event.action = 'labeled'
+            event.label = body['label']['name']
+        elif action == 'unlabeled':
+            event.action = 'unlabeled'
+            event.label = body['label']['name']
         else:
             return None
 
@@ -121,12 +127,11 @@
         action = body.get('action')
         if action != 'created':
             return
+        pr_body = self._issue_to_pull_request(body)
         number = body.get('issue').get('number')
         project_name = body.get('repository').get('full_name')
         pr_body = self.connection.getPull(project_name, number)
         if pr_body is None:
-            self.log.debug('Pull request #%s not found in project %s' %
-                           (number, project_name))
             return
 
         event = self._pull_request_to_event(pr_body)
@@ -135,6 +140,15 @@
         event.action = 'comment'
         return event
 
+    def _issue_to_pull_request(self, body):
+        number = body.get('issue').get('number')
+        project_name = body.get('repository').get('full_name')
+        pr_body = self.connection.getPull(project_name, number)
+        if pr_body is None:
+            self.log.debug('Pull request #%s not found in project %s' %
+                           (number, project_name))
+        return pr_body
+
     def _validate_signature(self, request):
         secret = self.connection.connection_config.get('webhook_token', None)
         if secret is None:
@@ -302,6 +316,16 @@
         repository = self.github.repository(owner, proj)
         repository.create_status(sha, state, url, description, context)
 
+    def labelPull(self, project, pr_number, label):
+        owner, proj = project.split('/')
+        pull_request = self.github.issue(owner, proj, pr_number)
+        pull_request.add_labels(label)
+
+    def unlabelPull(self, project, pr_number, label):
+        owner, proj = project.split('/')
+        pull_request = self.github.issue(owner, proj, pr_number)
+        pull_request.remove_label(label)
+
     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 80ab3c7..159103c 100644
--- a/zuul/driver/github/githubreporter.py
+++ b/zuul/driver/github/githubreporter.py
@@ -31,6 +31,12 @@
         self._commit_status = self.config.get('status', None)
         self._create_comment = self.config.get('comment', True)
         self._merge = self.config.get('merge', False)
+        self._labels = self.config.get('label', [])
+        if not isinstance(self._labels, list):
+            self._labels = [self._labels]
+        self._unlabels = self.config.get('unlabel', [])
+        if not isinstance(self._unlabels, list):
+            self._unlabels = [self._unlabels]
 
     def report(self, source, pipeline, item):
         """Comment on PR and set commit status."""
@@ -43,6 +49,8 @@
         if (self._merge and
             hasattr(item.change, 'number')):
             self.mergePull(item)
+        if self._labels or self._unlabels:
+            self.setLabels(item)
 
     def addPullComment(self, pipeline, item):
         message = self._formatItemReport(pipeline, item)
@@ -88,11 +96,30 @@
             self.connection.mergePull(project, pr_number, sha)
         item.change.is_merged = True
 
+    def setLabels(self, item):
+        project = item.change.project.name
+        pr_number = item.change.number
+        if self._labels:
+            self.log.debug('Reporting change %s, params %s, labels:\n%s' %
+                           (item.change, self.config, self._labels))
+        for label in self._labels:
+                self.connection.labelPull(project, pr_number, label)
+        if self._unlabels:
+            self.log.debug('Reporting change %s, params %s, unlabels:\n%s' %
+                           (item.change, self.config, self._unlabels))
+        for label in self._unlabels:
+                self.connection.unlabelPull(project, pr_number, label)
+
 
 def getSchema():
+    def toList(x):
+        return v.Any([x], x)
+
     github_reporter = v.Schema({
         'status': v.Any('pending', 'success', 'failure'),
         'comment': bool,
-        'merge': bool
+        'merge': bool,
+        'label': toList(str),
+        'unlabel': toList(str)
     })
     return github_reporter
diff --git a/zuul/driver/github/githubtrigger.py b/zuul/driver/github/githubtrigger.py
index 7c7e5d9..629609e 100644
--- a/zuul/driver/github/githubtrigger.py
+++ b/zuul/driver/github/githubtrigger.py
@@ -36,11 +36,15 @@
             actions = trigger.get('action')
             refs = trigger.get('refs')
             comments = self._toList(trigger.get('comment'))
+            labels = trigger.get('label')
+            unlabels = trigger.get('unlabel')
             f = EventFilter(trigger=self,
                             types=self._toList(types),
                             actions=self._toList(actions),
                             refs=self._toList(refs),
-                            comments=self._toList(comments))
+                            comments=self._toList(comments),
+                            labels=self._toList(labels),
+                            unlabels=self._toList(unlabels))
             efilters.append(f)
 
         return efilters
@@ -60,6 +64,8 @@
         'action': toList(str),
         'ref': toList(str),
         'comment': toList(str),
+        'label': toList(str),
+        'unlabel': toList(str),
     }
 
     return github_trigger
diff --git a/zuul/model.py b/zuul/model.py
index 3f56a3a..a86fbbd 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -1894,6 +1894,8 @@
         self.approvals = []
         self.branch = None
         self.comment = None
+        self.label = None
+        self.unlabel = None
         # ref-updated
         self.ref = None
         self.oldrev = None
@@ -2022,7 +2024,8 @@
     def __init__(self, trigger, types=[], branches=[], refs=[],
                  event_approvals={}, comments=[], emails=[], usernames=[],
                  timespecs=[], required_approvals=[], reject_approvals=[],
-                 pipelines=[], actions=[], ignore_deletes=True):
+                 pipelines=[], actions=[], labels=[], unlabels=[],
+                 ignore_deletes=True):
         super(EventFilter, self).__init__(
             required_approvals=required_approvals,
             reject_approvals=reject_approvals)
@@ -2044,6 +2047,8 @@
         self.actions = actions
         self.event_approvals = event_approvals
         self.timespecs = timespecs
+        self.labels = labels
+        self.unlabels = unlabels
         self.ignore_deletes = ignore_deletes
 
     def __repr__(self):
@@ -2078,6 +2083,10 @@
             ret += ' timespecs: %s' % ', '.join(self.timespecs)
         if self.actions:
             ret += ' actions: %s' % ', '.join(self.actions)
+        if self.labels:
+            ret += ' labels: %s' % ', '.join(self.labels)
+        if self.unlabels:
+            ret += ' unlabels: %s' % ', '.join(self.unlabels)
         ret += '>'
 
         return ret
@@ -2182,6 +2191,14 @@
         if self.actions and not matches_action:
             return False
 
+        # labels are ORed
+        if self.labels and event.label not in self.labels:
+            return False
+
+        # unlabels are ORed
+        if self.unlabels and event.unlabel not in self.unlabels:
+            return False
+
         return True