mirror of
https://chromium.googlesource.com/crosvm/crosvm
synced 2024-11-25 13:23:08 +00:00
7941257dd0
If dry runs are failing quickly the merge bot can start spamming the CQ with needless dry runs. Reducing the frequency to at most twice a day will prevent this. BUG=b:265803531 TEST=MERGE_BOT_TEST=1 tools/chromeos/merge_bot update-dry-runs Change-Id: I352d6d2653536a3af137dce71ff6306783c6c886 Reviewed-on: https://chromium-review.googlesource.com/c/crosvm/crosvm/+/4174613 Commit-Queue: Zihan Chen <zihanchen@google.com> Auto-Submit: Dennis Kempin <denniskempin@google.com> Reviewed-by: Zihan Chen <zihanchen@google.com>
326 lines
11 KiB
Python
Executable file
326 lines
11 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# Copyright 2022 The ChromiumOS Authors
|
|
# Use of this source code is governed by a BSD-style license that can be
|
|
# found in the LICENSE file.
|
|
|
|
# This script is used by the CI system to regularly update the merge and dry run changes.
|
|
#
|
|
# It can be run locally as well, however some permissions are only given to the bot's service
|
|
# account (and are enabled with --is-bot).
|
|
#
|
|
# See `./tools/chromeos/merge_bot -h` for details.
|
|
#
|
|
# When testing this script locally, use MERGE_BOT_TEST=1 ./tools/chromeos/merge_bot
|
|
# to use different tags and prevent emails from being sent or the CQ from being triggered.
|
|
|
|
from contextlib import contextmanager
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
from datetime import date
|
|
from typing import List
|
|
|
|
sys.path.append(os.path.dirname(sys.path[0]))
|
|
|
|
import re
|
|
|
|
from impl.common import CROSVM_ROOT, batched, cmd, quoted, run_commands, GerritChange, GERRIT_URL
|
|
|
|
git = cmd("git")
|
|
git_log = git("log --decorate=no --color=never")
|
|
curl = cmd("curl --silent --fail")
|
|
chmod = cmd("chmod")
|
|
|
|
UPSTREAM_URL = "https://chromium.googlesource.com/crosvm/crosvm"
|
|
CROS_URL = "https://chromium.googlesource.com/chromiumos/platform/crosvm"
|
|
|
|
# Gerrit tags used to identify bot changes.
|
|
TESTING = "MERGE_BOT_TEST" in os.environ
|
|
if TESTING:
|
|
MERGE_TAG = "testing-crosvm-merge"
|
|
DRY_RUN_TAG = "testing-crosvm-merge-dry-run"
|
|
else:
|
|
MERGE_TAG = "crosvm-merge" # type: ignore
|
|
DRY_RUN_TAG = "crosvm-merge-dry-run" # type: ignore
|
|
|
|
# This is the email of the account that posts CQ messages.
|
|
LUCI_EMAIL = "chromeos-scoped@luci-project-accounts.iam.gserviceaccount.com"
|
|
|
|
# Do not create more dry runs than this within a 24h timespan
|
|
MAX_DRY_RUNS_PER_DAY = 2
|
|
|
|
|
|
def list_active_merges():
|
|
return GerritChange.query(
|
|
"project:chromiumos/platform/crosvm",
|
|
"branch:chromeos",
|
|
"status:open",
|
|
f"hashtag:{MERGE_TAG}",
|
|
)
|
|
|
|
|
|
def list_active_dry_runs():
|
|
return GerritChange.query(
|
|
"project:chromiumos/platform/crosvm",
|
|
"branch:chromeos",
|
|
"status:open",
|
|
f"hashtag:{DRY_RUN_TAG}",
|
|
)
|
|
|
|
|
|
def list_recent_dry_runs(age: str):
|
|
return GerritChange.query(
|
|
"project:chromiumos/platform/crosvm",
|
|
"branch:chromeos",
|
|
f"-age:{age}",
|
|
f"hashtag:{DRY_RUN_TAG}",
|
|
)
|
|
|
|
|
|
def bug_notes(commit_range: str):
|
|
"Returns a string with all BUG=... lines of the specified commit range."
|
|
return "\n".join(
|
|
set(
|
|
line
|
|
for line in git_log(commit_range, "--pretty=%b").lines()
|
|
if re.match(r"^BUG=", line, re.I) and not re.match(r"^BUG=None", line, re.I)
|
|
)
|
|
)
|
|
|
|
|
|
def setup_tracking_branch(branch_name: str, tracking: str):
|
|
"Create and checkout `branch_name` tracking `tracking`. Overwrites existing branch."
|
|
git("fetch -q cros", tracking).fg()
|
|
git("checkout", f"cros/{tracking}").fg(quiet=True)
|
|
git("branch -D", branch_name).fg(quiet=True, check=False)
|
|
git("checkout -b", branch_name, "--track", f"cros/{tracking}").fg()
|
|
|
|
|
|
@contextmanager
|
|
def tracking_branch_context(branch_name: str, tracking: str):
|
|
"Switches to a tracking branch and back after the context is exited."
|
|
# Remember old head. Prefer branch name if available, otherwise revision of detached head.
|
|
old_head = git("symbolic-ref -q --short HEAD").stdout(check=False)
|
|
if not old_head:
|
|
old_head = git("rev-parse HEAD").stdout()
|
|
setup_tracking_branch(branch_name, tracking)
|
|
yield
|
|
git("checkout", old_head).fg()
|
|
|
|
|
|
def gerrit_prerequisites():
|
|
"Make sure we can upload to gerrit."
|
|
|
|
# Setup cros remote which we are merging into
|
|
if git("remote get-url cros").fg(check=False) != 0:
|
|
print("Setting up remote: cros")
|
|
git("remote add cros", CROS_URL).fg()
|
|
actual_remote = git("remote get-url cros").stdout()
|
|
if actual_remote != CROS_URL:
|
|
print(f"WARNING: Your remote 'cros' is {actual_remote} and does not match {CROS_URL}")
|
|
|
|
# Install gerrit Change-Id hook
|
|
hook_path = CROSVM_ROOT / ".git/hooks/commit-msg"
|
|
if not hook_path.exists():
|
|
hook_path.parent.mkdir(exist_ok=True)
|
|
curl(f"{GERRIT_URL}/tools/hooks/commit-msg").write_to(hook_path)
|
|
chmod("+x", hook_path).fg()
|
|
|
|
|
|
def upload_to_gerrit(target_branch: str, *extra_params: str):
|
|
if not TESTING:
|
|
extra_params = ("r=crosvm-uprev@google.com", *extra_params)
|
|
for i in range(3):
|
|
try:
|
|
print(f"Uploading to gerrit (Attempt {i})")
|
|
git(f"push cros HEAD:refs/for/{target_branch}%{','.join(extra_params)}").fg()
|
|
return
|
|
except:
|
|
continue
|
|
raise Exception("Could not upload changes to gerrit.")
|
|
|
|
|
|
####################################################################################################
|
|
# The functions below are callable via the command line
|
|
|
|
|
|
def create_merge_commits(revision: str, max_size: int = 0, create_dry_run: bool = False):
|
|
"Merges `revision` into HEAD, creating merge commits including at most `max-size` commits."
|
|
os.chdir(CROSVM_ROOT)
|
|
|
|
# Find list of commits to merge, then batch them into smaller merges.
|
|
commits = git_log(f"HEAD..{revision}", "--pretty=%H").lines()
|
|
if not commits:
|
|
print("Nothing to merge.")
|
|
return (0, False)
|
|
|
|
# Create a merge commit for each batch
|
|
batches = list(batched(commits, max_size)) if max_size > 0 else [commits]
|
|
has_conflicts = False
|
|
for i, batch in enumerate(reversed(batches)):
|
|
target = batch[0]
|
|
previous_rev = git(f"rev-parse {batch[-1]}^").stdout()
|
|
commit_range = f"{previous_rev}..{batch[0]}"
|
|
|
|
# Put together a message containing info about what's in the merge.
|
|
batch_str = f"{i + 1}/{len(batches)}" if len(batches) > 1 else ""
|
|
title = "Merge with upstream" if not create_dry_run else f"Merge dry run"
|
|
message = "\n\n".join(
|
|
[
|
|
f"{title} {date.today().isoformat()} {batch_str}",
|
|
git_log(commit_range, "--oneline").stdout(),
|
|
f"{UPSTREAM_URL}/+log/{commit_range}",
|
|
*([bug_notes(commit_range)] if not create_dry_run else []),
|
|
]
|
|
)
|
|
|
|
# git 'trailers' go into a separate paragraph to make sure they are properly separated.
|
|
trailers = "Commit: False" if create_dry_run or TESTING else ""
|
|
|
|
# Perfom merge
|
|
code = git("merge --no-ff", target, "-m", quoted(message), "-m", quoted(trailers)).fg(
|
|
check=False
|
|
)
|
|
if code != 0:
|
|
if not Path(".git/MERGE_HEAD").exists():
|
|
raise Exception("git merge failed for a reason other than merge conflicts.")
|
|
print("Merge has conflicts. Creating commit with conflict markers.")
|
|
git("add --update .").fg()
|
|
message = f"(CONFLICT) {message}"
|
|
git("commit", "-m", quoted(message), "-m", quoted(trailers)).fg()
|
|
has_conflicts = True
|
|
|
|
return (len(batches), has_conflicts)
|
|
|
|
|
|
def status():
|
|
"Shows the current status of pending merge and dry run changes in gerrit."
|
|
print("Active dry runs:")
|
|
for dry_run in list_active_dry_runs():
|
|
print(dry_run.pretty_info())
|
|
print()
|
|
print("Active merges:")
|
|
for merge in list_active_merges():
|
|
print(merge.pretty_info())
|
|
|
|
|
|
def update_merges(
|
|
revision: str,
|
|
target_branch: str = "chromeos",
|
|
max_size: int = 15,
|
|
is_bot: bool = False,
|
|
):
|
|
"""Uploads a new set of merge commits if the previous batch has been submitted."""
|
|
gerrit_prerequisites()
|
|
parsed_revision = git("rev-parse", revision).stdout()
|
|
|
|
active_merges = list_active_merges()
|
|
if active_merges:
|
|
print("Nothing to do. Previous merges are still pending:")
|
|
for merge in active_merges:
|
|
print(merge.pretty_info())
|
|
return
|
|
else:
|
|
print(f"Creating merge of {parsed_revision} into cros/{target_branch}")
|
|
with tracking_branch_context("merge-bot-branch", target_branch):
|
|
count, has_conflicts = create_merge_commits(
|
|
parsed_revision, max_size, create_dry_run=False
|
|
)
|
|
if count > 0:
|
|
labels: List[str] = []
|
|
if not has_conflicts:
|
|
if not TESTING:
|
|
labels.append("l=Commit-Queue+1")
|
|
if is_bot:
|
|
labels.append("l=Bot-Commit+1")
|
|
upload_to_gerrit(target_branch, f"hashtag={MERGE_TAG}", *labels)
|
|
|
|
|
|
def update_dry_runs(
|
|
revision: str,
|
|
target_branch: str = "chromeos",
|
|
max_size: int = 0,
|
|
is_bot: bool = False,
|
|
):
|
|
"""
|
|
Maintains dry run changes in gerrit, usually run by the crosvm bot, but can be called by
|
|
developers as well.
|
|
"""
|
|
gerrit_prerequisites()
|
|
parsed_revision = git("rev-parse", revision).stdout()
|
|
|
|
# Close active dry runs if they are done.
|
|
print("Checking active dry runs")
|
|
for dry_run in list_active_dry_runs():
|
|
cq_votes = dry_run.get_votes("Commit-Queue")
|
|
if not cq_votes or max(cq_votes) > 0:
|
|
print(dry_run, "CQ is still running.")
|
|
continue
|
|
|
|
# Check for luci results and add V+-1 votes to make it easier to identify failed dry runs.
|
|
luci_messages = dry_run.get_messages_by(LUCI_EMAIL)
|
|
if not luci_messages:
|
|
print(dry_run, "No luci messages yet.")
|
|
continue
|
|
|
|
last_luci_message = luci_messages[-1]
|
|
if "This CL passed the CQ dry run" in last_luci_message or (
|
|
"This CL has passed the run" in last_luci_message
|
|
):
|
|
dry_run.review(
|
|
"I think this dry run was SUCCESSFUL.",
|
|
{
|
|
"Verified": 1,
|
|
"Bot-Commit": 0,
|
|
},
|
|
)
|
|
elif "Failed builds" in last_luci_message or (
|
|
"This CL has failed the run. Reason:" in last_luci_message
|
|
):
|
|
dry_run.review(
|
|
"I think this dry run FAILED.",
|
|
{
|
|
"Verified": -1,
|
|
"Bot-Commit": 0,
|
|
},
|
|
)
|
|
|
|
dry_run.abandon("Dry completed.")
|
|
|
|
active_dry_runs = list_active_dry_runs()
|
|
if active_dry_runs:
|
|
print("There are active dry runs, not creating a new one.")
|
|
print("Active dry runs:")
|
|
for dry_run in active_dry_runs:
|
|
print(dry_run.pretty_info())
|
|
return
|
|
|
|
num_dry_runs = len(list_recent_dry_runs("1d"))
|
|
if num_dry_runs >= MAX_DRY_RUNS_PER_DAY:
|
|
print(f"Already created {num_dry_runs} in the past 24h. Not creating another one.")
|
|
return
|
|
|
|
print(f"Creating dry run merge of {parsed_revision} into cros/{target_branch}")
|
|
with tracking_branch_context("merge-bot-branch", target_branch):
|
|
count, has_conflicts = create_merge_commits(parsed_revision, max_size, create_dry_run=True)
|
|
if count > 0 and not has_conflicts:
|
|
upload_to_gerrit(
|
|
target_branch,
|
|
f"hashtag={DRY_RUN_TAG}",
|
|
*(["l=Commit-Queue+1"] if not TESTING else []),
|
|
*(["l=Bot-Commit+1"] if is_bot else []),
|
|
)
|
|
else:
|
|
if has_conflicts:
|
|
print("Not uploading dry-run with conflicts.")
|
|
else:
|
|
print("Nothing to upload.")
|
|
|
|
|
|
run_commands(
|
|
create_merge_commits,
|
|
status,
|
|
update_merges,
|
|
update_dry_runs,
|
|
gerrit_prerequisites,
|
|
)
|