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