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
This commit is contained in:
James E. Blair 2024-09-24 14:46:11 -07:00
parent 5f784ff7dc
commit c4c13b1ff4
5 changed files with 119 additions and 38 deletions

View File

@ -4143,6 +4143,43 @@ class TestGerritCircularDependencies(ZuulTestCase):
dict(name="project-job", result="ABORTED", changes="1,1 2,1"),
], ordered=False)
def test_circular_dependency_config_error(self):
# Use an error that does not leave a file comment to better
# exercise the change matching function.
use_job = textwrap.dedent(
"""
- foo:
project:
""")
A = self.fake_gerrit.addFakeChange("org/project", "master", "A")
B = self.fake_gerrit.addFakeChange("org/project1", "master", "B",
files={"zuul.yaml": use_job})
# A <-> B (via commit-depends)
A.data["commitMessage"] = "{}\n\nDepends-On: {}\n".format(
A.subject, B.data["url"]
)
B.data["commitMessage"] = "{}\n\nDepends-On: {}\n".format(
B.subject, A.data["url"]
)
self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
self.assertHistory([])
self.assertEqual(len(A.patchsets[-1]["approvals"]), 1)
self.assertEqual(A.patchsets[-1]["approvals"][0]["type"], "Verified")
self.assertEqual(A.patchsets[-1]["approvals"][0]["value"], "-1")
self.assertEqual(len(B.patchsets[-1]["approvals"]), 1)
self.assertEqual(B.patchsets[-1]["approvals"][0]["type"], "Verified")
self.assertEqual(B.patchsets[-1]["approvals"][0]["value"], "-1")
# Only report the error message to B
self.assertNotIn("more than one key", A.messages[-1])
self.assertIn("more than one key", B.messages[-1])
# Indicate that B has an error
self.assertIn("/2 (config error)", A.messages[-1])
self.assertIn("/2 (config error)", B.messages[-1])
class TestGithubCircularDependencies(ZuulTestCase):
config_file = "zuul-gerrit-github.conf"

View File

@ -69,7 +69,7 @@ class GerritReporter(BaseReporter):
comments = self.getFileComments(item, change)
if self._create_comment:
message = self._formatItemReport(item)
message = self._formatItemReport(item, change=change)
else:
message = ''

View File

@ -76,7 +76,7 @@ class GitlabReporter(BaseReporter):
if phase2 and self._merge:
self.mergeMR(item, change)
if not change.is_merged:
msg = self._formatItemReportMergeConflict(item)
msg = self._formatItemReportMergeConflict(item, change)
self.addMRComment(item, change, msg)
def addMRComment(self, item, change, comment=None):

View File

@ -80,7 +80,7 @@ class PagureReporter(BaseReporter):
if phase2 and self._merge:
self.mergePull(item, change)
if not change.is_merged:
msg = self._formatItemReportMergeConflict(item)
msg = self._formatItemReportMergeConflict(item, change)
self.addPullComment(item, change, msg)
def _formatItemReportJobs(self, item):

View File

