• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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