Files
zuul/zuul/reporter/__init__.py
Joseph Kostreva 9359a6042d Add commentable_files variable to GerritChange
Gerrit will give 400 file not found in revision error on merge changes
with jobs that do gerrit inline commenting. This is due to the fact
that the file list will include the parent 1. The POST API in gerrit
'/changes/{change-id}/revisions/{revision-id}/review' only knows about
the current revision. The filter in the reporter will also fail to
exclude the appropriate files as it includes changes from parent 1.

Change-Id: I64a4879698894f2ef255d769e0cdb59fec64981f
Co-Authored-By: James E. Blair <jim@acmegating.com>
2025-02-24 10:59:05 -08:00

398 lines
16 KiB
Python

# Copyright 2014 Rackspace Australia
# Copyright 2021-2024 Acme Gating, LLC
#
# 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 abc
import logging
class BaseReporter(object, metaclass=abc.ABCMeta):
"""Base class for reporters.
Defines the exact public methods that must be supplied.
"""
log = logging.getLogger("zuul.reporter.BaseReporter")
def __init__(self, driver, connection, config=None, parse_context=None):
self.driver = driver
self.connection = connection
self.config = config or {}
self._action = None
def setAction(self, action):
self._action = action
@abc.abstractmethod
def report(self, item, phase1=True, phase2=True):
"""Send the compiled report message
Two-phase reporting may be enabled if one or the other of the
`phase1` or `phase2` arguments is False.
Phase1 should report everything except the actual merge action.
Phase2 should report only the merge action.
:arg phase1 bool: Whether to enable phase1 reporting
:arg phase2 bool: Whether to enable phase2 reporting
"""
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.
"""
return []
def postConfig(self):
"""Run tasks after configuration is reloaded"""
def _configErrorMatchesChange(self, err, change):
context = err.key.context
if not context:
return False
if context.project_canonical_name != \
change.project.canonical_name:
return False
if not hasattr(change, 'branch'):
return False
if context.branch != change.branch:
return False
return True
def addConfigurationErrorComments(self, item, change, comments):
"""Add file comments for configuration errors.
Updates the comments dictionary with additional file comments
for any relevant configuration errors for the specified change.
:arg QueueItem item: The queue item
:arg Ref change: One of the item's changes to check
:arg dict comments: a file comments dictionary
"""
for err in item.getConfigErrors():
context = err.key.context
mark = err.key.mark
if not self._configErrorMatchesChange(err, change):
continue
if not (mark and err.short_error):
return False
if context.path not in change.files:
return False
existing_comments = comments.setdefault(context.path, [])
existing_comments.append(dict(line=mark.end_line,
message=err.short_error,
range=dict(
start_line=mark.line + 1,
start_character=mark.column,
end_line=mark.end_line,
end_character=mark.end_column)))
def _getFileComments(self, item, change):
"""Get the file comments from the zuul_return value"""
ret = {}
for build in item.current_build_set.getBuilds():
fc = build.result_data.get("zuul", {}).get("file_comments")
if not fc:
continue
# Only consider comments for this change
if change.cache_key not in build.job.all_refs:
continue
for fn, comments in fc.items():
existing_comments = ret.setdefault(fn, [])
existing_comments.extend(comments)
self.addConfigurationErrorComments(item, change, ret)
return ret
def getFileComments(self, item, change):
comments = self._getFileComments(item, change)
self.filterComments(item, change, comments)
return comments
def filterComments(self, item, change, comments):
"""Filter comments for files in change
Remove any comments for files which do not appear in the
specified change. Leave warning messages if this happens.
:arg QueueItem item: The queue item
:arg Change change: The change
:arg dict comments: a file comments dictionary (modified in place)
"""
for fn in list(comments.keys()):
if change.commentable_files is not None:
if fn not in change.commentable_files:
del comments[fn]
item.warning("Comments left for invalid file %s" % (fn,))
elif fn not in change.files:
del comments[fn]
item.warning("Comments left for invalid file %s" % (fn,))
def _getFormatter(self, action):
format_methods = {
'enqueue': self._formatItemReportEnqueue,
'start': self._formatItemReportStart,
'success': self._formatItemReportSuccess,
'failure': self._formatItemReportFailure,
'merge-conflict': self._formatItemReportMergeConflict,
'merge-failure': self._formatItemReportMergeFailure,
'config-error': self._formatItemReportConfigError,
'no-jobs': self._formatItemReportNoJobs,
'disabled': self._formatItemReportDisabled,
'dequeue': self._formatItemReportDequeue,
}
return format_methods[action]
def _formatItemReport(self, item, change=None,
with_jobs=True, action=None):
"""Format a report from the given items. Usually to provide results to
a reporter taking free-form text."""
action = action or self._action
ret = self._getFormatter(action)(item, change, with_jobs)
config_warnings = item.getConfigErrors(errors=False, warnings=True)
if config_warnings:
ret += '\nWarning:\n ' + config_warnings[0].error + '\n'
if item.current_build_set.warning_messages:
warning = '\n '.join(item.current_build_set.warning_messages)
ret += '\nWarning:\n ' + warning + '\n'
if item.current_build_set.debug_messages:
debug = '\n '.join(item.current_build_set.debug_messages)
ret += '\nDebug information:\n ' + debug + '\n'
if item.pipeline.footer_message:
ret += '\n' + item.pipeline.footer_message
return ret
def _formatItemReportEnqueue(self, item, change, with_jobs=True):
if status_url := self.connection.sched.globals.web_status_url:
status_url = item.formatUrlPattern(status_url)
# change, changes, and status_url are deprecated
return item.pipeline.enqueue_message.format(
pipeline=item.pipeline.getSafeAttributes(),
change=item.changes[0].getSafeAttributes(),
changes=[c.getSafeAttributes() for c in item.changes],
item_url=item.formatItemUrl(),
status_url=status_url)
def _formatItemReportStart(self, item, change, with_jobs=True):
if status_url := self.connection.sched.globals.web_status_url:
status_url = item.formatUrlPattern(status_url)
# change, changes, and status_url are deprecated
return item.pipeline.start_message.format(
pipeline=item.pipeline.getSafeAttributes(),
change=item.changes[0].getSafeAttributes(),
changes=[c.getSafeAttributes() for c in item.changes],
item_url=item.formatItemUrl(),
status_url=status_url)
def _formatItemReportSuccess(self, item, change, with_jobs=True):
msg = item.pipeline.success_message
if with_jobs:
item_url = item.formatItemUrl()
if item_url is not None:
msg += '\n' + item_url
msg += '\n\n' + self._formatItemReportJobs(item)
return msg
def _formatItemReportFailure(self, item, change, with_jobs=True):
if len(item.changes) > 1:
_this_change = 'These changes'
_depends = 'depend'
_is = 'are'
else:
_this_change = 'This change'
_depends = 'depends'
_is = 'is'
if item.dequeued_needing_change:
msg = (f'{_this_change} {_depends} on a change '
'that failed to merge.\n')
if isinstance(item.dequeued_needing_change, str):
msg += '\n' + item.dequeued_needing_change + '\n'
elif item.dequeued_missing_requirements:
msg = (f'{_this_change} {_is} unable to merge '
'due to a missing merge requirement.\n')
elif len(item.changes) > 1:
# No plural phrasing here; we are specifically talking
# about this change as part of a cycle.
msg = ('This change is part of a dependency cycle '
'that failed.\n\n')
# Attempt to provide relevant config error information
# since we will not add it below.
changes_with_errors = self._getChangesWithErrors(item)
if change in changes_with_errors:
msg += str(item.getConfigErrors(
errors=True, warnings=False)[0].error)
change_annotations = {c: ' (config error)'
for c in changes_with_errors}
if with_jobs:
msg = '{}\n{}'.format(msg, self._formatItemReportJobs(item))
msg = "{}\n{}".format(
msg, self._formatItemReportOtherChanges(item,
change_annotations))
elif item.didMergerFail():
msg = item.pipeline.merge_conflict_message
elif item.current_build_set.has_blocking_errors:
msg = str(item.getConfigErrors(
errors=True, warnings=False)[0].error)
else:
msg = item.pipeline.failure_message
if with_jobs:
item_url = item.formatItemUrl()
if item_url is not None:
msg += '\n' + item_url
msg += '\n\n' + self._formatItemReportJobs(item)
return msg
def _getChangesWithErrors(self, item):
# Attempt to determine whether this change is the source of a
# configuration error. This is best effort.
if not item.current_build_set.has_blocking_errors:
return []
ret = []
for change in item.changes:
for err in item.getConfigErrors():
if self._configErrorMatchesChange(err, change):
ret.append(change)
return ret
def _formatItemReportMergeConflict(self, item, change, with_jobs=True):
return item.pipeline.merge_conflict_message
def _formatItemReportMergeFailure(self, item, change, with_jobs=True):
return 'This change was not merged by the code review system.\n'
def _formatItemReportConfigError(self, item, change, with_jobs=True):
if item.getConfigErrors():
msg = str(item.getConfigErrors()[0].error)
else:
msg = "Unknown configuration error"
return msg
def _formatItemReportNoJobs(self, item, change, with_jobs=True):
if status_url := self.connection.sched.globals.web_status_url:
status_url = item.formatUrlPattern(status_url)
# change, changes, and status_url are deprecated
return item.pipeline.no_jobs_message.format(
pipeline=item.pipeline.getSafeAttributes(),
change=item.changes[0].getSafeAttributes(),
changes=[c.getSafeAttributes() for c in item.changes],
item_url=item.formatItemUrl(),
status_url=status_url)
def _formatItemReportDisabled(self, item, change, with_jobs=True):
if item.current_build_set.result == 'SUCCESS':
return self._formatItemReportSuccess(item, change)
elif item.current_build_set.result == 'FAILURE':
return self._formatItemReportFailure(item, change)
else:
return self._formatItemReport(item, change)
def _formatItemReportDequeue(self, item, change, with_jobs=True):
msg = item.pipeline.dequeue_message
if with_jobs:
msg += '\n\n' + self._formatItemReportJobs(item)
return msg
def _formatItemReportOtherChanges(self, item, change_annotations):
change_lines = []
for change in item.changes:
annotation = change_annotations.get(change, '')
change_lines.append(f' - {change.url}{annotation}')
return "Related changes:\n{}\n".format("\n".join(change_lines))
def _getItemReportJobsFields(self, item):
# Extract the report elements from an item
config = self.connection.sched.config
jobs_fields = []
skipped = 0
for job in item.getJobs():
build = item.current_build_set.getBuild(job)
(result, url) = item.formatJobResult(job)
# If child_jobs is being used to skip jobs, then the user
# probably has an expectation that some jobs will be
# skipped and doesn't need to see all of them. Otherwise,
# it may be a surprise and it may be better to include the
# job in the report.
if (build.error_detail and
'Skipped due to child_jobs' in build.error_detail):
skipped += 1
continue
if not job.voting:
voting = ' (non-voting)'
else:
voting = ''
if config and config.has_option(
'zuul', 'report_times'):
report_times = config.getboolean(
'zuul', 'report_times')
else:
report_times = True
if report_times and build.end_time and build.start_time:
dt = int(build.end_time - build.start_time)
m, s = divmod(dt, 60)
h, m = divmod(m, 60)
if h:
elapsed = ' in %dh %02dm %02ds' % (h, m, s)
elif m:
elapsed = ' in %dm %02ds' % (m, s)
else:
elapsed = ' in %ds' % (s)
else:
elapsed = ''
if build.error_detail:
error = ' ' + build.error_detail
else:
error = ''
name = job.name + ' '
# TODO(mordred) The gerrit consumption interface depends on
# something existing in the url field and don't have a great
# behavior defined for url being none/missing. Put name into
# the url field to match old behavior until we can deal with
# the gerrit-side piece as well
url = url or job.name
# Pass user defined success_message deciding the build result
# during result formatting
success_message = job.success_message
jobs_fields.append(
(name, url, result, error, elapsed, voting, success_message))
return jobs_fields, skipped
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 += '- %s%s : %s%s%s%s\n' % job_fields[:6]
if skipped:
jobtext = 'job' if skipped == 1 else 'jobs'
ret += 'Skipped %i %s\n' % (skipped, jobtext)
return ret