api.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. # Copyright 2014 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. import contextlib
  5. import hashlib
  6. from recipe_engine import recipe_api
  7. class Constants:
  8. def __init__(self):
  9. self.NONTRIVIAL_ROLL_FOOTER = 'Recipe-Nontrivial-Roll'
  10. self.MANUAL_CHANGE_FOOTER = 'Recipe-Manual-Change'
  11. self.BYPASS_FOOTER = 'Recipe-Tryjob-Bypass-Reason'
  12. self.SKIP_RETRY_FOOTER = 'Disable-Retries'
  13. self.CQ_DEPEND_FOOTER = 'Cq-Depend'
  14. self.ALL_VALID_FOOTERS = set([
  15. self.NONTRIVIAL_ROLL_FOOTER, self.MANUAL_CHANGE_FOOTER,
  16. self.BYPASS_FOOTER, self.SKIP_RETRY_FOOTER, self.CQ_DEPEND_FOOTER
  17. ])
  18. constants = Constants()
  19. class TryserverApi(recipe_api.RecipeApi):
  20. def __init__(self, *args, **kwargs):
  21. super(TryserverApi, self).__init__(*args, **kwargs)
  22. self._gerrit_change = None # self.m.buildbucket.common_pb2.GerritChange
  23. self._gerrit_change_repo_url = None
  24. self._gerrit_change_repo_host = None
  25. self._gerrit_change_repo_project = None
  26. self._gerrit_info_initialized = False
  27. self._gerrit_change_target_ref = None
  28. self._gerrit_change_fetch_ref = None
  29. self._gerrit_change_owner = None
  30. self._change_footers = None
  31. self._gerrit_commit_message = None
  32. def initialize(self):
  33. changes = self.m.buildbucket.build.input.gerrit_changes
  34. if len(changes) == 1:
  35. self.set_change(changes[0])
  36. @property
  37. def valid_footers(self): #pragma: nocover
  38. return constants.ALL_VALID_FOOTERS
  39. @property
  40. def constants(self): #pragma: nocover
  41. # Nocover to be removed when callers (not within depot_tools) exercise this
  42. return constants
  43. @property
  44. def gerrit_change(self):
  45. """Returns current gerrit change, if there is exactly one.
  46. Returns a self.m.buildbucket.common_pb2.GerritChange or None.
  47. """
  48. return self._gerrit_change
  49. @property
  50. def gerrit_change_repo_url(self):
  51. """Returns canonical URL of the gitiles repo of the current Gerrit CL.
  52. Populated iff gerrit_change is populated.
  53. """
  54. return self._gerrit_change_repo_url
  55. @property
  56. def gerrit_change_repo_host(self):
  57. """Returns the host of the gitiles repo of the current Gerrit CL.
  58. Populated iff gerrit_change is populated.
  59. """
  60. return self._gerrit_change_repo_host
  61. @property
  62. def gerrit_change_repo_project(self):
  63. """Returns the project of the gitiles repo of the current Gerrit CL.
  64. Populated iff gerrit_change is populated.
  65. """
  66. return self._gerrit_change_repo_project
  67. @property
  68. def gerrit_change_owner(self):
  69. """Returns owner of the current Gerrit CL.
  70. Populated iff gerrit_change is populated.
  71. Is a dictionary with keys like "name".
  72. """
  73. self._ensure_gerrit_change_info()
  74. return self._gerrit_change_owner
  75. @property
  76. def gerrit_change_review_url(self):
  77. """Returns the review URL for the active patchset."""
  78. # Gerrit redirects to insert the project into the URL.
  79. gerrit_change = self._gerrit_change
  80. return 'https://%s/c/%s/%s' % (
  81. gerrit_change.host, gerrit_change.change, gerrit_change.patchset)
  82. def _ensure_gerrit_change_info(self):
  83. """Initializes extra info about gerrit_change, fetched from Gerrit server.
  84. Initializes _gerrit_change_target_ref and _gerrit_change_fetch_ref.
  85. May emit a step when called for the first time.
  86. """
  87. cl = self.gerrit_change
  88. if not cl: # pragma: no cover
  89. return
  90. if self._gerrit_info_initialized:
  91. return
  92. td = self._test_data if self._test_data.enabled else {}
  93. mock_res = [{
  94. 'branch': td.get('gerrit_change_target_ref', 'main'),
  95. 'revisions': {
  96. '184ebe53805e102605d11f6b143486d15c23a09c': {
  97. '_number': str(cl.patchset),
  98. 'ref': 'refs/changes/%02d/%d/%d' % (
  99. cl.change % 100, cl.change, cl.patchset),
  100. },
  101. },
  102. 'owner': {
  103. 'name': 'John Doe',
  104. },
  105. }]
  106. res = self.m.gerrit.get_changes(
  107. host='https://' + cl.host,
  108. query_params=[('change', cl.change)],
  109. # This list must remain static/hardcoded.
  110. # If you need extra info, either change it here (hardcoded) or
  111. # fetch separately.
  112. o_params=['ALL_REVISIONS', 'DOWNLOAD_COMMANDS'],
  113. limit=1,
  114. name='fetch current CL info',
  115. timeout=360,
  116. step_test_data=lambda: self.m.json.test_api.output(mock_res))[0]
  117. self._gerrit_change_target_ref = res['branch']
  118. if not self._gerrit_change_target_ref.startswith('refs/'):
  119. self._gerrit_change_target_ref = (
  120. 'refs/heads/' + self._gerrit_change_target_ref)
  121. for rev in res['revisions'].values():
  122. if int(rev['_number']) == self.gerrit_change.patchset:
  123. self._gerrit_change_fetch_ref = rev['ref']
  124. break
  125. self._gerrit_change_owner = res['owner']
  126. self._gerrit_info_initialized = True
  127. @property
  128. def gerrit_change_fetch_ref(self):
  129. """Returns gerrit patch ref, e.g. "refs/heads/45/12345/6, or None.
  130. Populated iff gerrit_change is populated.
  131. """
  132. self._ensure_gerrit_change_info()
  133. return self._gerrit_change_fetch_ref
  134. @property
  135. def gerrit_change_target_ref(self):
  136. """Returns gerrit change destination ref, e.g. "refs/heads/main".
  137. Populated iff gerrit_change is populated.
  138. """
  139. self._ensure_gerrit_change_info()
  140. return self._gerrit_change_target_ref
  141. @property
  142. def gerrit_change_number(self):
  143. """Returns gerrit change patchset, e.g. 12345 for a patch ref of
  144. "refs/heads/45/12345/6".
  145. Populated iff gerrit_change is populated. Returns None if not populated.
  146. """
  147. self._ensure_gerrit_change_info()
  148. if not self._gerrit_change: #pragma: nocover
  149. return None
  150. return int(self._gerrit_change.change)
  151. @property
  152. def gerrit_patchset_number(self):
  153. """Returns gerrit change patchset, e.g. 6 for a patch ref of
  154. "refs/heads/45/12345/6".
  155. Populated iff gerrit_change is populated Returns None if not populated..
  156. """
  157. self._ensure_gerrit_change_info()
  158. if not self._gerrit_change: #pragma: nocover
  159. return None
  160. return int(self._gerrit_change.patchset)
  161. @property
  162. def is_tryserver(self):
  163. """Returns true iff we have a change to check out."""
  164. return (self.is_patch_in_git or self.is_gerrit_issue)
  165. @property
  166. def is_gerrit_issue(self):
  167. """Returns true iff the properties exist to match a Gerrit issue."""
  168. if self.gerrit_change:
  169. return True
  170. # TODO(tandrii): remove this, once nobody is using buildbot Gerrit Poller.
  171. return ('event.patchSet.ref' in self.m.properties and
  172. 'event.change.url' in self.m.properties and
  173. 'event.change.id' in self.m.properties)
  174. @property
  175. def is_patch_in_git(self):
  176. return (self.m.properties.get('patch_storage') == 'git' and
  177. self.m.properties.get('patch_repo_url') and
  178. self.m.properties.get('patch_ref'))
  179. def require_is_tryserver(self):
  180. if self.m.tryserver.is_tryserver:
  181. return
  182. status = self.m.step.EXCEPTION
  183. step_text = 'This recipe requires a gerrit CL for the source under test'
  184. if self.m.led.launched_by_led:
  185. status = self.m.step.FAILURE
  186. step_text += (
  187. "\n run 'led edit-cr-cl <source CL URL>' to attach a CL to test"
  188. )
  189. self.m.step.empty('not a tryjob', status=status, step_text=step_text)
  190. def get_files_affected_by_patch(self, patch_root,
  191. report_files_via_property=None,
  192. **kwargs):
  193. """Returns list of paths to files affected by the patch.
  194. Args:
  195. * patch_root: path relative to api.path['root'], usually obtained from
  196. api.gclient.get_gerrit_patch_root().
  197. * report_files_via_property: name of the output property to report the
  198. list of the files. If None (default), do not report.
  199. Returned paths will be relative to to api.path['root'].
  200. """
  201. cwd = self.m.context.cwd or self.m.path['start_dir'].join(patch_root)
  202. with self.m.context(cwd=cwd):
  203. step_result = self.m.git(
  204. '-c', 'core.quotePath=false', 'diff', '--cached', '--name-only',
  205. name='git diff to analyze patch',
  206. stdout=self.m.raw_io.output(),
  207. step_test_data=lambda:
  208. self.m.raw_io.test_api.stream_output('foo.cc'),
  209. **kwargs)
  210. paths = [self.m.path.join(patch_root, p.decode('utf-8')) for p in
  211. step_result.stdout.splitlines()]
  212. paths.sort()
  213. if self.m.platform.is_win:
  214. # Looks like "analyze" wants POSIX slashes even on Windows (since git
  215. # uses that format even on Windows).
  216. paths = [path.replace('\\', '/') for path in paths]
  217. step_result.presentation.logs['files'] = paths
  218. if report_files_via_property:
  219. step_result.presentation.properties[report_files_via_property] = {
  220. 'total_count': len(paths),
  221. # Do not report too many because it might violate build size limits,
  222. # and isn't very useful anyway.
  223. 'first_100': paths[:100],
  224. }
  225. return paths
  226. def set_subproject_tag(self, subproject_tag):
  227. """Adds a subproject tag to the build.
  228. This can be used to distinguish between builds that execute different steps
  229. depending on what was patched, e.g. blink vs. pure chromium patches.
  230. """
  231. assert self.is_tryserver
  232. step_result = self.m.step('TRYJOB SET SUBPROJECT_TAG', cmd=None)
  233. step_result.presentation.properties['subproject_tag'] = subproject_tag
  234. step_result.presentation.step_text = subproject_tag
  235. def _set_failure_type(self, failure_type):
  236. if not self.is_tryserver:
  237. return
  238. # TODO(iannucci): add API to set properties regardless of the current step.
  239. step_result = self.m.step('TRYJOB FAILURE', cmd=None)
  240. step_result.presentation.properties['failure_type'] = failure_type
  241. step_result.presentation.step_text = failure_type
  242. step_result.presentation.status = 'FAILURE'
  243. def set_patch_failure_tryjob_result(self):
  244. """Mark the tryjob result as failure to apply the patch."""
  245. self._set_failure_type('PATCH_FAILURE')
  246. def set_compile_failure_tryjob_result(self):
  247. """Mark the tryjob result as a compile failure."""
  248. self._set_failure_type('COMPILE_FAILURE')
  249. def set_test_failure_tryjob_result(self):
  250. """Mark the tryjob result as a test failure.
  251. This means we started running actual tests (not prerequisite steps
  252. like checkout or compile), and some of these tests have failed.
  253. """
  254. self._set_failure_type('TEST_FAILURE')
  255. def set_invalid_test_results_tryjob_result(self):
  256. """Mark the tryjob result as having invalid test results.
  257. This means we run some tests, but the results were not valid
  258. (e.g. no list of specific test cases that failed, or too many
  259. tests failing, etc).
  260. """
  261. self._set_failure_type('INVALID_TEST_RESULTS')
  262. def set_test_timeout_tryjob_result(self):
  263. """Mark the tryjob result as a test timeout.
  264. This means tests were scheduled but didn't finish executing within the
  265. timeout.
  266. """
  267. self._set_failure_type('TEST_TIMEOUT')
  268. def set_test_expired_tryjob_result(self):
  269. """Mark the tryjob result as a test expiration.
  270. This means a test task expired and was never scheduled, most likely due to
  271. lack of capacity.
  272. """
  273. self._set_failure_type('TEST_EXPIRED')
  274. def get_footers(self, patch_text=None):
  275. """Retrieves footers from the patch description.
  276. footers are machine readable tags embedded in commit messages. See
  277. git-footers documentation for more information.
  278. """
  279. return self._get_footers(patch_text)
  280. def _ensure_gerrit_commit_message(self):
  281. """Fetch full commit message for Gerrit change."""
  282. self._ensure_gerrit_change_info()
  283. self._gerrit_commit_message = self.m.gerrit.get_change_description(
  284. 'https://%s' % self.gerrit_change.host,
  285. self.gerrit_change_number,
  286. self.gerrit_patchset_number,
  287. timeout=360)
  288. def _get_footers(self, patch_text=None):
  289. if patch_text is not None:
  290. return self._get_footer_step(patch_text)
  291. if self._change_footers: #pragma: nocover
  292. return self._change_footers
  293. if self.gerrit_change:
  294. self._ensure_gerrit_commit_message()
  295. self._change_footers = self._get_footer_step(self._gerrit_commit_message)
  296. return self._change_footers
  297. raise Exception(
  298. 'No patch text or associated changelist, cannot get footers') #pragma: nocover
  299. def _get_footer_step(self, patch_text):
  300. result = self.m.step('parse description', [
  301. 'python3',
  302. self.repo_resource('git_footers.py'), '--json',
  303. self.m.json.output()
  304. ],
  305. stdin=self.m.raw_io.input(data=patch_text))
  306. return result.json.output
  307. def get_footer(self, tag, patch_text=None):
  308. """Gets a specific tag from a CL description"""
  309. footers = self._get_footers(patch_text)
  310. if footers is None:
  311. return []
  312. return footers.get(tag, [])
  313. def normalize_footer_name(self, footer):
  314. return '-'.join([ word.title() for word in footer.strip().split('-') ])
  315. def set_change(self, change):
  316. """Set the gerrit change for this module.
  317. Args:
  318. * change: a self.m.buildbucket.common_pb2.GerritChange.
  319. """
  320. self._gerrit_info_initialized = False
  321. self._gerrit_change = change
  322. gs_suffix = '-review.googlesource.com'
  323. host = change.host
  324. if host.endswith(gs_suffix):
  325. host = '%s.googlesource.com' % host[:-len(gs_suffix)]
  326. self._gerrit_change_repo_url = 'https://%s/%s' % (host, change.project)
  327. self._gerrit_change_repo_host = host
  328. self._gerrit_change_repo_project = change.project