@ -60,6 +60,19 @@ class BaseReporter(object, metaclass=abc.ABCMeta):
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.
@ -75,17 +88,12 @@ class BaseReporter(object, metaclass=abc.ABCMeta):
for err in item.getConfigErrors():
context = err.key.context
mark = err.key.mark
if not (context and mark and err.short_error):
continue
if context.project_canonical_name != \
change.project.canonical_name:
continue
if not hasattr(change, 'branch'):
continue
if context.branch != change.branch:
if not self._configErrorMatchesChange(err, change):
continue
if not (mark and err.short_error):
return False
if context.path not in change.files:
continue
return False
existing_comments = comments.setdefault(context.path, [])
existing_comments.append(dict(line=mark.end_line,
message=err.short_error,
@ -147,11 +155,12 @@ class BaseReporter(object, metaclass=abc.ABCMeta):
}
return format_methods[action]
def _formatItemReport(self, item, with_jobs=True, action=None):
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, with_jobs)
ret = self._getFormatter(action)(item, change, with_jobs)
config_warnings = item.getConfigErrors(errors=False, warnings=True)
if config_warnings:
@ -170,7 +179,7 @@ class BaseReporter(object, metaclass=abc.ABCMeta):
return ret
def _formatItemReportEnqueue(self, item, with_jobs=True):
def _formatItemReportEnqueue(self, item, change, with_jobs=True):
if status_url := self.connection.sched.globals.web_status_url:
status_url = item.formatUrlPattern(status_url)
@ -182,7 +191,7 @@ class BaseReporter(object, metaclass=abc.ABCMeta):
item_url=item.formatItemUrl(),
status_url=status_url)
def _formatItemReportStart(self, item, with_jobs=True):
def _formatItemReportStart(self, item, change, with_jobs=True):
if status_url := self.connection.sched.globals.web_status_url:
status_url = item.formatUrlPattern(status_url)
@ -194,7 +203,7 @@ class BaseReporter(object, metaclass=abc.ABCMeta):
item_url=item.formatItemUrl(),
status_url=status_url)
def _formatItemReportSuccess(self, item, with_jobs=True):
def _formatItemReportSuccess(self, item, change, with_jobs=True):
msg = item.pipeline.success_message
if with_jobs:
item_url = item.formatItemUrl()
@ -203,24 +212,43 @@ class BaseReporter(object, metaclass=abc.ABCMeta):
msg += '\n\n' + self._formatItemReportJobs(item)
return msg
def _formatItemReportFailure(self, item, with_jobs=True):
def _formatItemReportFailure(self, item, change, with_jobs=True):
if len(item.changes) > 1:
change_text = 'These changes'
_this_change = 'These changes'
_depends = 'depend'
_is = 'are'
else:
change_text = 'This change'
_this_change = 'This change'
_depends = 'depends'
_is = 'is'
if item.dequeued_needing_change:
msg = f'{change_text} depends on a change that failed to merge.\n'
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'{change_text} is unable to merge '
msg = (f'{_this_change} {_is} unable to merge '
'due to a missing merge requirement.\n')
elif len(item.changes) > 1:
msg = f'{change_text} is part of a dependency cycle that failed.\n'
# 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\n{}'.format(msg, self._formatItemReportJobs(item))
msg = "{}\n\n{}".format(
msg, self._formatItemReportOtherChanges(item))
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:
@ -235,20 +263,33 @@ class BaseReporter(object, metaclass=abc.ABCMeta):
msg += '\n\n' + self._formatItemReportJobs(item)
return msg
def _formatItemReportMergeConflict(self, item, with_jobs=True):
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, with_jobs=True):
def _formatItemReportMergeFailure(self, item, change, with_jobs=True):
return 'This change was not merged by the code review system.\n'
def _formatItemReportConfigError(self, item, with_jobs=True):
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, with_jobs=True):
def _formatItemReportNoJobs(self, item, change, with_jobs=True):
if status_url := self.connection.sched.globals.web_status_url:
status_url = item.formatUrlPattern(status_url)
@ -260,23 +301,26 @@ class BaseReporter(object, metaclass=abc.ABCMeta):
item_url=item.formatItemUrl(),
status_url=status_url)
def _formatItemReportDisabled(self, item, with_jobs=True):
def _formatItemReportDisabled(self, item, change, with_jobs=True):
if item.current_build_set.result == 'SUCCESS':
return self._formatItemReportSuccess(item)
return self._formatItemReportSuccess(item, change)
elif item.current_build_set.result == 'FAILURE':
return self._formatItemReportFailure(item)
return self._formatItemReportFailure(item, change)
else:
return self._formatItemReport(item)
return self._formatItemReport(item, change)
def _formatItemReportDequeue(self, item, with_jobs=True):
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):
return "Related changes:\n{}\n".format("\n".join(
f' - {c.url}' for c in item.changes))
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