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