Implement basic github checks API workflow

Utilizing the checks API to report the build state to Github provides
some additional functionality that is not supported by the status API.

Those are:
 - Defining custom actions to e.g. cancel a running build
 - Report line-based file annotations

This change implements the basic checks API workflow. Once this is in
place, the additional features could simply be added on top.

Change-Id: I7e790783ee35971085863b5487ff094fa0b23d65
Story: 2007268
Task: 38672
This commit is contained in:
Felix Edel 2020-01-23 09:38:37 +01:00 committed by Felix Edel
parent 1914417dd4
commit 33f87bea9c
No known key found for this signature in database
GPG Key ID: E95717A102DD3030
12 changed files with 718 additions and 11 deletions

View File

@ -59,6 +59,7 @@ To create a `GitHub application
* Set permissions:
* Repository administration: Read
* Checks: Read & Write
* Repository contents: Read & Write (write to let zuul merge change)
* Issues: Read & Write
* Pull requests: Read & Write
@ -66,6 +67,7 @@ To create a `GitHub application
* Set events subscription:
* Check run
* Commit comment
* Create
* Push
@ -203,6 +205,8 @@ the following options.
.. value:: push
.. value:: check_run
.. attr:: action
A :value:`pipeline.trigger.<github source>.event.pull_request`
@ -254,6 +258,18 @@ the following options.
Pull request review removed.
A :value:`pipeline.trigger.<github source>.event.check_run`
event will have associated action(s) to trigger from. The
supported actions are:
.. value:: requested
A check run is requested.
.. value:: completed
A check run completed.
.. attr:: branch
The branch associated with the event. Example: ``master``. This
@ -295,6 +311,23 @@ the following options.
format of ``user:context:status``. For example,
``zuul_github_ci_bot:check_pipeline:success``.
.. attr: check
This is only used for ``check_run`` events. It works similar to
the ``status`` attribute and accepts a list of strings each of
which matches the app requesting or updating the check run, the
check run's name and the conclusion in the format of
``app:name::conclusion``.
To make Zuul properly interact with Github's checks API, each
pipeline that is using the checks API should have at least one
trigger that matches the pipeline's name regardless of the result,
e.g. ``zuul:cool-pipeline:.*``. This will enable the cool-pipeline
to trigger whenever a user requests the ``cool-pipeline`` check
run as part of the ``zuul`` check suite.
Additionally, one could use ``.*:success`` to trigger a pipeline
whenever a successful check run is reported (e.g. useful for
gating).
.. attr:: ref
This is only used for ``push`` events. This field is treated as
@ -330,6 +363,12 @@ itself. Status name, description, and context is taken from the pipeline.
status. Defaults to the zuul server status_url, or the empty
string if that is unset.
.. attr:: check
If the reporter should utilize github's checks API to set the commit
status, this must be set to ``in_progress``, ``success`` or ``failure``
(depending on which status the reporter should report).
.. attr:: comment
:default: true

View File

@ -0,0 +1,12 @@
---
features:
- |
The Github driver now has a basic support for the Github checks API.
To enable reporting build results via the checks API one can configure the
the new :attr:`pipeline.<reporter>.<github source>.check` attribute on the
Github reporter. It's also possible to trigger on a requested or completed
:value:`pipeline.trigger.<github source>.event.check_run`.
To be able to use the checks API, zuul must be authenticated as Github
app. For more information about the necessary requirements, please see
the :ref:`github_driver` driver documentation.

View File

@ -2089,6 +2089,23 @@ class FakeGithubPullRequest(object):
}
return (name, data)
def getCheckRunRequestedEvent(self, cr_name, app="zuul"):
name = "check_run"
data = {
"action": "rerequested",
"check_run": {
"head_sha": self.head_sha,
"name": cr_name,
"app": {
"slug": app,
},
},
"repository": {
"full_name": self.project,
},
}
return (name, data)
def setMerged(self, commit_message):
self.is_merged = True
self.merge_message = commit_message

View File

