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