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