123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354 |
- # Copyright 2016 The Chromium Authors. All rights reserved.
- # Use of this source code is governed by a BSD-style license that can be
- # found in the LICENSE file.
- from recipe_engine import recipe_api
- from PB.recipe_engine import result as result_pb2
- from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
- from PB.go.chromium.org.luci.common.proto.findings import findings as findings_pb
- # 8 minutes seems like a reasonable upper bound on presubmit timings.
- # According to event mon data we have, it seems like anything longer than
- # this is a bug, and should just instant fail.
- _DEFAULT_TIMEOUT_S = 480
- class PresubmitApi(recipe_api.RecipeApi):
- def __init__(self, properties, **kwargs):
- super(PresubmitApi, self).__init__(**kwargs)
- self._runhooks = properties.runhooks
- self._timeout_s = properties.timeout_s or _DEFAULT_TIMEOUT_S
- @property
- def presubmit_support_path(self):
- return self.repo_resource('presubmit_support.py')
- def __call__(self, *args, **kwargs):
- """Returns a presubmit step."""
- name = kwargs.pop('name', 'presubmit')
- with self.m.depot_tools.on_path():
- cmd = ['vpython3', self.presubmit_support_path]
- cmd.extend(args)
- cmd.extend(['--json_output', self.m.json.output()])
- if self.m.resultdb.enabled:
- kwargs['wrapper'] = ('rdb', 'stream', '--')
- return self.m.step(name, cmd, **kwargs)
- @property
- def _relative_root(self):
- if self.m.tryserver.is_tryserver:
- return self.m.gclient.get_gerrit_patch_root().rstrip('/')
- else:
- return self.m.gclient.c.solutions[0].name.rstrip('/')
- def prepare(self, root_solution_revision=None):
- """Sets up a presubmit run.
- This includes:
- - setting up the checkout w/ bot_update
- - locally committing the applied patch
- - running hooks, if requested
- This expects the gclient configuration to already have been set.
- Args:
- root_solution_revision: revision of the root solution
- Returns:
- the StepResult from the bot_update step.
- """
- # Set up the root solution revision by either passing the revision
- # to this function or adding it to the input properties.
- root_solution_revision = (
- root_solution_revision or
- self.m.properties.get('root_solution_revision'))
- # Expect callers to have already set up their gclient configuration.
- bot_update_step = self.m.bot_update.ensure_checkout(
- timeout=3600,
- no_fetch_tags=True,
- root_solution_revision=root_solution_revision)
- abs_root = self.m.context.cwd / self._relative_root
- if self.m.tryserver.is_tryserver:
- with self.m.context(cwd=abs_root):
- # TODO(unowned): Consider either:
- # - extracting user name & email address from the issue, or
- # - using a dedicated and clearly nonexistent name/email address
- step_result = self.m.git(
- '-c',
- 'user.email=commit-bot@chromium.org',
- '-c',
- 'user.name=The Commit Bot',
- '-c',
- 'diff.ignoreSubmodules=all',
- 'commit',
- '-a',
- '-m',
- 'Committed patch',
- name='commit-git-patch',
- raise_on_failure=False,
- stdout=self.m.raw_io.output_text('stdout',
- add_output_log='on_failure'),
- infra_step=False,
- )
- if step_result.retcode:
- failure_md_lines = ['Failed to apply patch.']
- if step_result.stdout:
- failure_md_lines += step_result.stdout.splitlines() + ['']
- if 'nothing to commit' in step_result.stdout:
- failure_md_lines.append(
- 'Was an identical diff already submitted elsewhere?')
- raise self.m.step.StepFailure('<br/>'.join(failure_md_lines))
- if self._runhooks:
- with self.m.context(cwd=self.m.path.checkout_dir):
- self.m.gclient.runhooks()
- return bot_update_step
- def execute(self, bot_update_step, skip_owners=False, run_all=False):
- """Runs presubmit and sets summary markdown if applicable.
- Also uploads the presubmit results as findings if the results contain
- location data.
- Args:
- * bot_update_step: the StepResult from a previously executed bot_update step.
- * skip_owners: a boolean indicating whether Owners checks should be skipped.
- Returns:
- a RawResult object, suitable for being returned from RunSteps.
- """
- abs_root = self.m.context.cwd / self._relative_root
- got_revision_properties = self.m.bot_update.get_project_revision_properties(
- # Replace path.sep with '/', since most recipes are written assuming '/'
- # as the delimiter. This breaks on windows otherwise.
- self._relative_root.replace(self.m.path.sep, '/'),
- self.m.gclient.c)
- upstream = bot_update_step.properties.get(got_revision_properties[0])
- presubmit_args = []
- if self.m.tryserver.is_tryserver:
- presubmit_args = [
- '--issue',
- self.m.tryserver.gerrit_change.change,
- '--patchset',
- self.m.tryserver.gerrit_change.patchset,
- '--gerrit_url',
- 'https://%s' % self.m.tryserver.gerrit_change.host,
- '--gerrit_project',
- self.m.tryserver.gerrit_change.project,
- '--gerrit_branch',
- self.m.tryserver.gerrit_change_target_ref,
- '--gerrit_fetch',
- ]
- if run_all:
- presubmit_args.extend([
- '--all', '--no_diffs',
- '--verbose'
- ])
- if self.m.cv.active and self.m.cv.run_mode == self.m.cv.DRY_RUN:
- presubmit_args.append('--dry_run')
- additionalArgs = ['--root', abs_root,'--commit']
- if not run_all:
- additionalArgs.extend([
- '--verbose', '--verbose',
- ])
- additionalArgs.extend([
- '--skip_canned', 'CheckTreeIsOpen',
- '--upstream', upstream, # '' if not in bot_update mode.
- ])
- presubmit_args.extend(additionalArgs)
- if skip_owners:
- presubmit_args.extend([
- '--skip_canned', 'CheckOwners'
- ])
- raw_result = result_pb2.RawResult()
- presubmit_step = self(
- *presubmit_args,
- timeout=self._timeout_s,
- # ok_ret='any' causes all exceptions to be ignored in this step
- ok_ret='any')
- if presubmit_step.exc_result.retcode != 0:
- presubmit_step.presentation.status = 'FAILURE'
- # Set recipe result values and upload findings
- if (step_json := presubmit_step.json.output):
- raw_result.summary_markdown = _createSummaryMarkdown(step_json)
- if self.m.tryserver.is_tryserver:
- self._upload_findings_from_result(step_json)
- if presubmit_step.exc_result.retcode == 0:
- raw_result.status = common_pb2.SUCCESS
- return raw_result
- elif presubmit_step.exc_result.had_timeout:
- raw_result.status = common_pb2.FAILURE
- raw_result.summary_markdown += (
- '\n\nTimeout occurred during presubmit step.')
- elif presubmit_step.exc_result.retcode == 1:
- raw_result.status = common_pb2.FAILURE
- self.m.tryserver.set_test_failure_tryjob_result()
- else:
- raw_result.status = common_pb2.INFRA_FAILURE
- self.m.tryserver.set_invalid_test_results_tryjob_result()
- # Handle unexpected errors not caught by json output
- if raw_result.summary_markdown == '':
- raw_result.status = common_pb2.INFRA_FAILURE
- raw_result.summary_markdown = (
- 'Something unexpected occurred'
- ' while running presubmit checks.'
- ' Please [file a bug](https://issues.chromium.org'
- '/issues/new?component=1456211)')
- return raw_result
- def _upload_findings_from_result(self, result_json):
- if not self.m.resultdb.enabled: # pragma: no cover
- return
- findings = []
- base_finding = findings_pb.Finding(
- category='chromium_presubmit',
- location=findings_pb.Location(
- gerrit_change_ref=findings_pb.Location.GerritChangeReference(
- host=self.m.tryserver.gerrit_change.host,
- project=self.m.tryserver.gerrit_change.project,
- change=self.m.tryserver.gerrit_change.change,
- patchset=self.m.tryserver.gerrit_change.patchset,
- ), ),
- )
- for results, level in [
- (result_json.get('errors',
- []), findings_pb.Finding.SEVERITY_LEVEL_ERROR),
- (result_json.get('warnings',
- []), findings_pb.Finding.SEVERITY_LEVEL_WARNING),
- (result_json.get('notifications',
- []), findings_pb.Finding.SEVERITY_LEVEL_INFO)
- ]:
- for result in results:
- message = result.get('message', '')
- if result.get('long_text', None):
- message += '\n\n' + result['long_text']
- for loc in result.get('locations', []):
- f = findings_pb.Finding()
- f.CopyFrom(base_finding)
- f.message = message
- f.severity_level = level
- f.location.file_path = loc['file_path'].replace(self.m.path.sep, '/')
- if loc.get('start_line', None):
- f.location.range.start_line = loc['start_line']
- f.location.range.end_line = loc['end_line']
- f.location.range.start_column = loc.get('start_col', 0)
- f.location.range.end_column = loc.get('end_col', 0)
- findings.append(f)
- if findings:
- self.m.findings.upload_findings(
- findings, step_name='upload presubmit results as findings')
- def _limitSize(message_list, char_limit=450):
- """Returns a list of strings within a certain character length.
- Args:
- * message_list (List[str]) - The message to truncate as a list
- of lines (without line endings).
- """
- hint = ('**The complete output can be'
- ' found at the bottom of the presubmit stdout.**')
- char_count = 0
- for index, message in enumerate(message_list):
- char_count += len(message)
- if char_count > char_limit:
- if index == 0:
- # Show at minimum part of the first error message
- first_message = message_list[index].splitlines()
- return ['\n'.join(
- _limitSize(first_message)
- )
- ]
- total_errors = len(message_list)
- # If code is being cropped, the closing code tag will
- # get removed, so add it back before the hint.
- code_tag = '```'
- message_list[index - 1] = '\n'.join((message_list[index - 1], code_tag))
- oversized_msg = ('\n**Error size > %d chars, '
- 'there are %d more error(s) (%d total)**') % (
- char_limit, total_errors - index, total_errors
- )
- return message_list[:index] + [oversized_msg, hint]
- return message_list
- def _createSummaryMarkdown(step_json):
- """Returns a string with data on errors, warnings, and notifications.
- Extracts the number of errors, warnings and notifications
- from the dictionary(step_json).
- Then it lists all the errors line by line.
- Args:
- * step_json = {
- 'errors': [
- {
- 'message': string,
- 'long_text': string,
- 'items: [string],
- 'fatal': boolean
- }
- ],
- 'notifications': [
- {
- 'message': string,
- 'long_text': string,
- 'items: [string],
- 'fatal': boolean
- }
- ],
- 'warnings': [
- {
- 'message': string,
- 'long_text': string,
- 'items: [string],
- 'fatal': boolean
- }
- ]
- }
- """
- errors = step_json['errors']
- warning_count = len(step_json['warnings'])
- notif_count = len(step_json['notifications'])
- description = (
- f'#### There are {len(errors)} error(s), {warning_count} warning(s), '
- f'and {notif_count} notifications(s).')
- error_messages = []
- for error in errors:
- error_messages.append(
- '**ERROR**\n```\n%s\n%s\n```' % (
- error['message'], error['long_text'])
- )
- error_messages = _limitSize(error_messages)
- # Description is not counted in the total message size.
- # It is inserted afterward to ensure it is the first message seen.
- error_messages.insert(0, description)
- if warning_count or notif_count:
- error_messages.append(
- ('#### To see notifications and warnings,'
- ' look at the stdout of the presubmit step.')
- )
- return '\n\n'.join(error_messages)
|