@ -64,6 +64,47 @@ class FakeStatus(object):
}
class FakeCheckRun(object):
def __init__(self, name, details_url, output, status, conclusion,
completed_at, app):
self.name = name
self.details_url = details_url
self.output = output
self.conclusion = conclusion
self.completed_at = completed_at
self.app = app
# Github automatically sets the status to "completed" if a conclusion
# is provided.
if conclusion is not None:
self.status = "completed"
else:
self.status = status
def as_dict(self):
return {
"name": self.name,
"status": self.status,
"output": self.output,
"details_url": self.details_url,
"conclusion": self.conclusion,
"completed_at": self.completed_at,
"app": {
"slug": self.app,
},
}
def update(self, conclusion, completed_at, output, details_url):
self.conclusion = conclusion
self.completed_at = completed_at
self.output = output
self.details_url = details_url
# As we are only calling the update method when a build is completed,
# we can always set the status to "completed".
self.status = "completed"
class FakeGHReview(object):
def __init__(self, data):
@ -83,6 +124,7 @@ class FakeCommit(object):
def __init__(self, sha):
self._statuses = []
self.sha = sha
self._check_runs = []
def set_status(self, state, url, description, context, user):
status = FakeStatus(
@ -91,14 +133,29 @@ class FakeCommit(object):
# the last status provided for a commit.
self._statuses.insert(0, status)
def set_check_run(self, name, details_url, output, status, conclusion,
completed_at, app):
check_run = FakeCheckRun(
name, details_url, output, status, conclusion, completed_at, app)
# Always insert a check_run to the front of the list to represent the
# last check_run provided for a commit.
self._check_runs.insert(0, check_run)
def get_url(self, path, params=None):
if path == 'statuses':
statuses = [s.as_dict() for s in self._statuses]
return FakeResponse(statuses)
if path == "check-runs":
check_runs = [c.as_dict() for c in self._check_runs]
resp = {"total_count": len(check_runs), "check_runs": check_runs}
return FakeResponse(resp)
def statuses(self):
return self._statuses
def check_runs(self):
return self._check_runs
def status(self):
'''
Returns the combined status wich only contains the latest statuses of
@ -121,6 +178,15 @@ class FakeRepository(object):
self.data = data
self.name = name
# Simple dictionary to store permission values per feature (e.g.
# checks, Repository contents, Pull requests, Commit statuses, ...).
# Could be used to just enable/deable a permission (True, False) or
# provide more specific values like "read" or "read&write". The mocked
# functionality in the FakeRepository class should then check for this
# value and raise an appropriate exception like a production Github
# would do in case the permission is not sufficient or missing at all.
self._permissions = {}
# fail the next commit requests with 404
self.fail_not_found = 0
@ -136,6 +202,13 @@ class FakeRepository(object):
branch.protected = protected
return
def _set_permission(self, key, value):
# NOTE (felix): Currently, this is only used to mock a repo with
# missing checks API permissions. But we could also use it to test
# arbitrary permission values like missing write, but only read
# permissions for a specific functionality.
self._permissions[key] = value
def _build_url(self, *args, **kwargs):
path_args = ['repos', self.name]
path_args.extend(args)
@ -162,6 +235,26 @@ class FakeRepository(object):
self._commits[sha] = commit
commit.set_status(state, url, description, context, user)
def create_check_run(self, head_sha, name, details_url=None, output=None,
status=None, conclusion=None, completed_at=None,
app="zuul"):
# Raise the appropriate github3 exception in case we don't have
# permission to access the checks API
if self._permissions.get("checks") is False:
# To create a proper github3 exception, we need to mock a response
# object
raise github3.exceptions.ForbiddenError(
FakeResponse("Resource not accessible by integration", 403)
)
commit = self._commits.get(head_sha, None)
if commit is None:
commit = FakeCommit(head_sha)
self._commits[head_sha] = commit
commit.set_check_run(
name, details_url, output, status, conclusion, completed_at, app)
def commit(self, sha):
if self.fail_not_found > 0:
@ -410,6 +503,12 @@ class FakeResponse(object):
self.data = data
self.links = {}
@property
def content(self):
# Building github3 exceptions requires a Response object with the
# content attribute set.
return self.data
def json(self):
return self.data

View File

@ -78,6 +78,25 @@
comment: true
status: failure
- pipeline:
name: checks-api-reporting
description: Reporting via Githubs Checks API
manager: independent
trigger:
github:
- event: push
- event: pull_request
action: opened
start:
github:
check: in_progress
success:
github:
check: success
failure:
github:
check: failure
- job:
name: base
parent: null
@ -104,3 +123,9 @@
push-reporting:
jobs:
- project-test1
- project:
name: org/project3
checks-api-reporting:
jobs:
- project-test1

View File

@ -75,6 +75,19 @@
github:
comment: true
- pipeline:
name: trigger_check_run
manager: independent
trigger:
github:
- event: check_run
action: requested
check: zuul:tenant-one/check:.*
success:
github:
comment: true
check: success
- pipeline:
name: trigger
manager: independent
@ -350,6 +363,10 @@
name: project14-current
run: playbooks/project14-current.yaml
- job:
name: project15-check-run
run: playbooks/project15-check-run.yaml
- project:
name: org/project1
pipeline:
@ -442,3 +459,9 @@
reject_current:
jobs:
- project14-current
- project:
name: org/project15
trigger_check_run:
jobs:
- project15-check-run

View File

@ -26,7 +26,8 @@ import github3.exceptions
from zuul.driver.github.githubconnection import GithubShaCache
import zuul.rpcclient
from tests.base import BaseTestCase, ZuulTestCase, simple_layout, random_sha1
from tests.base import (BaseTestCase, ZuulGithubAppTestCase, ZuulTestCase,
simple_layout, random_sha1)
from tests.base import ZuulWebFixture
@ -576,6 +577,37 @@ class TestGithubDriver(ZuulTestCase):
self.executor_server.release()
self.waitUntilSettled()
@simple_layout("layouts/reporting-github.yaml", driver="github")
def test_reporting_checks_api_unauthorized(self):
# Using the checks API only works with app authentication. As all tests
# within the TestGithubDriver class are executed without app
# authentication, the checks API won't work here.
project = "org/project3"
github = self.fake_github.getGithubClient(None)
# The pipeline reports pull request status both on start and success.
# As we are not authenticated as app, this won't create or update any
# check runs, but should post two comments (start, success) informing
# the user about the missing authentication.
A = self.fake_github.openFakePullRequest(project, "master", "A")
self.fake_github.emitEvent(A.getPullRequestOpenedEvent())
self.waitUntilSettled()
self.assertIn(
A.head_sha, github.repo_from_project(project)._commits.keys()
)
check_runs = self.fake_github.getCommitChecks(project, A.head_sha)
self.assertEqual(0, len(check_runs))
expected_warning = (
"Unable to create or update check tenant-one/checks-api-reporting."
" Must be authenticated as app integration."
)
self.assertEqual(2, len(A.comments))
self.assertIn(expected_warning, A.comments[0])
self.assertIn(expected_warning, A.comments[1])
@simple_layout('layouts/merging-github.yaml', driver='github')
def test_report_pull_merge(self):
# pipeline merges the pull request on success
@ -1554,3 +1586,128 @@ class TestGithubShaCache(BaseTestCase):
}
cache.update('foo/bar', pr_dict)
self.assertEqual(cache.get('foo/bar', '123456'), set({1}))
class TestGithubAppDriver(ZuulGithubAppTestCase):
"""Inheriting from ZuulGithubAppTestCase will enable app authentication"""
config_file = 'zuul-github-driver.conf'
@simple_layout("layouts/reporting-github.yaml", driver="github")
def test_reporting_checks_api(self):
"""Using the checks API only works with app authentication"""
project = "org/project3"
github = self.fake_github.getGithubClient(None)
# pipeline reports pull request status both on start and success
self.executor_server.hold_jobs_in_build = True
A = self.fake_github.openFakePullRequest(project, "master", "A")
self.fake_github.emitEvent(A.getPullRequestOpenedEvent())
self.waitUntilSettled()
# We should have a pending check for the head sha
self.assertIn(
A.head_sha, github.repo_from_project(project)._commits.keys())
check_runs = self.fake_github.getCommitChecks(project, A.head_sha)
self.assertEqual(1, len(check_runs))
check_run = check_runs[0]
self.assertEqual("tenant-one/checks-api-reporting", check_run["name"])
self.assertEqual("in_progress", check_run["status"])
self.assertThat(
check_run["output"]["summary"],
MatchesRegex(r'.*Starting checks-api-reporting jobs.*', re.DOTALL)
)
# TODO (felix): How can we test if the details_url was set correctly?
# How can the details_url be configured on the test case?
self.executor_server.hold_jobs_in_build = False
self.executor_server.release()
self.waitUntilSettled()
# We should now have an updated status for the head sha
check_runs = self.fake_github.getCommitChecks(project, A.head_sha)
self.assertEqual(1, len(check_runs))
check_run = check_runs[0]
self.assertEqual("tenant-one/checks-api-reporting", check_run["name"])
self.assertEqual("completed", check_run["status"])
self.assertEqual("success", check_run["conclusion"])
self.assertThat(
check_run["output"]["summary"],
MatchesRegex(r'.*Build succeeded.*', re.DOTALL)
)
self.assertIsNotNone(check_run["completed_at"])
@simple_layout("layouts/reporting-github.yaml", driver="github")
def test_update_non_existing_check_run(self):
project = "org/project3"
github = self.fake_github.getGithubClient(None)
# pipeline reports pull request status both on start and success
self.executor_server.hold_jobs_in_build = True
A = self.fake_github.openFakePullRequest(project, "master", "A")
self.fake_github.emitEvent(A.getPullRequestOpenedEvent())
self.waitUntilSettled()
# We should have a pending check for the head sha
commit = github.repo_from_project(project)._commits.get(A.head_sha)
check_runs = commit.check_runs()
self.assertEqual(1, len(check_runs))
# Delete this check_run to simulate a failed check_run creation
commit._check_runs = []
# Now run the build and check if the update of the check_run could
# still be accomplished.
self.executor_server.hold_jobs_in_build = False
self.executor_server.release()
self.waitUntilSettled()
check_runs = self.fake_github.getCommitChecks(project, A.head_sha)
self.assertEqual(1, len(check_runs))
check_run = check_runs[0]
self.assertEqual("tenant-one/checks-api-reporting", check_run["name"])
self.assertEqual("completed", check_run["status"])
self.assertEqual("success", check_run["conclusion"])
self.assertThat(
check_run["output"]["summary"],
MatchesRegex(r'.*Build succeeded.*', re.DOTALL)
)
self.assertIsNotNone(check_run["completed_at"])
@simple_layout("layouts/reporting-github.yaml", driver="github")
def test_update_check_run_missing_permissions(self):
project = "org/project3"
github = self.fake_github.getGithubClient(None)
repo = github.repo_from_project(project)
repo._set_permission("checks", False)
A = self.fake_github.openFakePullRequest(project, "master", "A")
self.fake_github.emitEvent(A.getPullRequestOpenedEvent())
self.waitUntilSettled()
# Alghough we are authenticated as github app, we are lacking the
# necessary "checks" permissions for the test repository. Thus, the
# check run creation/update should fail and we end up in two comments
# being posted to the PR with appropriate warnings.
commit = github.repo_from_project(project)._commits.get(A.head_sha)
check_runs = commit.check_runs()
self.assertEqual(0, len(check_runs))
self.assertIn(
A.head_sha, github.repo_from_project(project)._commits.keys()
)
check_runs = self.fake_github.getCommitChecks(project, A.head_sha)
self.assertEqual(0, len(check_runs))
expected_warning = (
"Failed to update check run tenant-one/checks-api-reporting: "
"403 Resource not accessible by integration"
)
self.assertEqual(2, len(A.comments))
self.assertIn(expected_warning, A.comments[0])
self.assertIn(expected_warning, A.comments[1])

View File

@ -135,6 +135,37 @@ class TestGithubRequirements(ZuulTestCase):
self.assertEqual(len(self.history), 2)
self.assertEqual(self.history[1].name, 'project2-trigger')
@simple_layout("layouts/requirements-github.yaml", driver="github")
def test_trigger_on_check_run(self):
"""Test trigger on: check_run"""
project = "org/project15"
A = self.fake_github.openFakePullRequest(project, "master", "A")
# A check_run request with a different name should not cause it to be
# enqueued.
self.fake_github.emitEvent(
A.getCheckRunRequestedEvent("tenant-one/different-check")
)
self.waitUntilSettled()
self.assertEqual(len(self.history), 0)
# A check_run request with the correct name, but for a different app
# should not cause it to be enqueued.
self.fake_github.emitEvent(
A.getCheckRunRequestedEvent("tenant-one/check", app="other-ci")
)
self.waitUntilSettled()
self.assertEqual(len(self.history), 0)
# A check_run request with the correct name for the correct app should
# cause it to be enqueued.
self.fake_github.emitEvent(
A.getCheckRunRequestedEvent("tenant-one/check"))
self.waitUntilSettled()
self.assertEqual(len(self.history), 1)
self.assertEqual(self.history[0].name, "project15-check-run")
@simple_layout('layouts/requirements-github.yaml', driver='github')
def test_pipeline_require_review_username(self):
"Test pipeline requirement: review username"

View File

@ -552,6 +552,58 @@ class GithubEventProcessor(object):
event.status = "%s:%s:%s" % _status_as_tuple(self.body)
return event
def _event_check_run(self):
"""Handles check_run requests.
This maps to the "Re-run" action on a check run and the "Re-run failed
checks" on a check suite in Github.
This event should be handled similar to a PR commnent or a push.
"""
action = self.body.get("action")
# NOTE (felix): We could also handle "requested" events here, which are
# sent by Github whenever a change is pushed. But as we are already
# listening to push events, this would result in two trigger events
# for the same Github event.
if action not in ["rerequested", "completed"]:
return
# The head_sha identifies the commit the check_run is requested for
# (similar to Github's status API).
check_run = self.body.get("check_run")
if not check_run:
# This shouldn't happen but in case something went wrong it should
# also not cause an exception in the event handling
return
project = self.body.get("repository", {}).get("full_name")
head_sha = check_run.get("head_sha")
# Zuul will only accept Github changes that are part of a PR, thus we
# must look up the PR first.
pr_body = self.connection.getPullBySha(
head_sha, project, self.zuul_event_id)
if pr_body is None:
self.log.debug(
"Could not find appropriate PR for SHA %s. "
"Skipping check_run event",
head_sha
)
return
# Build a trigger event for the check_run request
event = self._pull_request_to_event(pr_body)
event.type = "check_run"
# Simplify rerequested action to requested
if action == "rerequested":
action = "requested"
event.action = action
check_run = "%s:%s:%s" % _check_as_tuple(self.body["check_run"])
event.check_run = check_run
return event
def _issue_to_pull_request(self, body):
number = body.get('issue').get('number')
project_name = body.get('repository').get('full_name')
@ -1628,6 +1680,17 @@ class GithubConnection(BaseConnection):
successful = set([s.context for s in commit.status().statuses
if s.state == 'success'])
if self.app_id:
try:
# Required contexts can be fulfilled by statuses or check runs.
successful.update([cr.name for cr in commit.check_runs()
if cr.conclusion == 'success'])
except github3.exceptions.GitHubException as exc:
self.log.error(
"Unable to retrieve check runs for commit %s: %s",
commit.sha, str(exc)
)
# Required contexts must be a subset of the successful contexts as
# we allow additional successful status contexts we don't care about.
return required_contexts.issubset(successful)
@ -1723,6 +1786,29 @@ class GithubConnection(BaseConnection):
log.debug("Set commit status to %s for sha %s on %s",
state, sha, project)
def getCommitChecks(self, project_name, sha, zuul_event_id=None):
log = get_annotated_logger(self.log, zuul_event_id)
if not self.app_id:
log.debug(
"Not authenticated as Github app. Unable to retrieve commit "
"checks for sha %s on %s",
sha, project_name
)
return []
github = self.getGithubClient(
project_name, zuul_event_id=zuul_event_id
)
url = github.session.build_url(
"repos", project_name, "commits", sha, "check-runs")
headers = {'Accept': 'application/vnd.github.antiope-preview+json'}
params = {"per_page": 100}
resp = github.session.get(url, params=params, headers=headers)
resp.raise_for_status()
log.debug("Got commit checks for sha %s on %s", sha, project_name)
return resp.json().get("check_runs", [])
def reviewPull(self, project, pr_number, sha, review, body,
zuul_event_id=None):
github = self.getGithubClient(project, zuul_event_id=zuul_event_id)
@ -1748,6 +1834,144 @@ class GithubConnection(BaseConnection):
pull_request.remove_label(label)
log.debug("Removed label %s from %s#%s", label, proj, pr_number)
def updateCheck(self, project, pr_number, sha, status, completed, context,
details_url, message, zuul_event_id=None):
log = get_annotated_logger(self.log, zuul_event_id)
github = self.getGithubClient(project, zuul_event_id=zuul_event_id)
owner, proj = project.split("/")
repository = github.repository(owner, proj)
# Track a list of failed check run operations to report back to Github
errors = []
if not self.app_id:
# We don't try to update check runs, if we aren't authenticated as
# Github app at all. If we are, we still have to ensure that we
# don't crash on missing permissions.
log.debug(
"Not authenticated as Github app. Unable to create or update "
"check run '%s' for sha %s on %s",
context, sha, project
)
errors.append(
"Unable to create or update check {}. Must be authenticated "
"as app integration.".format(
context
)
)
return errors
output = {"title": "Summary", "summary": message}
# Currently, the GithubReporter only supports start and end reporting.
# During the build no further update will be reported.
if completed:
# As the buildset itself does not provide a proper end time, we
# use the current time instead. Otherwise, we would have to query
# all builds contained in the buildset and search for the latest
# build.end_time available.
completed_at = datetime.datetime.now(utc).isoformat()
# When reporting the completion of a check_run, we must set the
# conclusion, as the status will always be "completed".
conclusion = status
# Unless something went wrong during the start reporting of this
# change (e.g. the check_run creation failed), there should already
# be a check_run available. If not we will create one.
check_runs = []
try:
check_runs = [
c for c in repository.commit(sha).check_runs()
if c.name == context
]
except github3.exceptions.GitHubException as exc:
log.error(
"Could not retrieve existing check runs for %s#%s on "
"sha %s: %s",
project, pr_number, sha, str(exc),
)
if not check_runs:
log.debug(
"Could not find check run %s for %s#%s on sha %s. "
"Creating a new one",
context, project, pr_number, sha,
)
try:
check_run = repository.create_check_run(
name=context,
head_sha=sha,
conclusion=conclusion,
completed_at=completed_at,
output=output,
details_url=details_url,
)
except github3.exceptions.GitHubException as exc:
# TODO (felix): Should we retry the check_run creation?
log.error(
"Failed to create check run %s for %s#%s on sha %s: "
"%s",
context, project, pr_number, sha, str(exc)
)
errors.append(
"Failed to create check run {}: {}".format(
context, str(exc)
)
)
else:
check_run = check_runs[0]
log.debug(
"Updating existing check run %s for %s#%s on sha %s "
"with status %s",
context, project, pr_number, sha, status,
)
try:
check_run.update(
conclusion=conclusion,
completed_at=completed_at,
output=output,
details_url=details_url,
)
except github3.exceptions.GitHubException as exc:
log.error(
"Failed to update check run %s for %s#%s on sha %s: "
"%s",
context, project, pr_number, sha, str(exc),
)
errors.append(
"Failed to update check run {}: {}".format(
context, str(exc)
)
)
else:
# Report the start of a check run
try:
check_run = repository.create_check_run(
name=context,
head_sha=sha,
status=status,
output=output,
details_url=details_url,
)
except github3.exceptions.GitHubException as exc:
# TODO (felix): Should we retry the check run creation?
log.error(
"Failed to create check run %s for %s#%s on sha %s: %s",
context, project, pr_number, sha, str(exc),
)
errors.append(
"Failed to update check run {}: {}".format(
context, str(exc)
)
)
return errors
def getPushedFileNames(self, event):
files = set()
for c in event.commits:
@ -1768,8 +1992,7 @@ class GithubConnection(BaseConnection):
# by user, so that we can require/trigger by user too.
seen = []
statuses = []
for status in self.getCommitStatuses(
project.name, sha, event):
for status in self.getCommitStatuses(project.name, sha, event):
stuple = _status_as_tuple(status)
if "%s:%s" % (stuple[0], stuple[1]) not in seen:
statuses.append("%s:%s:%s" % stuple)
@ -1898,3 +2121,18 @@ def _status_as_tuple(status):
context = status.get('context')
state = status.get('state')
return (user, context, state)
def _check_as_tuple(check):
"""Translate a check into a tuple of app, name, conclusion"""
# A check_run does not contain any "creator" information like a status, but
# only the app for/by which it was created.
app = check.get("app")
if app:
slug = app.get("slug")
else:
slug = "Unknown"
name = check.get("name")
conclusion = check.get("conclusion")
return (slug, name, conclusion)

View File

@ -56,6 +56,7 @@ class GithubTriggerEvent(TriggerEvent):
self.unlabel = None
self.action = None
self.delivery = None
self.check_runs = None
def isPatchsetCreated(self):
if self.type == 'pull_request':
@ -76,6 +77,8 @@ class GithubTriggerEvent(TriggerEvent):
r.append('%s,%s' % (self.change_number, self.patch_number))
if self.delivery:
r.append('delivery: %s' % self.delivery)
if self.check_runs:
r.append('check_runs: %s' % self.check_runs)
return ' '.join(r)
@ -217,7 +220,7 @@ class GithubEventFilter(EventFilter, GithubCommonFilter):
def __init__(self, trigger, types=[], branches=[], refs=[],
comments=[], actions=[], labels=[], unlabels=[],
states=[], statuses=[], required_statuses=[],
ignore_deletes=True):
check_runs=[], ignore_deletes=True):
EventFilter.__init__(self, trigger)
@ -237,6 +240,7 @@ class GithubEventFilter(EventFilter, GithubCommonFilter):
self.states = states
self.statuses = statuses
self.required_statuses = required_statuses
self.check_runs = check_runs
self.ignore_deletes = ignore_deletes
def __repr__(self):
@ -254,6 +258,8 @@ class GithubEventFilter(EventFilter, GithubCommonFilter):
ret += ' comments: %s' % ', '.join(self._comments)
if self.actions:
ret += ' actions: %s' % ', '.join(self.actions)
if self.check_runs:
ret += ' check_runs: %s' % ','.join(self.check_runs)
if self.labels:
ret += ' labels: %s' % ', '.join(self.labels)
if self.unlabels:
@ -320,6 +326,17 @@ class GithubEventFilter(EventFilter, GithubCommonFilter):
return FalseWithReason("Actions %s doesn't match %s" % (
self.actions, event.action))
# check_runs are ORed
if self.check_runs:
check_run_found = False
for check_run in self.check_runs:
if re2.fullmatch(check_run, event.check_run):
check_run_found = True
break
if not check_run_found:
return FalseWithReason("Check_runs %s doesn't match %s" % (
self.check_runs, event.check_run))
# labels are ORed
if self.labels and event.label not in self.labels:
return FalseWithReason("Labels %s doesn't match %s" % (

View File

@ -42,6 +42,7 @@ class GithubReporter(BaseReporter):
super(GithubReporter, self).__init__(driver, connection, config)
self._commit_status = self.config.get('status', None)
self._create_comment = self.config.get('comment', True)
self._check = self.config.get('check', False)
self._merge = self.config.get('merge', False)
self._labels = self.config.get('label', [])
if not isinstance(self._labels, list):
@ -77,12 +78,22 @@ class GithubReporter(BaseReporter):
# Comments, labels, and merges can only be performed on pull requests.
# If the change is not a pull request (e.g. a push) skip them.
if hasattr(item.change, 'number'):
if self._create_comment:
self.addPullComment(item)
errors_received = False
if self._labels or self._unlabels:
self.setLabels(item)
if self._review:
self.addReview(item)
if self._check:
check_errors = self.updateCheck(item)
# TODO (felix): We could use this mechanism to also report back
# errors from label and review actions
if check_errors:
item.current_build_set.warning_messages.extend(
check_errors
)
errors_received = True
if self._create_comment or errors_received:
self.addPullComment(item)
if (self._merge):
self.mergePull(item)
if not item.change.is_merged:
@ -194,6 +205,38 @@ class GithubReporter(BaseReporter):
self.connection.unlabelPull(project, pr_number, label,
zuul_event_id=item.event)
def updateCheck(self, item):
log = get_annotated_logger(self.log, item.event)
message = self._formatItemReport(item)
project = item.change.project.name
pr_number = item.change.number
sha = item.change.patchset
# Check if the buildset is finished or not. In case it's finished, we
# must provide additional parameters when updating the check_run via
# the Github API later on.
completed = item.current_build_set.result is not None
status = self._check
log.debug(
"Updating check for change %s, params %s, context %s, message: %s",
item.change, self.config, self.context, message
)
details_url = item.formatStatusUrl()
return self.connection.updateCheck(
project,
pr_number,
sha,
status,
completed,
self.context,
details_url,
message,
zuul_event_id=item.event,
)
def setLabels(self, item):
log = get_annotated_logger(self.log, item.event)
project = item.change.project.name
@ -244,9 +287,11 @@ class GithubReporter(BaseReporter):
this reporter itself is likely to set before submitting.
"""
# check if we report a status, if not we can return an empty list
# check if we report a status or a check, if not we can return an
# empty list
status = self.config.get('status')
if not status:
check = self.config.get("check")
if not any([status, check]):
return []
# we return a status so return the status we report to github
@ -262,6 +307,7 @@ def getSchema():
'label': scalar_or_list(str),
'unlabel': scalar_or_list(str),
'review': v.Any('approve', 'request-changes', 'comment'),
'review-body': str
'review-body': str,
'check': v.Any("in_progress", "success", "failure"),
})
return github_reporter

View File

@ -33,6 +33,7 @@ class GithubTrigger(BaseTrigger):
branches=to_list(trigger.get('branch')),
refs=to_list(trigger.get('ref')),
comments=to_list(trigger.get('comment')),
check_runs=to_list(trigger.get('check')),
labels=to_list(trigger.get('label')),
unlabels=to_list(trigger.get('unlabel')),
states=to_list(trigger.get('state')),
@ -52,7 +53,8 @@ def getSchema():
v.Required('event'):
scalar_or_list(v.Any('pull_request',
'pull_request_review',
'push')),
'push',
'check_run')),
'action': scalar_or_list(str),
'branch': scalar_or_list(str),
'ref': scalar_or_list(str),
@ -61,7 +63,8 @@ def getSchema():
'unlabel': scalar_or_list(str),
'state': scalar_or_list(str),
'require-status': scalar_or_list(str),
'status': scalar_or_list(str)
'status': scalar_or_list(str),
'check': scalar_or_list(str),
}
return github_trigger