api.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. # Copyright 2016 The Chromium Authors. All rights reserved.
  2. # Use of this source code is governed by a BSD-style license that can be
  3. # found in the LICENSE file.
  4. from recipe_engine import recipe_api
  5. from PB.recipe_engine import result as result_pb2
  6. from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
  7. from PB.go.chromium.org.luci.common.proto.findings import findings as findings_pb
  8. # 8 minutes seems like a reasonable upper bound on presubmit timings.
  9. # According to event mon data we have, it seems like anything longer than
  10. # this is a bug, and should just instant fail.
  11. _DEFAULT_TIMEOUT_S = 480
  12. class PresubmitApi(recipe_api.RecipeApi):
  13. def __init__(self, properties, **kwargs):
  14. super(PresubmitApi, self).__init__(**kwargs)
  15. self._runhooks = properties.runhooks
  16. self._timeout_s = properties.timeout_s or _DEFAULT_TIMEOUT_S
  17. @property
  18. def presubmit_support_path(self):
  19. return self.repo_resource('presubmit_support.py')
  20. def __call__(self, *args, **kwargs):
  21. """Returns a presubmit step."""
  22. name = kwargs.pop('name', 'presubmit')
  23. with self.m.depot_tools.on_path():
  24. cmd = ['vpython3', self.presubmit_support_path]
  25. cmd.extend(args)
  26. cmd.extend(['--json_output', self.m.json.output()])
  27. if self.m.resultdb.enabled:
  28. kwargs['wrapper'] = ('rdb', 'stream', '--')
  29. return self.m.step(name, cmd, **kwargs)
  30. @property
  31. def _relative_root(self):
  32. if self.m.tryserver.is_tryserver:
  33. return self.m.gclient.get_gerrit_patch_root().rstrip('/')
  34. else:
  35. return self.m.gclient.c.solutions[0].name.rstrip('/')
  36. def prepare(self, root_solution_revision=None):
  37. """Sets up a presubmit run.
  38. This includes:
  39. - setting up the checkout w/ bot_update
  40. - locally committing the applied patch
  41. - running hooks, if requested
  42. This expects the gclient configuration to already have been set.
  43. Args:
  44. root_solution_revision: revision of the root solution
  45. Returns:
  46. the StepResult from the bot_update step.
  47. """
  48. # Set up the root solution revision by either passing the revision
  49. # to this function or adding it to the input properties.
  50. root_solution_revision = (
  51. root_solution_revision or
  52. self.m.properties.get('root_solution_revision'))
  53. # Expect callers to have already set up their gclient configuration.
  54. bot_update_step = self.m.bot_update.ensure_checkout(
  55. timeout=3600,
  56. no_fetch_tags=True,
  57. root_solution_revision=root_solution_revision)
  58. abs_root = self.m.context.cwd / self._relative_root
  59. if self.m.tryserver.is_tryserver:
  60. with self.m.context(cwd=abs_root):
  61. # TODO(unowned): Consider either:
  62. # - extracting user name & email address from the issue, or
  63. # - using a dedicated and clearly nonexistent name/email address
  64. step_result = self.m.git(
  65. '-c',
  66. 'user.email=commit-bot@chromium.org',
  67. '-c',
  68. 'user.name=The Commit Bot',
  69. '-c',
  70. 'diff.ignoreSubmodules=all',
  71. 'commit',
  72. '-a',
  73. '-m',
  74. 'Committed patch',
  75. name='commit-git-patch',
  76. raise_on_failure=False,
  77. stdout=self.m.raw_io.output_text('stdout',
  78. add_output_log='on_failure'),
  79. infra_step=False,
  80. )
  81. if step_result.retcode:
  82. failure_md_lines = ['Failed to apply patch.']
  83. if step_result.stdout:
  84. failure_md_lines += step_result.stdout.splitlines() + ['']
  85. if 'nothing to commit' in step_result.stdout:
  86. failure_md_lines.append(
  87. 'Was an identical diff already submitted elsewhere?')
  88. raise self.m.step.StepFailure('<br/>'.join(failure_md_lines))
  89. if self._runhooks:
  90. with self.m.context(cwd=self.m.path.checkout_dir):
  91. self.m.gclient.runhooks()
  92. return bot_update_step
  93. def execute(self, bot_update_step, skip_owners=False, run_all=False):
  94. """Runs presubmit and sets summary markdown if applicable.
  95. Also uploads the presubmit results as findings if the results contain
  96. location data.
  97. Args:
  98. * bot_update_step: the StepResult from a previously executed bot_update step.
  99. * skip_owners: a boolean indicating whether Owners checks should be skipped.
  100. Returns:
  101. a RawResult object, suitable for being returned from RunSteps.
  102. """
  103. abs_root = self.m.context.cwd / self._relative_root
  104. got_revision_properties = self.m.bot_update.get_project_revision_properties(
  105. # Replace path.sep with '/', since most recipes are written assuming '/'
  106. # as the delimiter. This breaks on windows otherwise.
  107. self._relative_root.replace(self.m.path.sep, '/'),
  108. self.m.gclient.c)
  109. upstream = bot_update_step.properties.get(got_revision_properties[0])
  110. presubmit_args = []
  111. if self.m.tryserver.is_tryserver:
  112. presubmit_args = [
  113. '--issue',
  114. self.m.tryserver.gerrit_change.change,
  115. '--patchset',
  116. self.m.tryserver.gerrit_change.patchset,
  117. '--gerrit_url',
  118. 'https://%s' % self.m.tryserver.gerrit_change.host,
  119. '--gerrit_project',
  120. self.m.tryserver.gerrit_change.project,
  121. '--gerrit_branch',
  122. self.m.tryserver.gerrit_change_target_ref,
  123. '--gerrit_fetch',
  124. ]
  125. if run_all:
  126. presubmit_args.extend([
  127. '--all', '--no_diffs',
  128. '--verbose'
  129. ])
  130. if self.m.cv.active and self.m.cv.run_mode == self.m.cv.DRY_RUN:
  131. presubmit_args.append('--dry_run')
  132. additionalArgs = ['--root', abs_root,'--commit']
  133. if not run_all:
  134. additionalArgs.extend([
  135. '--verbose', '--verbose',
  136. ])
  137. additionalArgs.extend([
  138. '--skip_canned', 'CheckTreeIsOpen',
  139. '--upstream', upstream, # '' if not in bot_update mode.
  140. ])
  141. presubmit_args.extend(additionalArgs)
  142. if skip_owners:
  143. presubmit_args.extend([
  144. '--skip_canned', 'CheckOwners'
  145. ])
  146. raw_result = result_pb2.RawResult()
  147. presubmit_step = self(
  148. *presubmit_args,
  149. timeout=self._timeout_s,
  150. # ok_ret='any' causes all exceptions to be ignored in this step
  151. ok_ret='any')
  152. if presubmit_step.exc_result.retcode != 0:
  153. presubmit_step.presentation.status = 'FAILURE'
  154. # Set recipe result values and upload findings
  155. if (step_json := presubmit_step.json.output):
  156. raw_result.summary_markdown = _createSummaryMarkdown(step_json)
  157. if self.m.tryserver.is_tryserver:
  158. self._upload_findings_from_result(step_json)
  159. if presubmit_step.exc_result.retcode == 0:
  160. raw_result.status = common_pb2.SUCCESS
  161. return raw_result
  162. elif presubmit_step.exc_result.had_timeout:
  163. raw_result.status = common_pb2.FAILURE
  164. raw_result.summary_markdown += (
  165. '\n\nTimeout occurred during presubmit step.')
  166. elif presubmit_step.exc_result.retcode == 1:
  167. raw_result.status = common_pb2.FAILURE
  168. self.m.tryserver.set_test_failure_tryjob_result()
  169. else:
  170. raw_result.status = common_pb2.INFRA_FAILURE
  171. self.m.tryserver.set_invalid_test_results_tryjob_result()
  172. # Handle unexpected errors not caught by json output
  173. if raw_result.summary_markdown == '':
  174. raw_result.status = common_pb2.INFRA_FAILURE
  175. raw_result.summary_markdown = (
  176. 'Something unexpected occurred'
  177. ' while running presubmit checks.'
  178. ' Please [file a bug](https://issues.chromium.org'
  179. '/issues/new?component=1456211)')
  180. return raw_result
  181. def _upload_findings_from_result(self, result_json):
  182. if not self.m.resultdb.enabled: # pragma: no cover
  183. return
  184. findings = []
  185. base_finding = findings_pb.Finding(
  186. category='chromium_presubmit',
  187. location=findings_pb.Location(
  188. gerrit_change_ref=findings_pb.Location.GerritChangeReference(
  189. host=self.m.tryserver.gerrit_change.host,
  190. project=self.m.tryserver.gerrit_change.project,
  191. change=self.m.tryserver.gerrit_change.change,
  192. patchset=self.m.tryserver.gerrit_change.patchset,
  193. ), ),
  194. )
  195. for results, level in [
  196. (result_json.get('errors',
  197. []), findings_pb.Finding.SEVERITY_LEVEL_ERROR),
  198. (result_json.get('warnings',
  199. []), findings_pb.Finding.SEVERITY_LEVEL_WARNING),
  200. (result_json.get('notifications',
  201. []), findings_pb.Finding.SEVERITY_LEVEL_INFO)
  202. ]:
  203. for result in results:
  204. message = result.get('message', '')
  205. if result.get('long_text', None):
  206. message += '\n\n' + result['long_text']
  207. for loc in result.get('locations', []):
  208. f = findings_pb.Finding()
  209. f.CopyFrom(base_finding)
  210. f.message = message
  211. f.severity_level = level
  212. f.location.file_path = loc['file_path'].replace(self.m.path.sep, '/')
  213. if loc.get('start_line', None):
  214. f.location.range.start_line = loc['start_line']
  215. f.location.range.end_line = loc['end_line']
  216. f.location.range.start_column = loc.get('start_col', 0)
  217. f.location.range.end_column = loc.get('end_col', 0)
  218. findings.append(f)
  219. if findings:
  220. self.m.findings.upload_findings(
  221. findings, step_name='upload presubmit results as findings')
  222. def _limitSize(message_list, char_limit=450):
  223. """Returns a list of strings within a certain character length.
  224. Args:
  225. * message_list (List[str]) - The message to truncate as a list
  226. of lines (without line endings).
  227. """
  228. hint = ('**The complete output can be'
  229. ' found at the bottom of the presubmit stdout.**')
  230. char_count = 0
  231. for index, message in enumerate(message_list):
  232. char_count += len(message)
  233. if char_count > char_limit:
  234. if index == 0:
  235. # Show at minimum part of the first error message
  236. first_message = message_list[index].splitlines()
  237. return ['\n'.join(
  238. _limitSize(first_message)
  239. )
  240. ]
  241. total_errors = len(message_list)
  242. # If code is being cropped, the closing code tag will
  243. # get removed, so add it back before the hint.
  244. code_tag = '```'
  245. message_list[index - 1] = '\n'.join((message_list[index - 1], code_tag))
  246. oversized_msg = ('\n**Error size > %d chars, '
  247. 'there are %d more error(s) (%d total)**') % (
  248. char_limit, total_errors - index, total_errors
  249. )
  250. return message_list[:index] + [oversized_msg, hint]
  251. return message_list
  252. def _createSummaryMarkdown(step_json):
  253. """Returns a string with data on errors, warnings, and notifications.
  254. Extracts the number of errors, warnings and notifications
  255. from the dictionary(step_json).
  256. Then it lists all the errors line by line.
  257. Args:
  258. * step_json = {
  259. 'errors': [
  260. {
  261. 'message': string,
  262. 'long_text': string,
  263. 'items: [string],
  264. 'fatal': boolean
  265. }
  266. ],
  267. 'notifications': [
  268. {
  269. 'message': string,
  270. 'long_text': string,
  271. 'items: [string],
  272. 'fatal': boolean
  273. }
  274. ],
  275. 'warnings': [
  276. {
  277. 'message': string,
  278. 'long_text': string,
  279. 'items: [string],
  280. 'fatal': boolean
  281. }
  282. ]
  283. }
  284. """
  285. errors = step_json['errors']
  286. warning_count = len(step_json['warnings'])
  287. notif_count = len(step_json['notifications'])
  288. description = (
  289. f'#### There are {len(errors)} error(s), {warning_count} warning(s), '
  290. f'and {notif_count} notifications(s).')
  291. error_messages = []
  292. for error in errors:
  293. error_messages.append(
  294. '**ERROR**\n```\n%s\n%s\n```' % (
  295. error['message'], error['long_text'])
  296. )
  297. error_messages = _limitSize(error_messages)
  298. # Description is not counted in the total message size.
  299. # It is inserted afterward to ensure it is the first message seen.
  300. error_messages.insert(0, description)
  301. if warning_count or notif_count:
  302. error_messages.append(
  303. ('#### To see notifications and warnings,'
  304. ' look at the stdout of the presubmit step.')
  305. )
  306. return '\n\n'.join(error_messages)