# 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 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_pattern = self.config.get('status-url') if not url_pattern: sched_config = self.connection.sched.config if sched_config.has_option('web', 'status_url'): url_pattern = sched_config.get('web', 'status_url') url = item.formatUrlPattern(url_pattern) if url_pattern else '' 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) return self.connection.updateCheck( project, pr_number, sha, status, completed, self.context, details_url, message, file_comments, zuul_event_id=item.event, ) 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 += change.title account = getattr(change.source_event, 'account', None) if not account: return message name = account['name'] email = account['email'] message += '\n\nReviewed-by: ' if name: message += name if email: if name: message += ' ' message += '<' + email + '>' if name or email: message += '\n ' message += account['html_url'] return 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