merge_bot 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. #!/usr/bin/env python3
  2. # Copyright 2022 The ChromiumOS Authors
  3. # Use of this source code is governed by a BSD-style license that can be
  4. # found in the LICENSE file.
  5. # This script is used by the CI system to regularly update the merge and dry run changes.
  6. #
  7. # It can be run locally as well, however some permissions are only given to the bot's service
  8. # account (and are enabled with --is-bot).
  9. #
  10. # See `./tools/chromeos/merge_bot -h` for details.
  11. #
  12. # When testing this script locally, use MERGE_BOT_TEST=1 ./tools/chromeos/merge_bot
  13. # to use different tags and prevent emails from being sent or the CQ from being triggered.
  14. from contextlib import contextmanager
  15. import os
  16. from pathlib import Path
  17. import sys
  18. from datetime import date
  19. from typing import List
  20. import random
  21. import string
  22. sys.path.append(os.path.dirname(sys.path[0]))
  23. import re
  24. from impl.common import CROSVM_ROOT, batched, cmd, quoted, run_commands, GerritChange, GERRIT_URL
  25. git = cmd("git")
  26. git_log = git("log --decorate=no --color=never")
  27. curl = cmd("curl --silent --fail")
  28. chmod = cmd("chmod")
  29. dev_container = cmd("tools/dev_container")
  30. mkdir = cmd("mkdir -p")
  31. UPSTREAM_URL = "https://chromium.googlesource.com/crosvm/crosvm"
  32. CROS_URL = "https://chromium.googlesource.com/chromiumos/platform/crosvm"
  33. # Gerrit tags used to identify bot changes.
  34. TESTING = "MERGE_BOT_TEST" in os.environ
  35. if TESTING:
  36. MERGE_TAG = "testing-crosvm-merge"
  37. DRY_RUN_TAG = "testing-crosvm-merge-dry-run"
  38. else:
  39. MERGE_TAG = "crosvm-merge" # type: ignore
  40. DRY_RUN_TAG = "crosvm-merge-dry-run" # type: ignore
  41. # This is the email of the account that posts CQ messages.
  42. LUCI_EMAIL = "chromeos-scoped@luci-project-accounts.iam.gserviceaccount.com"
  43. # Do not create more dry runs than this within a 24h timespan
  44. MAX_DRY_RUNS_PER_DAY = 2
  45. def list_active_merges():
  46. return GerritChange.query(
  47. "project:chromiumos/platform/crosvm",
  48. "branch:chromeos",
  49. "status:open",
  50. f"hashtag:{MERGE_TAG}",
  51. )
  52. def list_active_dry_runs():
  53. return GerritChange.query(
  54. "project:chromiumos/platform/crosvm",
  55. "branch:chromeos",
  56. "status:open",
  57. f"hashtag:{DRY_RUN_TAG}",
  58. )
  59. def list_recent_dry_runs(age: str):
  60. return GerritChange.query(
  61. "project:chromiumos/platform/crosvm",
  62. "branch:chromeos",
  63. f"-age:{age}",
  64. f"hashtag:{DRY_RUN_TAG}",
  65. )
  66. def bug_notes(commit_range: str):
  67. "Returns a string with all BUG=... lines of the specified commit range."
  68. return "\n".join(
  69. set(
  70. line
  71. for line in git_log(commit_range, "--pretty=%b").lines()
  72. if re.match(r"^BUG=", line, re.I) and not re.match(r"^BUG=None", line, re.I)
  73. )
  74. )
  75. def setup_tracking_branch(branch_name: str, tracking: str):
  76. "Create and checkout `branch_name` tracking `tracking`. Overwrites existing branch."
  77. git("fetch -q cros", tracking).fg()
  78. git("checkout", f"cros/{tracking}").fg(quiet=True)
  79. git("branch -D", branch_name).fg(quiet=True, check=False)
  80. git("checkout -b", branch_name, "--track", f"cros/{tracking}").fg()
  81. @contextmanager
  82. def tracking_branch_context(branch_name: str, tracking: str):
  83. "Switches to a tracking branch and back after the context is exited."
  84. # Remember old head. Prefer branch name if available, otherwise revision of detached head.
  85. old_head = git("symbolic-ref -q --short HEAD").stdout(check=False)
  86. if not old_head:
  87. old_head = git("rev-parse HEAD").stdout()
  88. setup_tracking_branch(branch_name, tracking)
  89. yield
  90. git("checkout", old_head).fg()
  91. def gerrit_prerequisites():
  92. "Make sure we can upload to gerrit."
  93. # Setup cros remote which we are merging into
  94. if git("remote get-url cros").fg(check=False) != 0:
  95. print("Setting up remote: cros")
  96. git("remote add cros", CROS_URL).fg()
  97. actual_remote = git("remote get-url cros").stdout()
  98. if actual_remote != CROS_URL:
  99. print(f"WARNING: Your remote 'cros' is {actual_remote} and does not match {CROS_URL}")
  100. # Install gerrit Change-Id hook
  101. hook_path = CROSVM_ROOT / ".git/hooks/commit-msg"
  102. if not hook_path.exists():
  103. hook_path.parent.mkdir(exist_ok=True)
  104. curl(f"{GERRIT_URL}/tools/hooks/commit-msg").write_to(hook_path)
  105. chmod("+x", hook_path).fg()
  106. def upload_to_gerrit(target_branch: str, *extra_params: str):
  107. if not TESTING:
  108. extra_params = ("r=crosvm-uprev@google.com", *extra_params)
  109. for i in range(3):
  110. try:
  111. print(f"Uploading to gerrit (Attempt {i})")
  112. git(f"push cros HEAD:refs/for/{target_branch}%{','.join(extra_params)}").fg()
  113. return
  114. except:
  115. continue
  116. raise Exception("Could not upload changes to gerrit.")
  117. def rename_files_to_random(dir_path: str):
  118. "Rename all files in a folder to random file names with extension kept"
  119. print("Renaming all files in " + dir_path)
  120. file_names = os.listdir(dir_path)
  121. for file_name in filter(os.path.isfile, map(lambda x: os.path.join(dir_path, x), file_names)):
  122. file_extension = os.path.splitext(file_name)[1]
  123. new_name_stem = "".join(
  124. random.choice(string.ascii_lowercase + string.digits) for _ in range(16)
  125. )
  126. new_path = os.path.join(dir_path, new_name_stem + file_extension)
  127. print(f"Renaming {file_name} to {new_path}")
  128. os.rename(file_name, new_path)
  129. def create_pgo_profile():
  130. "Create PGO profile matching HEAD at merge."
  131. has_kvm = os.path.exists("/dev/kvm")
  132. if not has_kvm:
  133. return
  134. os.chdir(CROSVM_ROOT)
  135. tmpdirname = "target/pgotmp/" + "".join(
  136. random.choice(string.ascii_lowercase + string.digits) for _ in range(16)
  137. )
  138. mkdir(tmpdirname).fg()
  139. benchmark_list = list(
  140. map(
  141. lambda x: os.path.splitext(x)[0],
  142. filter(lambda x: x.endswith(".rs"), os.listdir("e2e_tests/benches")),
  143. )
  144. )
  145. print(f"Building instrumented binary, perf data will be saved to {tmpdirname}")
  146. dev_container(
  147. "./tools/build_release --build-profile release --profile-generate /workspace/" + tmpdirname
  148. ).fg()
  149. print()
  150. print("List of benchmarks to run:")
  151. for bench_name in benchmark_list:
  152. print(bench_name)
  153. print()
  154. dev_container("mkdir -p /var/empty").fg()
  155. for bench_name in benchmark_list:
  156. print(f"Running bechmark: {bench_name}")
  157. dev_container(f"./tools/bench {bench_name}").fg()
  158. # Instrumented binary always give same file name to generated .profraw files, rename to avoid
  159. # overwriting profile from previous bench suite
  160. rename_files_to_random(tmpdirname)
  161. mkdir("profiles").fg()
  162. dev_container(
  163. f"cargo profdata -- merge -o /workspace/profiles/benchmarks.profdata /workspace/{tmpdirname}"
  164. ).fg()
  165. dev_container("xz -f -9e -T 0 /workspace/profiles/benchmarks.profdata").fg()
  166. ####################################################################################################
  167. # The functions below are callable via the command line
  168. def create_merge_commits(
  169. revision: str, max_size: int = 0, create_dry_run: bool = False, force_pgo: bool = False
  170. ):
  171. "Merges `revision` into HEAD, creating merge commits including at most `max-size` commits."
  172. os.chdir(CROSVM_ROOT)
  173. # Find list of commits to merge, then batch them into smaller merges.
  174. commits = git_log(f"HEAD..{revision}", "--pretty=%H").lines()
  175. if not commits:
  176. print("Nothing to merge.")
  177. return (0, False)
  178. else:
  179. commit_authors = git_log(f"HEAD..{revision}", "--pretty=%an").lines()
  180. if all(map(lambda x: x == "recipe-roller", commit_authors)):
  181. print("All commits are from recipe roller, don't merge yet")
  182. return (0, False)
  183. # Create a merge commit for each batch
  184. batches = list(batched(commits, max_size)) if max_size > 0 else [commits]
  185. has_conflicts = False
  186. for i, batch in enumerate(reversed(batches)):
  187. target = batch[0]
  188. previous_rev = git(f"rev-parse {batch[-1]}^").stdout()
  189. commit_range = f"{previous_rev}..{batch[0]}"
  190. # Put together a message containing info about what's in the merge.
  191. batch_str = f"{i + 1}/{len(batches)}" if len(batches) > 1 else ""
  192. title = "Merge with upstream" if not create_dry_run else f"Merge dry run"
  193. message = "\n\n".join(
  194. [
  195. f"{title} {date.today().isoformat()} {batch_str}",
  196. git_log(commit_range, "--oneline").stdout(),
  197. f"{UPSTREAM_URL}/+log/{commit_range}",
  198. *([bug_notes(commit_range)] if not create_dry_run else []),
  199. ]
  200. )
  201. # git 'trailers' go into a separate paragraph to make sure they are properly separated.
  202. trailers = "Commit: False" if create_dry_run or TESTING else ""
  203. # Perfom merge
  204. code = git("merge --no-ff", target, "-m", quoted(message), "-m", quoted(trailers)).fg(
  205. check=False
  206. )
  207. if code != 0:
  208. if not Path(".git/MERGE_HEAD").exists():
  209. raise Exception("git merge failed for a reason other than merge conflicts.")
  210. print("Merge has conflicts. Creating commit with conflict markers.")
  211. git("add --update .").fg()
  212. message = f"(CONFLICT) {message}"
  213. git("commit", "-m", quoted(message), "-m", quoted(trailers)).fg()
  214. has_conflicts = True
  215. # Only uprev PGO profile on Monday to reduce impact on repo size
  216. # TODO: b/181105093 - Re-evaluate throttling strategy after sometime
  217. if date.today().weekday() == 0 or force_pgo:
  218. create_pgo_profile()
  219. git("add profiles/benchmarks.profdata.xz").fg()
  220. git("commit --amend --no-edit").fg()
  221. return (len(batches), has_conflicts)
  222. def status():
  223. "Shows the current status of pending merge and dry run changes in gerrit."
  224. print("Active dry runs:")
  225. for dry_run in list_active_dry_runs():
  226. print(dry_run.pretty_info())
  227. print()
  228. print("Active merges:")
  229. for merge in list_active_merges():
  230. print(merge.pretty_info())
  231. def update_merges(
  232. revision: str,
  233. target_branch: str = "chromeos",
  234. max_size: int = 15,
  235. is_bot: bool = False,
  236. ):
  237. """Uploads a new set of merge commits if the previous batch has been submitted."""
  238. gerrit_prerequisites()
  239. parsed_revision = git("rev-parse", revision).stdout()
  240. active_merges = list_active_merges()
  241. if active_merges:
  242. print("Nothing to do. Previous merges are still pending:")
  243. for merge in active_merges:
  244. print(merge.pretty_info())
  245. return
  246. else:
  247. print(f"Creating merge of {parsed_revision} into cros/{target_branch}")
  248. with tracking_branch_context("merge-bot-branch", target_branch):
  249. count, has_conflicts = create_merge_commits(
  250. parsed_revision, max_size, create_dry_run=False
  251. )
  252. if count > 0:
  253. labels: List[str] = []
  254. if not has_conflicts:
  255. if not TESTING:
  256. labels.append("l=Commit-Queue+1")
  257. if is_bot:
  258. labels.append("l=Bot-Commit+1")
  259. upload_to_gerrit(target_branch, f"hashtag={MERGE_TAG}", *labels)
  260. def update_dry_runs(
  261. revision: str,
  262. target_branch: str = "chromeos",
  263. max_size: int = 0,
  264. is_bot: bool = False,
  265. ):
  266. """
  267. Maintains dry run changes in gerrit, usually run by the crosvm bot, but can be called by
  268. developers as well.
  269. """
  270. gerrit_prerequisites()
  271. parsed_revision = git("rev-parse", revision).stdout()
  272. # Close active dry runs if they are done.
  273. print("Checking active dry runs")
  274. for dry_run in list_active_dry_runs():
  275. cq_votes = dry_run.get_votes("Commit-Queue")
  276. if not cq_votes or max(cq_votes) > 0:
  277. print(dry_run, "CQ is still running.")
  278. continue
  279. # Check for luci results and add V+-1 votes to make it easier to identify failed dry runs.
  280. luci_messages = dry_run.get_messages_by(LUCI_EMAIL)
  281. if not luci_messages:
  282. print(dry_run, "No luci messages yet.")
  283. continue
  284. last_luci_message = luci_messages[-1]
  285. if "This CL passed the CQ dry run" in last_luci_message or (
  286. "This CL has passed the run" in last_luci_message
  287. ):
  288. dry_run.review(
  289. "I think this dry run was SUCCESSFUL.",
  290. {
  291. "Verified": 1,
  292. "Bot-Commit": 0,
  293. },
  294. )
  295. elif "Failed builds" in last_luci_message or (
  296. "This CL has failed the run. Reason:" in last_luci_message
  297. ):
  298. dry_run.review(
  299. "I think this dry run FAILED.",
  300. {
  301. "Verified": -1,
  302. "Bot-Commit": 0,
  303. },
  304. )
  305. dry_run.abandon("Dry completed.")
  306. active_dry_runs = list_active_dry_runs()
  307. if active_dry_runs:
  308. print("There are active dry runs, not creating a new one.")
  309. print("Active dry runs:")
  310. for dry_run in active_dry_runs:
  311. print(dry_run.pretty_info())
  312. return
  313. num_dry_runs = len(list_recent_dry_runs("1d"))
  314. if num_dry_runs >= MAX_DRY_RUNS_PER_DAY:
  315. print(f"Already created {num_dry_runs} in the past 24h. Not creating another one.")
  316. return
  317. print(f"Creating dry run merge of {parsed_revision} into cros/{target_branch}")
  318. with tracking_branch_context("merge-bot-branch", target_branch):
  319. count, has_conflicts = create_merge_commits(
  320. parsed_revision, max_size, create_dry_run=True, force_pgo=True
  321. )
  322. if count > 0 and not has_conflicts:
  323. upload_to_gerrit(
  324. target_branch,
  325. f"hashtag={DRY_RUN_TAG}",
  326. *(["l=Commit-Queue+1"] if not TESTING else []),
  327. *(["l=Bot-Commit+1"] if is_bot else []),
  328. )
  329. else:
  330. if has_conflicts:
  331. print("Not uploading dry-run with conflicts.")
  332. else:
  333. print("Nothing to upload.")
  334. run_commands(
  335. create_merge_commits,
  336. status,
  337. update_merges,
  338. update_dry_runs,
  339. gerrit_prerequisites,
  340. )