Merge "Dequeue changes via github checks API"

This commit is contained in:
Zuul 2020-08-06 23:57:49 +00:00 committed by Gerrit Code Review
commit ccdeffaa2f
7 changed files with 263 additions and 13 deletions

View File

@ -0,0 +1,6 @@
---
features:
- |
Changes can now be dequeued via the Github checks API. If a github
reporter is configured to use the checks API, all running checks will
provide a custom "Abort" action.

View File

@ -2215,6 +2215,31 @@ class FakeGithubPullRequest(object):
} }
return (name, data) return (name, data)
def getCheckRunAbortEvent(self, check_run):
# A check run aborted event can only be created from a FakeCheckRun as
# we need some information like external_id which is "calculated"
# during the creation of the check run.
name = "check_run"
data = {
"action": "requested_action",
"requested_action": {
"identifier": "abort",
},
"check_run": {
"head_sha": self.head_sha,
"name": check_run["name"],
"app": {
"slug": check_run["app"]
},
"external_id": check_run["external_id"],
},
"repository": {
"full_name": self.project,
},
}
return (name, data)
def setMerged(self, commit_message): def setMerged(self, commit_message):
self.is_merged = True self.is_merged = True
self.merge_message = commit_message self.merge_message = commit_message

View File

@ -84,12 +84,17 @@ class FakeStatus(object):
class FakeCheckRun(object): class FakeCheckRun(object):
def __init__(self, name, details_url, output, status, conclusion, def __init__(self, name, details_url, output, status, conclusion,
completed_at, app): completed_at, external_id, actions, app):
if actions is None:
actions = []
self.name = name self.name = name
self.details_url = details_url self.details_url = details_url
self.output = output self.output = output
self.conclusion = conclusion self.conclusion = conclusion
self.completed_at = completed_at self.completed_at = completed_at
self.external_id = external_id
self.actions = actions
self.app = app self.app = app
# Github automatically sets the status to "completed" if a conclusion # Github automatically sets the status to "completed" if a conclusion
@ -107,16 +112,21 @@ class FakeCheckRun(object):
"details_url": self.details_url, "details_url": self.details_url,
"conclusion": self.conclusion, "conclusion": self.conclusion,
"completed_at": self.completed_at, "completed_at": self.completed_at,
"external_id": self.external_id,
"actions": self.actions,
"app": { "app": {
"slug": self.app, "slug": self.app,
}, },
} }
def update(self, conclusion, completed_at, output, details_url): def update(self, conclusion, completed_at, output, details_url,
external_id, actions):
self.conclusion = conclusion self.conclusion = conclusion
self.completed_at = completed_at self.completed_at = completed_at
self.output = output self.output = output
self.details_url = details_url self.details_url = details_url
self.external_id = external_id
self.actions = actions
# As we are only calling the update method when a build is completed, # As we are only calling the update method when a build is completed,
# we can always set the status to "completed". # we can always set the status to "completed".
@ -152,9 +162,17 @@ class FakeCommit(object):
self._statuses.insert(0, status) self._statuses.insert(0, status)
def set_check_run(self, name, details_url, output, status, conclusion, def set_check_run(self, name, details_url, output, status, conclusion,
completed_at, app): completed_at, external_id, actions, app):
check_run = FakeCheckRun( check_run = FakeCheckRun(
name, details_url, output, status, conclusion, completed_at, app name,
details_url,
output,
status,
conclusion,
completed_at,
external_id,
actions,
app,
) )
# Always insert a check_run to the front of the list to represent the # Always insert a check_run to the front of the list to represent the
# last check_run provided for a commit. # last check_run provided for a commit.
@ -264,7 +282,7 @@ class FakeRepository(object):
def create_check_run(self, head_sha, name, details_url=None, output=None, def create_check_run(self, head_sha, name, details_url=None, output=None,
status=None, conclusion=None, completed_at=None, status=None, conclusion=None, completed_at=None,
app="zuul"): external_id=None, actions=None, app="zuul"):
# Raise the appropriate github3 exception in case we don't have # Raise the appropriate github3 exception in case we don't have
# permission to access the checks API # permission to access the checks API
@ -280,7 +298,16 @@ class FakeRepository(object):
commit = FakeCommit(head_sha) commit = FakeCommit(head_sha)
self._commits[head_sha] = commit self._commits[head_sha] = commit
commit.set_check_run( commit.set_check_run(
name, details_url, output, status, conclusion, completed_at, app) name,
details_url,
output,
status,
conclusion,
completed_at,
external_id,
actions,
app,
)
def commit(self, sha): def commit(self, sha):

