# 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 import model from zuul.lib.logutil import get_annotated_logger 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 = { model.MERGER_MERGE: 'merge', model.MERGER_MERGE_RESOLVE: 'merge', model.MERGER_SQUASH_MERGE: 'squash', model.MERGER_REBASE: 'rebase', } 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, phase1=True, phase2=True): """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 phase1 and 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 phase1: 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 phase2 and self._merge: try: self.mergePull(item) except Exception as e: self.addPullComment(item, str(e)) def _formatJobResult(self, job_fields): # We select different emojis to represents build results: # heavy_check_mark: SUCCESS # warning: SKIPPED/ABORTED # x: all types of FAILUREs # In addition, failure results are in bold text job_result = job_fields[2] # Also need to handle user defined success_message. # The job_fields[6]: the user defined seccess_message (if available) success_message = job_fields[6] emoji = 'x' bold_result = True if job_result in ('SUCCESS', success_message): emoji = 'heavy_check_mark' bold_result = False elif job_result in ('SKIPPED', 'ABORTED', 'CANCELED'): emoji = 'warning' bold_result = False if bold_result: return ':%s: [%s](%s) **%s**%s%s%s\n' % ( (emoji,) + job_fields[:6]) else: return ':%s: [%s](%s) %s%s%s%s\n' % ( (emoji,) + job_fields[:6]) def _formatItemReportJobs(self, item): # Return the list of jobs portion of the report ret = '' jobs_fields, skipped = self._getItemReportJobsFields(item) for job_fields in jobs_fields: ret += self._formatJobResult(job_fields) if skipped: jobtext = 'job' if skipped == 1 else 'jobs' ret += 'Skipped %i %s\n' % (skipped, jobtext) 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 = model.get_merge_mode_name(merge_mode) 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) self.connection.updateChangeAttributes(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" or status == "skipped" or status == "neutral" ) 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'] username = r['by']['username'] review_message = 'Reviewed-by:' if name: review_message += ' {}'.format(name) elif username: review_message += ' {}'.format(username) else: review_message += ' Anonymous' if email: review_message += ' <{}>'.format(email) review_users.append(review_message) 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", "skipped", "neutral"), }) return github_reporter