xref: /aosp_15_r20/external/toolchain-utils/rust_tools/rust_watch.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
1#!/usr/bin/env python3
2# Copyright 2020 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"""Checks for various upstream events with the Rust toolchain.
7
8Sends an email if something interesting (probably) happened.
9"""
10
11import argparse
12import itertools
13import json
14import logging
15import pathlib
16import re
17import shutil
18import subprocess
19import sys
20import time
21from typing import Any, Dict, Iterable, List, NamedTuple, Optional, Tuple
22
23from cros_utils import bugs
24from cros_utils import email_sender
25from cros_utils import tiny_render
26
27
28def gentoo_sha_to_link(sha: str) -> str:
29    """Gets a URL to a webpage that shows the Gentoo commit at `sha`."""
30    return f"https://gitweb.gentoo.org/repo/gentoo.git/commit?id={sha}"
31
32
33def send_email(subject: str, body: List[tiny_render.Piece]) -> None:
34    """Sends an email with the given title and body to... whoever cares."""
35    email_sender.EmailSender().SendX20Email(
36        subject=subject,
37        identifier="rust-watch",
38        well_known_recipients=["cros-team"],
39        text_body=tiny_render.render_text_pieces(body),
40        html_body=tiny_render.render_html_pieces(body),
41    )
42
43
44class RustReleaseVersion(NamedTuple):
45    """Represents a version of Rust's stable compiler."""
46
47    major: int
48    minor: int
49    patch: int
50
51    @staticmethod
52    def from_string(version_string: str) -> "RustReleaseVersion":
53        m = re.match(r"(\d+)\.(\d+)\.(\d+)", version_string)
54        if not m:
55            raise ValueError(f"{version_string!r} isn't a valid version string")
56        return RustReleaseVersion(*[int(x) for x in m.groups()])
57
58    def __str__(self) -> str:
59        return f"{self.major}.{self.minor}.{self.patch}"
60
61    def to_json(self) -> str:
62        return str(self)
63
64    @staticmethod
65    def from_json(s: str) -> "RustReleaseVersion":
66        return RustReleaseVersion.from_string(s)
67
68
69class State(NamedTuple):
70    """State that we keep around from run to run."""
71
72    # The last Rust release tag that we've seen.
73    last_seen_release: RustReleaseVersion
74
75    # We track Gentoo's upstream Rust ebuild. This is the last SHA we've seen
76    # that updates it.
77    last_gentoo_sha: str
78
79    def to_json(self) -> Dict[str, Any]:
80        return {
81            "last_seen_release": self.last_seen_release.to_json(),
82            "last_gentoo_sha": self.last_gentoo_sha,
83        }
84
85    @staticmethod
86    def from_json(s: Dict[str, Any]) -> "State":
87        return State(
88            last_seen_release=RustReleaseVersion.from_json(
89                s["last_seen_release"]
90            ),
91            last_gentoo_sha=s["last_gentoo_sha"],
92        )
93
94
95def parse_release_tags(lines: Iterable[str]) -> Iterable[RustReleaseVersion]:
96    """Parses `git ls-remote --tags` output into Rust stable versions."""
97    refs_tags = "refs/tags/"
98    for line in lines:
99        _sha, tag = line.split(None, 1)
100        tag = tag.strip()
101        # Each tag has an associated 'refs/tags/name^{}', which is the actual
102        # object that the tag points to. That's irrelevant to us.
103        if tag.endswith("^{}"):
104            continue
105
106        if not tag.startswith(refs_tags):
107            continue
108
109        short_tag = tag[len(refs_tags) :]
110        # There are a few old versioning schemes. Ignore them.
111        if short_tag.startswith("0.") or short_tag.startswith("release-"):
112            continue
113        yield RustReleaseVersion.from_string(short_tag)
114
115
116def fetch_most_recent_release() -> RustReleaseVersion:
117    """Fetches the most recent stable `rustc` version."""
118    result = subprocess.run(
119        ["git", "ls-remote", "--tags", "https://github.com/rust-lang/rust"],
120        check=True,
121        stdin=None,
122        capture_output=True,
123        encoding="utf-8",
124    )
125    tag_lines = result.stdout.strip().splitlines()
126    return max(parse_release_tags(tag_lines))
127
128
129class GitCommit(NamedTuple):
130    """Represents a single git commit."""
131
132    sha: str
133    subject: str
134
135
136def update_git_repo(git_dir: pathlib.Path) -> None:
137    """Updates the repo at `git_dir`, retrying a few times on failure."""
138    for i in itertools.count(start=1):
139        result = subprocess.run(
140            ["git", "fetch", "origin"],
141            check=False,
142            cwd=str(git_dir),
143            stdin=None,
144        )
145
146        if not result.returncode:
147            break
148
149        if i == 5:
150            # 5 attempts is too many. Something else may be wrong.
151            result.check_returncode()
152
153        sleep_time = 60 * i
154        logging.error(
155            "Failed updating gentoo's repo; will try again in %ds...",
156            sleep_time,
157        )
158        time.sleep(sleep_time)
159
160
161def get_new_gentoo_commits(
162    git_dir: pathlib.Path, most_recent_sha: str
163) -> List[GitCommit]:
164    """Gets commits to dev-lang/rust since `most_recent_sha`.
165
166    Older commits come earlier in the returned list.
167    """
168    commits = subprocess.run(
169        [
170            "git",
171            "log",
172            "--format=%H %s",
173            f"{most_recent_sha}..origin/master",  # nocheck
174            "--",
175            "dev-lang/rust",
176        ],
177        capture_output=True,
178        check=False,
179        cwd=str(git_dir),
180        encoding="utf-8",
181    )
182
183    if commits.returncode:
184        logging.error(
185            "Error getting new gentoo commits; stderr:\n%s", commits.stderr
186        )
187        commits.check_returncode()
188
189    results = []
190    for line in commits.stdout.strip().splitlines():
191        sha, subject = line.strip().split(None, 1)
192        results.append(GitCommit(sha=sha, subject=subject))
193
194    # `git log` outputs things in newest -> oldest order.
195    results.reverse()
196    return results
197
198
199def setup_gentoo_git_repo(git_dir: pathlib.Path) -> str:
200    """Sets up a gentoo git repo at the given directory. Returns HEAD."""
201    subprocess.run(
202        [
203            "git",
204            "clone",
205            "https://anongit.gentoo.org/git/repo/gentoo.git",
206            str(git_dir),
207        ],
208        stdin=None,
209        check=True,
210    )
211
212    head_rev = subprocess.run(
213        ["git", "rev-parse", "HEAD"],
214        cwd=str(git_dir),
215        check=True,
216        stdin=None,
217        capture_output=True,
218        encoding="utf-8",
219    )
220    return head_rev.stdout.strip()
221
222
223def read_state(state_file: pathlib.Path) -> State:
224    """Reads state from the given file."""
225    with state_file.open(encoding="utf-8") as f:
226        return State.from_json(json.load(f))
227
228
229def atomically_write_state(state_file: pathlib.Path, state: State) -> None:
230    """Writes state to the given file."""
231    temp_file = pathlib.Path(str(state_file) + ".new")
232    with temp_file.open("w", encoding="utf-8") as f:
233        json.dump(state.to_json(), f)
234    temp_file.rename(state_file)
235
236
237def file_bug(title: str, body: str) -> None:
238    """Files update bugs with the given title/body."""
239    # (component, optional_assignee)
240    targets = (
241        (bugs.WellKnownComponents.CrOSToolchainPublic, "[email protected]"),
242        # b/269170429: Some Android folks said they wanted this before, and
243        # figuring out the correct way to apply permissions has been a pain. No
244        # one seems to be missing these notifications & the Android Rust folks
245        # are keeping on top of their toolchain, so ignore this for now.
246        # (bugs.WellKnownComponents.AndroidRustToolchain, None),
247    )
248    for component, assignee in targets:
249        bugs.CreateNewBug(
250            component,
251            title,
252            body,
253            assignee,
254            parent_bug=bugs.RUST_MAINTENANCE_METABUG,
255        )
256
257
258def maybe_compose_bug(
259    old_state: State,
260    newest_release: RustReleaseVersion,
261) -> Optional[Tuple[str, str]]:
262    """Creates a bug to file about the new release, if doing is desired."""
263    if newest_release == old_state.last_seen_release:
264        return None
265
266    title = f"[Rust] Update to {newest_release}"
267    body = (
268        "A new Rust stable release has been detected; we should probably roll "
269        "to it.\n"
270        "\n"
271        "The regression-from-stable-to-stable tag might be interesting to "
272        "keep an eye on: https://github.com/rust-lang/rust/labels/"
273        "regression-from-stable-to-stable\n"
274        "\n"
275        "If you notice any bugs or issues you'd like to share, please "
276        "also note them on go/shared-rust-update-notes.\n"
277        "\n"
278        "See go/crostc-rust-rotation for the current rotation schedule.\n"
279        "\n"
280        "For questions about this bot, please contact chromeos-toolchain@ and "
281        "CC gbiv@."
282    )
283    return title, body
284
285
286def maybe_compose_email(
287    new_gentoo_commits: List[GitCommit],
288) -> Optional[Tuple[str, List[tiny_render.Piece]]]:
289    """Creates an email given our new state, if doing so is appropriate."""
290    if not new_gentoo_commits:
291        return None
292
293    subject_pieces = []
294    body_pieces: List[tiny_render.Piece] = []
295
296    # Separate the sections a bit for prettier output.
297    if body_pieces:
298        body_pieces += [tiny_render.line_break, tiny_render.line_break]
299
300    if len(new_gentoo_commits) == 1:
301        subject_pieces.append("new rust ebuild commit detected")
302        body_pieces.append("commit:")
303    else:
304        subject_pieces.append("new rust ebuild commits detected")
305        body_pieces.append("commits (newest first):")
306
307    commit_lines = []
308    for commit in new_gentoo_commits:
309        commit_lines.append(
310            [
311                tiny_render.Link(
312                    gentoo_sha_to_link(commit.sha),
313                    commit.sha[:12],
314                ),
315                f": {commit.subject}",
316            ]
317        )
318
319    body_pieces.append(tiny_render.UnorderedList(commit_lines))
320
321    subject = "[rust-watch] " + "; ".join(subject_pieces)
322    return subject, body_pieces
323
324
325def main(argv: List[str]) -> None:
326    logging.basicConfig(level=logging.INFO)
327
328    parser = argparse.ArgumentParser(
329        description=__doc__,
330        formatter_class=argparse.RawDescriptionHelpFormatter,
331    )
332    parser.add_argument(
333        "--state_dir", required=True, help="Directory to store state in."
334    )
335    parser.add_argument(
336        "--skip_side_effects",
337        action="store_true",
338        help="Don't send an email or file a bug.",
339    )
340    parser.add_argument(
341        "--skip_state_update",
342        action="store_true",
343        help="Don't update the state file. Doesn't apply to initial setup.",
344    )
345    opts = parser.parse_args(argv)
346
347    state_dir = pathlib.Path(opts.state_dir)
348    state_file = state_dir / "state.json"
349    gentoo_subdir = state_dir / "upstream-gentoo"
350    if not state_file.exists():
351        logging.info("state_dir isn't fully set up; doing that now.")
352
353        # Could be in a partially set-up state.
354        if state_dir.exists():
355            logging.info("incomplete state_dir detected; removing.")
356            shutil.rmtree(str(state_dir))
357
358        state_dir.mkdir(parents=True)
359        most_recent_release = fetch_most_recent_release()
360        most_recent_gentoo_commit = setup_gentoo_git_repo(gentoo_subdir)
361        atomically_write_state(
362            state_file,
363            State(
364                last_seen_release=most_recent_release,
365                last_gentoo_sha=most_recent_gentoo_commit,
366            ),
367        )
368        # Running through this _should_ be a nop, but do it anyway. Should make
369        # any bugs more obvious on the first run of the script.
370
371    prior_state = read_state(state_file)
372    logging.info("Last state was %r", prior_state)
373
374    most_recent_release = fetch_most_recent_release()
375    logging.info("Most recent Rust release is %s", most_recent_release)
376
377    logging.info("Fetching new commits from Gentoo")
378    update_git_repo(gentoo_subdir)
379    new_commits = get_new_gentoo_commits(
380        gentoo_subdir, prior_state.last_gentoo_sha
381    )
382    logging.info("New commits: %r", new_commits)
383
384    maybe_bug = maybe_compose_bug(prior_state, most_recent_release)
385    maybe_email = maybe_compose_email(new_commits)
386
387    if maybe_bug is None:
388        logging.info("No bug to file")
389    else:
390        bug_title, bug_body = maybe_bug
391        if opts.skip_side_effects:
392            logging.info(
393                "Skipping sending bug with title %r and contents\n%s",
394                bug_title,
395                bug_body,
396            )
397        else:
398            logging.info("Writing new bug")
399            file_bug(bug_title, bug_body)
400
401    if maybe_email is None:
402        logging.info("No email to send")
403    else:
404        email_title, email_body = maybe_email
405        if opts.skip_side_effects:
406            logging.info(
407                "Skipping sending email with title %r and contents\n%s",
408                email_title,
409                tiny_render.render_html_pieces(email_body),
410            )
411        else:
412            logging.info("Sending email")
413            send_email(email_title, email_body)
414
415    if opts.skip_state_update:
416        logging.info("Skipping state update, as requested")
417        return
418
419    newest_sha = (
420        new_commits[-1].sha if new_commits else prior_state.last_gentoo_sha
421    )
422    atomically_write_state(
423        state_file,
424        State(
425            last_seen_release=most_recent_release,
426            last_gentoo_sha=newest_sha,
427        ),
428    )
429
430
431if __name__ == "__main__":
432    main(sys.argv[1:])
433