View File

@ -12,6 +12,7 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import json
import os import os
import re import re
from testtools.matchers import MatchesRegex, Not, StartsWith from testtools.matchers import MatchesRegex, Not, StartsWith
@ -1796,6 +1797,28 @@ class TestGithubAppDriver(ZuulGithubAppTestCase):
check_run["output"]["summary"], check_run["output"]["summary"],
MatchesRegex(r'.*Starting checks-api-reporting jobs.*', re.DOTALL) MatchesRegex(r'.*Starting checks-api-reporting jobs.*', re.DOTALL)
) )
# The external id should be a json-string containing all relevant
# information to uniquely identify this change.
self.assertEqual(
json.dumps(
{
"tenant": "tenant-one",
"pipeline": "checks-api-reporting",
"change": 1
}
),
check_run["external_id"],
)
# A running check run should provide a custom abort action
self.assertEqual(1, len(check_run["actions"]))
self.assertEqual(
{
"identifier": "abort",
"description": "Abort this check run",
"label": "Abort",
},
check_run["actions"][0],
)
# TODO (felix): How can we test if the details_url was set correctly? # TODO (felix): How can we test if the details_url was set correctly?
# How can the details_url be configured on the test case? # How can the details_url be configured on the test case?
@ -1817,6 +1840,8 @@ class TestGithubAppDriver(ZuulGithubAppTestCase):
MatchesRegex(r'.*Build succeeded.*', re.DOTALL) MatchesRegex(r'.*Build succeeded.*', re.DOTALL)
) )
self.assertIsNotNone(check_run["completed_at"]) self.assertIsNotNone(check_run["completed_at"])
# A completed check run should not provide any custom actions
self.assertEqual(0, len(check_run["actions"]))
# Tell gate to merge to test checks requirements # Tell gate to merge to test checks requirements
self.fake_github.emitEvent(A.getCommentAddedEvent('merge me')) self.fake_github.emitEvent(A.getCommentAddedEvent('merge me'))
@ -1915,6 +1940,8 @@ class TestGithubAppDriver(ZuulGithubAppTestCase):
MatchesRegex(r'.*Build succeeded.*', re.DOTALL) MatchesRegex(r'.*Build succeeded.*', re.DOTALL)
) )
self.assertIsNotNone(check_run["completed_at"]) self.assertIsNotNone(check_run["completed_at"])
# A completed check run should not provide any custom actions
self.assertEqual(0, len(check_run["actions"]))
@simple_layout("layouts/reporting-github.yaml", driver="github") @simple_layout("layouts/reporting-github.yaml", driver="github")
def test_update_check_run_missing_permissions(self): def test_update_check_run_missing_permissions(self):
@ -1950,6 +1977,65 @@ class TestGithubAppDriver(ZuulGithubAppTestCase):
self.assertIn(expected_warning, A.comments[0]) self.assertIn(expected_warning, A.comments[0])
self.assertIn(expected_warning, A.comments[1]) self.assertIn(expected_warning, A.comments[1])
@simple_layout("layouts/reporting-github.yaml", driver="github")
def test_abort_check_run(self):
"Test that we can dequeue a change by aborting the related check run"
project = "org/project3"
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 that provides an
# abort action.
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.assertEqual(1, len(check_run["actions"]))
self.assertEqual("abort", check_run["actions"][0]["identifier"])
self.assertEqual(
{
"tenant": "tenant-one",
"pipeline": "checks-api-reporting",
"change": 1
},
json.loads(check_run["external_id"])
)
# Simulate a click on the "Abort" button in Github by faking a webhook
# event with our custom abort action.
# Handling this event should dequeue the related change
self.fake_github.emitEvent(A.getCheckRunAbortEvent(check_run))
self.waitUntilSettled()
tenant = self.scheds.first.sched.abide.tenants.get("tenant-one")
check_pipeline = tenant.layout.pipelines["check"]
self.assertEqual(0, len(check_pipeline.getAllItems()))
self.assertEqual(1, self.countJobResults(self.history, "ABORTED"))
# The buildset was already dequeued, so there shouldn't be anything to
# release.
self.executor_server.hold_jobs_in_build = False
self.executor_server.release()
self.waitUntilSettled()
# Since the change/buildset was dequeued, the check run should be
# reported as cancelled and don't provide any further action.
check_runs = self.fake_github.getCommitChecks(project, A.head_sha)
self.assertEqual(1, len(check_runs))
aborted_check_run = check_runs[0]
self.assertEqual(
"tenant-one/checks-api-reporting", aborted_check_run["name"]
)
self.assertEqual("completed", aborted_check_run["status"])
self.assertEqual("cancelled", aborted_check_run["conclusion"])
self.assertEqual(0, len(aborted_check_run["actions"]))
class TestCheckRunAnnotations(ZuulGithubAppTestCase, AnsibleZuulTestCase): class TestCheckRunAnnotations(ZuulGithubAppTestCase, AnsibleZuulTestCase):
"""We need Github app authentication and be able to run Ansible jobs""" """We need Github app authentication and be able to run Ansible jobs"""

