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