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