View File

@ -46,6 +46,7 @@ from zuul.lib.logutil import get_annotated_logger
from zuul.model import Ref, Branch, Tag, Project from zuul.model import Ref, Branch, Tag, Project
from zuul.exceptions import MergeFailure from zuul.exceptions import MergeFailure
from zuul.driver.github.githubmodel import PullRequest, GithubTriggerEvent from zuul.driver.github.githubmodel import PullRequest, GithubTriggerEvent
from zuul.scheduler import DequeueEvent
GITHUB_BASE_URL = 'https://api.github.com' GITHUB_BASE_URL = 'https://api.github.com'
PREVIEW_JSON_ACCEPT = 'application/vnd.github.machine-man-preview+json' PREVIEW_JSON_ACCEPT = 'application/vnd.github.machine-man-preview+json'
@ -361,6 +362,8 @@ class GithubEventProcessor(object):
self.connection.getEventQueueSize()) self.connection.getEventQueueSize())
try: try:
self._process_event() self._process_event()
except Exception:
self.log.exception("Exception when processing event:")
finally: finally:
self.log.debug("Finished event processing") self.log.debug("Finished event processing")
return self.event return self.event
@ -426,7 +429,7 @@ class GithubEventProcessor(object):
# If this event references a branch and we're excluding # If this event references a branch and we're excluding
# unprotected branches, we might need to check whether the # unprotected branches, we might need to check whether the
# branch is now protected. # branch is now protected.
if event.branch: if hasattr(event, "branch") and event.branch:
b = self.connection.getBranch(project.name, event.branch) b = self.connection.getBranch(project.name, event.branch)
if b is not None: if b is not None:
branch_protected = b.get('protected') branch_protected = b.get('protected')
@ -591,7 +594,7 @@ class GithubEventProcessor(object):
# sent by Github whenever a change is pushed. But as we are already # sent by Github whenever a change is pushed. But as we are already
# listening to push events, this would result in two trigger events # listening to push events, this would result in two trigger events
# for the same Github event. # for the same Github event.
if action not in ["rerequested", "completed"]: if action not in ["rerequested", "completed", "requested_action"]:
return return
# The head_sha identifies the commit the check_run is requested for # The head_sha identifies the commit the check_run is requested for
@ -617,16 +620,55 @@ class GithubEventProcessor(object):
) )
return return
# Build a trigger event for the check_run request # In case a "requested_action" event was triggered, we must first
# evaluate the contained action (identifier), to build the right
# event that will e.g. abort/dequeue a buildset.
if action == "requested_action":
# Look up the action's identifier from the payload
identifier = self.body.get("requested_action", {}).get(
"identifier"
)
# currently we only support "abort" identifier
if identifier != "abort":
return
# In case of an abort (which is currently the only supported
# action), we will build a dequeue event and return this rather
# than a trigger event.
return self._check_run_action_to_event(check_run, project)
# If no requested_action was supplied, we build a trigger event for the
# check run request
event = self._pull_request_to_event(pr_body) event = self._pull_request_to_event(pr_body)
event.type = "check_run" event.type = "check_run"
# Simplify rerequested action to requested # Simplify rerequested action to requested
if action == "rerequested": if action == "rerequested":
action = "requested" action = "requested"
event.action = action event.action = action
check_run = "%s:%s:%s" % _check_as_tuple(self.body["check_run"]) check_run_tuple = "%s:%s:%s" % _check_as_tuple(check_run)
event.check_run = check_run event.check_run = check_run_tuple
return event
def _check_run_action_to_event(self, check_run, project):
# Extract necessary values from the check's external id to dequeue
# the corresponding change in Zuul
dequeue_attrs = json.loads(check_run["external_id"])
# The dequeue operations needs the change in format
# <pr_number>,<commit_sha>
change = "{},{}".format(dequeue_attrs["change"], check_run["head_sha"])
# Instead of a trigger event, we directly dequeue the change by calling
# the appropriate method on the scheduler.
event = DequeueEvent(
dequeue_attrs["tenant"],
dequeue_attrs["pipeline"],
project,
change,
ref=None
)
return event return event
def _issue_to_pull_request(self, body): def _issue_to_pull_request(self, body):
@ -1880,12 +1922,16 @@ class GithubConnection(BaseConnection):
log.debug("Removed label %s from %s#%s", label, proj, pr_number) log.debug("Removed label %s from %s#%s", label, proj, pr_number)
def updateCheck(self, project, pr_number, sha, status, completed, context, def updateCheck(self, project, pr_number, sha, status, completed, context,
details_url, message, file_comments, zuul_event_id=None): details_url, message, file_comments, external_id,
zuul_event_id=None):
log = get_annotated_logger(self.log, zuul_event_id) log = get_annotated_logger(self.log, zuul_event_id)
github = self.getGithubClient(project, zuul_event_id=zuul_event_id) github = self.getGithubClient(project, zuul_event_id=zuul_event_id)
owner, proj = project.split("/") owner, proj = project.split("/")
repository = github.repository(owner, proj) repository = github.repository(owner, proj)
# Always provide an empty list of actions by default
actions = []
# Track a list of failed check run operations to report back to Github # Track a list of failed check run operations to report back to Github
errors = [] errors = []
@ -1959,6 +2005,8 @@ class GithubConnection(BaseConnection):
completed_at=completed_at, completed_at=completed_at,
output=output, output=output,
details_url=details_url, details_url=details_url,
external_id=external_id,
actions=actions,
) )
except github3.exceptions.GitHubException as exc: except github3.exceptions.GitHubException as exc:
# TODO (felix): Should we retry the check_run creation? # TODO (felix): Should we retry the check_run creation?
@ -1986,6 +2034,8 @@ class GithubConnection(BaseConnection):
completed_at=completed_at, completed_at=completed_at,
output=output, output=output,
details_url=details_url, details_url=details_url,
external_id=external_id,
actions=actions,
) )
except github3.exceptions.GitHubException as exc: except github3.exceptions.GitHubException as exc:
log.error( log.error(
@ -2000,6 +2050,19 @@ class GithubConnection(BaseConnection):
) )
else: else:
# Add an abort/dequeue action to running check runs
actions.append(
{
"label": "Abort",
"description": "Abort this check run",
# Usually Github wants us to provide an identifier for our
# system here, so we can identify this action. But as zuul
# is already identifying this event based on the check run
# this shouldn't be necessary.
"identifier": "abort",
}
)
# Report the start of a check run # Report the start of a check run
try: try:
check_run = repository.create_check_run( check_run = repository.create_check_run(
@ -2008,6 +2071,8 @@ class GithubConnection(BaseConnection):
status=status, status=status,
output=output, output=output,
details_url=details_url, details_url=details_url,
external_id=external_id,
actions=actions,
) )
except github3.exceptions.GitHubException as exc: except github3.exceptions.GitHubException as exc:
# TODO (felix): Should we retry the check run creation? # TODO (felix): Should we retry the check run creation?

