Files
zuul/zuul/reporter/__init__.py
T
James E. Blair c4c13b1ff4 Provide config error information for dependency cycles
When a dependency cycle causes a config error and reports, we omit information
about the error itself since the same message is used for all changes in the
cycle, and it may be confusing to leave a config error message on a change that
is not directly responsible for the error.

However, this leaves something to be desired in that the user must then
closely examine each change, or look for line comments, to determine the source
of the error.

To improve this, we will now generate the reporting message with the actual
change that is being reported in mind.  This will allow us to leave an extended
message on the change that directly caused the error, and an abbreviated one on
the other changes in the cycle.

To further help users get to the right change, we will also annotate the list
of changes in the cycle to indicate which ones have config errors.

The result will look something like this for a change in a cycle
without a config error (this report has no file comments):

    This change is part of a dependency cycle that failed.

    Related changes:
      - http://localhost:55777/1
      - http://localhost:55777/2 (config error)

And like this for the other change with the error (this report
would have file comments if any were generated, but not in the
 case of this specific error):

    Zuul encountered a syntax error while parsing its
    configuration in the repo org/project1 on branch master.  The
    problem was:

      Configuration item has more than one key.  Each zuul.yaml
      configuration file must be a list of dictionaries with a
      single key, for example:

      - job:
          name: foo

      - project:
          name: bar

      Ensure that every item in the list is a dictionary with only
      one key (in this example, 'job' and 'project').  This error
      may be caused by insufficient indentation of the keys under
      the configuration item ('name' in this example).

      The incorrect values are around:

      foo: null
      project: null

    Related changes:
      - http://localhost:55777/1
      - http://localhost:55777/2 (config error)

Change-Id: Ib1804786406dd841533cc3fab31af37ec3449b69
2024-10-14 15:55:19 -07:00

394 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 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