336 lines
13 KiB
Python
336 lines
13 KiB
Python
# Copyright 2015 Puppet Labs
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
# not use this file except in compliance with the License. You may obtain
|
|
# a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
import json
|
|
import logging
|
|
import voluptuous as v
|
|
import time
|
|
|
|
from zuul.lib.logutil import get_annotated_logger
|
|
from zuul.model import MERGER_MERGE_RESOLVE, MERGER_MERGE, MERGER_MAP, \
|
|
MERGER_SQUASH_MERGE
|
|
from zuul.reporter import BaseReporter
|
|
from zuul.exceptions import MergeFailure
|
|
from zuul.driver.util import scalar_or_list
|
|
from zuul.driver.github.githubsource import GithubSource
|
|
|
|
|
|
class GithubReporter(BaseReporter):
|
|
"""Sends off reports to Github."""
|
|
|
|
name = 'github'
|
|
log = logging.getLogger("zuul.GithubReporter")
|
|
|
|
# Merge modes supported by github
|
|
merge_modes = {
|
|
MERGER_MERGE: 'merge',
|
|
MERGER_MERGE_RESOLVE: 'merge',
|
|
MERGER_SQUASH_MERGE: 'squash',
|
|
}
|
|
|
|
def __init__(self, driver, connection, pipeline, config=None):
|
|
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):
|
|
self._labels = [self._labels]
|
|
self._unlabels = self.config.get('unlabel', [])
|
|
self._review = self.config.get('review')
|
|
self._review_body = self.config.get('review-body')
|
|
if not isinstance(self._unlabels, list):
|
|
self._unlabels = [self._unlabels]
|
|
self.context = "{}/{}".format(pipeline.tenant.name, pipeline.name)
|
|
|
|
def report(self, item):
|
|
"""Report on an event."""
|
|
# If the source is not GithubSource we cannot report anything here.
|
|
if not isinstance(item.change.project.source, GithubSource):
|
|
return
|
|
|
|
# For supporting several Github connections we also must filter by
|
|
# the canonical hostname.
|
|
if item.change.project.source.connection.canonical_hostname != \
|
|
self.connection.canonical_hostname:
|
|
return
|
|
|
|
# order is important for github branch protection.
|
|
# A status should be set before a merge attempt
|
|
if self._commit_status is not None:
|
|
if (hasattr(item.change, 'patchset') and
|
|
item.change.patchset is not None):
|
|
self.setCommitStatus(item)
|
|
elif (hasattr(item.change, 'newrev') and
|
|
item.change.newrev is not None):
|
|
self.setCommitStatus(item)
|
|
# 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'):
|
|
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):
|
|
try:
|
|
self.mergePull(item)
|
|
except Exception as e:
|
|
self.addPullComment(item, str(e))
|
|
|
|
def _formatItemReportJobs(self, item):
|
|
# Return the list of jobs portion of the report
|
|
ret = ''
|
|
jobs_fields = self._getItemReportJobsFields(item)
|
|
for job_fields in jobs_fields:
|
|
ret += '- [%s](%s) : %s%s%s%s\n' % job_fields
|
|
return ret
|
|
|
|
def addPullComment(self, item, comment=None):
|
|
log = get_annotated_logger(self.log, item.event)
|
|
message = comment or self._formatItemReport(item)
|
|
project = item.change.project.name
|
|
pr_number = item.change.number
|
|
log.debug('Reporting change %s, params %s, message: %s',
|
|
item.change, self.config, message)
|
|
self.connection.commentPull(project, pr_number, message,
|
|
zuul_event_id=item.event)
|
|
|
|
def setCommitStatus(self, item):
|
|
log = get_annotated_logger(self.log, item.event)
|
|
|
|
project = item.change.project.name
|
|
if hasattr(item.change, 'patchset'):
|
|
sha = item.change.patchset
|
|
elif hasattr(item.change, 'newrev'):
|
|
sha = item.change.newrev
|
|
state = self._commit_status
|
|
|
|
url = item.formatStatusUrl()
|
|
|
|
description = '%s status: %s' % (item.pipeline.name,
|
|
self._commit_status)
|
|
|
|
if len(description) >= 140:
|
|
# This pipeline is named with a long name and thus this
|
|
# desciption would overflow the GitHub limit of 1024 bytes.
|
|
# Truncate the description. In practice, anything over 140
|
|
# characters seems to trip the limit.
|
|
description = 'status: %s' % self._commit_status
|
|
|
|
log.debug(
|
|
'Reporting change %s, params %s, '
|
|
'context: %s, state: %s, description: %s, url: %s',
|
|
item.change, self.config, self.context, state, description, url)
|
|
|
|
self.connection.setCommitStatus(
|
|
project, sha, state, url, description, self.context,
|
|
zuul_event_id=item.event)
|
|
|
|
def mergePull(self, item):
|
|
log = get_annotated_logger(self.log, item.event)
|
|
merge_mode = item.current_build_set.getMergeMode()
|
|
|
|
if merge_mode not in self.merge_modes:
|
|
mode = [x[0] for x in MERGER_MAP.items() if x[1] == merge_mode][0]
|
|
self.log.warning('Merge mode %s not supported by Github', mode)
|
|
raise MergeFailure('Merge mode %s not supported by Github' % mode)
|
|
|
|
merge_mode = self.merge_modes[merge_mode]
|
|
project = item.change.project.name
|
|
pr_number = item.change.number
|
|
sha = item.change.patchset
|
|
log.debug('Reporting change %s, params %s, merging via API',
|
|
item.change, self.config)
|
|
message = self._formatMergeMessage(item.change)
|
|
|
|
for i in [1, 2]:
|
|
try:
|
|
self.connection.mergePull(project, pr_number, message, sha=sha,
|
|
method=merge_mode,
|
|
zuul_event_id=item.event)
|
|
item.change.is_merged = True
|
|
return
|
|
except MergeFailure as e:
|
|
log.exception('Merge attempt of change %s %s/2 failed.',
|
|
item.change, i, exc_info=True)
|
|
error_message = str(e)
|
|
if i == 1:
|
|
time.sleep(2)
|
|
log.warning('Merge of change %s failed after 2 attempts, giving up',
|
|
item.change)
|
|
raise MergeFailure(error_message)
|
|
|
|
def addReview(self, item):
|
|
log = get_annotated_logger(self.log, item.event)
|
|
project = item.change.project.name
|
|
pr_number = item.change.number
|
|
sha = item.change.patchset
|
|
log.debug('Reporting change %s, params %s, review:\n%s',
|
|
item.change, self.config, self._review)
|
|
self.connection.reviewPull(
|
|
project,
|
|
pr_number,
|
|
sha,
|
|
self._review,
|
|
self._review_body,
|
|
zuul_event_id=item.event)
|
|
for label in self._unlabels:
|
|
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
|
|
|
|
status = self._check
|
|
# We declare a item as completed if it either has a result
|
|
# (success|failure) or a dequeue reporter is called (cancelled in case
|
|
# of Github checks API). For the latter one, the item might or might
|
|
# not have a result, but we still must set a conclusion on the check
|
|
# run. Thus, we cannot rely on the buildset's result only, but also
|
|
# check the state the reporter is going to report.
|
|
completed = (
|
|
item.current_build_set.result is not None or status == "cancelled"
|
|
)
|
|
|
|
log.debug(
|
|
"Updating check for change %s, params %s, context %s, message: %s",
|
|
item.change, self.config, self.context, message
|
|
)
|
|
|
|
details_url = item.formatStatusUrl()
|
|
|
|
# Check for inline comments that can be reported via checks API
|
|
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,
|
|
}
|
|
)
|
|
|
|
state = item.dynamic_state[self.connection.connection_name]
|
|
check_run_id, errors = self.connection.updateCheck(
|
|
project,
|
|
pr_number,
|
|
sha,
|
|
status,
|
|
completed,
|
|
self.context,
|
|
details_url,
|
|
message,
|
|
file_comments,
|
|
external_id,
|
|
zuul_event_id=item.event,
|
|
check_run_id=state.get('check_run_id')
|
|
)
|
|
|
|
if check_run_id:
|
|
state['check_run_id'] = check_run_id
|
|
|
|
return errors
|
|
|
|
def setLabels(self, item):
|
|
log = get_annotated_logger(self.log, item.event)
|
|
project = item.change.project.name
|
|
pr_number = item.change.number
|
|
if self._labels:
|
|
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,
|
|
zuul_event_id=item.event)
|
|
if self._unlabels:
|
|
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,
|
|
zuul_event_id=item.event)
|
|
|
|
def _formatMergeMessage(self, change):
|
|
message = []
|
|
if change.title:
|
|
message.append(change.title)
|
|
if change.body_text:
|
|
message.append(change.body_text)
|
|
merge_message = "\n\n".join(message)
|
|
|
|
if change.reviews:
|
|
review_users = []
|
|
for r in change.reviews:
|
|
name = r['by']['name']
|
|
email = r['by']['email']
|
|
review_users.append('Reviewed-by: {} <{}>'.format(name, email))
|
|
merge_message += '\n\n'
|
|
merge_message += '\n'.join(review_users)
|
|
|
|
return merge_message
|
|
|
|
def getSubmitAllowNeeds(self):
|
|
"""Get a list of code review labels that are allowed to be
|
|
"needed" in the submit records for a change, with respect
|
|
to this queue. In other words, the list of review labels
|
|
this reporter itself is likely to set before submitting.
|
|
"""
|
|
|
|
# check if we report a status or a check, if not we can return an
|
|
# empty list
|
|
status = self.config.get('status')
|
|
check = self.config.get("check")
|
|
if not any([status, check]):
|
|
return []
|
|
|
|
# we return a status so return the status we report to github
|
|
return [self.context]
|
|
|
|
|
|
def getSchema():
|
|
github_reporter = v.Schema({
|
|
'status': v.Any('pending', 'success', 'failure'),
|
|
'status-url': str,
|
|
'comment': bool,
|
|
'merge': bool,
|
|
'label': scalar_or_list(str),
|
|
'unlabel': scalar_or_list(str),
|
|
'review': v.Any('approve', 'request-changes', 'comment'),
|
|
'review-body': str,
|
|
'check': v.Any("in_progress", "success", "failure", "cancelled"),
|
|
})
|
|
return github_reporter
|