View File

@ -12,6 +12,7 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import json
import logging import logging
import voluptuous as v import voluptuous as v
import time import time
@ -234,6 +235,21 @@ class GithubReporter(BaseReporter):
# Check for inline comments that can be reported via checks API # Check for inline comments that can be reported via checks API
file_comments = self.getFileComments(item) file_comments = self.getFileComments(item)
# Github allows an external id to be added to a check run. We can use
# this to identify the check run in any custom actions we define.
# To uniquely identify the corresponding buildset in zuul, we need
# tenant, pipeline and change. The buildset's uuid cannot be used
# safely, as it might change e.g. during a gate reset. Fore more
# information, please see Jim's comment on
# https://review.opendev.org/#/c/666258/7
external_id = json.dumps(
{
"tenant": item.pipeline.tenant.name,
"pipeline": item.pipeline.name,
"change": item.change.number,
}
)
return self.connection.updateCheck( return self.connection.updateCheck(
project, project,
pr_number, pr_number,
@ -244,6 +260,7 @@ class GithubReporter(BaseReporter):
details_url, details_url,
message, message,
file_comments, file_comments,
external_id,
zuul_event_id=item.event, zuul_event_id=item.event,
) )

View File

@ -40,7 +40,7 @@ from zuul.lib.logutil import get_annotated_logger
from zuul.lib.statsd import get_statsd from zuul.lib.statsd import get_statsd
import zuul.lib.queue import zuul.lib.queue
import zuul.lib.repl import zuul.lib.repl
from zuul.model import Build, HoldRequest, Tenant from zuul.model import Build, HoldRequest, Tenant, TriggerEvent
COMMANDS = ['full-reconfigure', 'smart-reconfigure', 'stop', 'repl', 'norepl'] COMMANDS = ['full-reconfigure', 'smart-reconfigure', 'stop', 'repl', 'norepl']
@ -456,9 +456,33 @@ class Scheduler(threading.Thread):
self.statsd.gauge('zuul.executors.jobs_queued', execute_queue) self.statsd.gauge('zuul.executors.jobs_queued', execute_queue)
def addEvent(self, event): def addEvent(self, event):
# Check the event type and put it in the corresponding queue
if isinstance(event, TriggerEvent):
return self._addTriggerEvent(event)
if isinstance(event, ManagementEvent):
return self._addManagementEvent(event)
if isinstance(event, ResultEvent):
return self._addResultEvent(event)
self.log.warning(
"Unable to found appropriate queue for event %s", event
)
def _addTriggerEvent(self, event):
self.trigger_event_queue.put(event) self.trigger_event_queue.put(event)
self.wake_event.set() self.wake_event.set()
def _addManagementEvent(self, event):
self.management_event_queue.put(event)
self.wake_event.set()
event.wait()
def _addResultEvent(self, event):
self.result_event_queue.put(event)
self.wake_event.set()
def onBuildStarted(self, build): def onBuildStarted(self, build):
build.start_time = time.time() build.start_time = time.time()
event = BuildStartedEvent(build) event = BuildStartedEvent(build)