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