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