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 new reverts in LLVM on a nightly basis. 8 9If any reverts are found that were previously unknown, this cherry-picks them or 10fires off an email. All LLVM SHAs to monitor are autodetected. 11""" 12 13 14import argparse 15import io 16import json 17import logging 18import os 19import pprint 20import subprocess 21import sys 22import typing as t 23 24import cros_utils.email_sender as email_sender 25import cros_utils.tiny_render as tiny_render 26import get_llvm_hash 27import get_upstream_patch 28import git_llvm_rev 29import revert_checker 30 31 32State = t.Any 33 34 35def _find_interesting_android_shas( 36 android_llvm_toolchain_dir: str, 37) -> t.List[t.Tuple[str, str]]: 38 llvm_project = os.path.join( 39 android_llvm_toolchain_dir, "toolchain/llvm-project" 40 ) 41 42 def get_llvm_merge_base(branch: str) -> str: 43 head_sha = subprocess.check_output( 44 ["git", "rev-parse", branch], 45 cwd=llvm_project, 46 encoding="utf-8", 47 ).strip() 48 merge_base = subprocess.check_output( 49 ["git", "merge-base", branch, "aosp/upstream-main"], 50 cwd=llvm_project, 51 encoding="utf-8", 52 ).strip() 53 logging.info( 54 "Merge-base for %s (HEAD == %s) and upstream-main is %s", 55 branch, 56 head_sha, 57 merge_base, 58 ) 59 return merge_base 60 61 main_legacy = get_llvm_merge_base("aosp/master-legacy") # nocheck 62 testing_upstream = get_llvm_merge_base("aosp/testing-upstream") 63 result = [("main-legacy", main_legacy)] 64 65 # If these are the same SHA, there's no point in tracking both. 66 if main_legacy != testing_upstream: 67 result.append(("testing-upstream", testing_upstream)) 68 else: 69 logging.info( 70 "main-legacy and testing-upstream are identical; ignoring " 71 "the latter." 72 ) 73 return result 74 75 76def _parse_llvm_ebuild_for_shas( 77 ebuild_file: io.TextIOWrapper, 78) -> t.List[t.Tuple[str, str]]: 79 def parse_ebuild_assignment(line: str) -> str: 80 no_comments = line.split("#")[0] 81 no_assign = no_comments.split("=", 1)[1].strip() 82 assert no_assign.startswith('"') and no_assign.endswith('"'), no_assign 83 return no_assign[1:-1] 84 85 llvm_hash, llvm_next_hash = None, None 86 for line in ebuild_file: 87 if line.startswith("LLVM_HASH="): 88 llvm_hash = parse_ebuild_assignment(line) 89 if llvm_next_hash: 90 break 91 if line.startswith("LLVM_NEXT_HASH"): 92 llvm_next_hash = parse_ebuild_assignment(line) 93 if llvm_hash: 94 break 95 if not llvm_next_hash or not llvm_hash: 96 raise ValueError( 97 "Failed to detect SHAs for llvm/llvm_next. Got: " 98 "llvm=%s; llvm_next=%s" % (llvm_hash, llvm_next_hash) 99 ) 100 101 results = [("llvm", llvm_hash)] 102 if llvm_next_hash != llvm_hash: 103 results.append(("llvm-next", llvm_next_hash)) 104 return results 105 106 107def _find_interesting_chromeos_shas( 108 chromeos_base: str, 109) -> t.List[t.Tuple[str, str]]: 110 llvm_dir = os.path.join( 111 chromeos_base, "src/third_party/chromiumos-overlay/sys-devel/llvm" 112 ) 113 candidate_ebuilds = [ 114 os.path.join(llvm_dir, x) 115 for x in os.listdir(llvm_dir) 116 if "_pre" in x and not os.path.islink(os.path.join(llvm_dir, x)) 117 ] 118 119 if len(candidate_ebuilds) != 1: 120 raise ValueError( 121 "Expected exactly one llvm ebuild candidate; got %s" 122 % pprint.pformat(candidate_ebuilds) 123 ) 124 125 with open(candidate_ebuilds[0], encoding="utf-8") as f: 126 return _parse_llvm_ebuild_for_shas(f) 127 128 129_Email = t.NamedTuple( 130 "_Email", 131 [ 132 ("subject", str), 133 ("body", tiny_render.Piece), 134 ], 135) 136 137 138def _generate_revert_email( 139 repository_name: str, 140 friendly_name: str, 141 sha: str, 142 prettify_sha: t.Callable[[str], tiny_render.Piece], 143 get_sha_description: t.Callable[[str], tiny_render.Piece], 144 new_reverts: t.List[revert_checker.Revert], 145) -> _Email: 146 email_pieces = [ 147 "It looks like there may be %s across %s (" 148 % ( 149 "a new revert" if len(new_reverts) == 1 else "new reverts", 150 friendly_name, 151 ), 152 prettify_sha(sha), 153 ").", 154 tiny_render.line_break, 155 tiny_render.line_break, 156 "That is:" if len(new_reverts) == 1 else "These are:", 157 ] 158 159 revert_listing = [] 160 for revert in sorted(new_reverts, key=lambda r: r.sha): 161 revert_listing.append( 162 [ 163 prettify_sha(revert.sha), 164 " (appears to revert ", 165 prettify_sha(revert.reverted_sha), 166 "): ", 167 get_sha_description(revert.sha), 168 ] 169 ) 170 171 email_pieces.append(tiny_render.UnorderedList(items=revert_listing)) 172 email_pieces += [ 173 tiny_render.line_break, 174 "PTAL and consider reverting them locally.", 175 ] 176 return _Email( 177 subject="[revert-checker/%s] new %s discovered across %s" 178 % ( 179 repository_name, 180 "revert" if len(new_reverts) == 1 else "reverts", 181 friendly_name, 182 ), 183 body=email_pieces, 184 ) 185 186 187_EmailRecipients = t.NamedTuple( 188 "_EmailRecipients", 189 [ 190 ("well_known", t.List[str]), 191 ("direct", t.List[str]), 192 ], 193) 194 195 196def _send_revert_email(recipients: _EmailRecipients, email: _Email) -> None: 197 email_sender.EmailSender().SendX20Email( 198 subject=email.subject, 199 identifier="revert-checker", 200 well_known_recipients=recipients.well_known, 201 direct_recipients=["gbiv@google.com"] + recipients.direct, 202 text_body=tiny_render.render_text_pieces(email.body), 203 html_body=tiny_render.render_html_pieces(email.body), 204 ) 205 206 207def _write_state(state_file: str, new_state: State) -> None: 208 try: 209 tmp_file = state_file + ".new" 210 with open(tmp_file, "w", encoding="utf-8") as f: 211 json.dump( 212 new_state, f, sort_keys=True, indent=2, separators=(",", ": ") 213 ) 214 os.rename(tmp_file, state_file) 215 except: 216 try: 217 os.remove(tmp_file) 218 except FileNotFoundError: 219 pass 220 raise 221 222 223def _read_state(state_file: str) -> State: 224 try: 225 with open(state_file) as f: 226 return json.load(f) 227 except FileNotFoundError: 228 logging.info( 229 "No state file found at %r; starting with an empty slate", 230 state_file, 231 ) 232 return {} 233 234 235def find_shas( 236 llvm_dir: str, 237 interesting_shas: t.List[t.Tuple[str, str]], 238 state: State, 239 new_state: State, 240): 241 for friendly_name, sha in interesting_shas: 242 logging.info("Finding reverts across %s (%s)", friendly_name, sha) 243 all_reverts = revert_checker.find_reverts( 244 llvm_dir, sha, root="origin/" + git_llvm_rev.MAIN_BRANCH 245 ) 246 logging.info( 247 "Detected the following revert(s) across %s:\n%s", 248 friendly_name, 249 pprint.pformat(all_reverts), 250 ) 251 252 new_state[sha] = [r.sha for r in all_reverts] 253 254 if sha not in state: 255 logging.info("SHA %s is new to me", sha) 256 existing_reverts = set() 257 else: 258 existing_reverts = set(state[sha]) 259 260 new_reverts = [r for r in all_reverts if r.sha not in existing_reverts] 261 if not new_reverts: 262 logging.info("...All of which have been reported.") 263 continue 264 265 yield (friendly_name, sha, new_reverts) 266 267 268def do_cherrypick( 269 chroot_path: str, 270 llvm_dir: str, 271 interesting_shas: t.List[t.Tuple[str, str]], 272 state: State, 273 reviewers: t.List[str], 274 cc: t.List[str], 275) -> State: 276 new_state: State = {} 277 seen: t.Set[str] = set() 278 for friendly_name, _sha, reverts in find_shas( 279 llvm_dir, interesting_shas, state, new_state 280 ): 281 if friendly_name in seen: 282 continue 283 seen.add(friendly_name) 284 for sha, reverted_sha in reverts: 285 try: 286 # We upload reverts for all platforms by default, since there's no 287 # real reason for them to be CrOS-specific. 288 get_upstream_patch.get_from_upstream( 289 chroot_path=chroot_path, 290 create_cl=True, 291 start_sha=reverted_sha, 292 patches=[sha], 293 reviewers=reviewers, 294 cc=cc, 295 platforms=(), 296 ) 297 except get_upstream_patch.CherrypickError as e: 298 logging.info("%s, skipping...", str(e)) 299 return new_state 300 301 302def do_email( 303 is_dry_run: bool, 304 llvm_dir: str, 305 repository: str, 306 interesting_shas: t.List[t.Tuple[str, str]], 307 state: State, 308 recipients: _EmailRecipients, 309) -> State: 310 def prettify_sha(sha: str) -> tiny_render.Piece: 311 rev = get_llvm_hash.GetVersionFrom(llvm_dir, sha) 312 313 # 12 is arbitrary, but should be unambiguous enough. 314 short_sha = sha[:12] 315 return tiny_render.Switch( 316 text=f"r{rev} ({short_sha})", 317 html=tiny_render.Link( 318 href="https://reviews.llvm.org/rG" + sha, inner="r" + str(rev) 319 ), 320 ) 321 322 def get_sha_description(sha: str) -> tiny_render.Piece: 323 return subprocess.check_output( 324 ["git", "log", "-n1", "--format=%s", sha], 325 cwd=llvm_dir, 326 encoding="utf-8", 327 ).strip() 328 329 new_state: State = {} 330 for friendly_name, sha, new_reverts in find_shas( 331 llvm_dir, interesting_shas, state, new_state 332 ): 333 email = _generate_revert_email( 334 repository, 335 friendly_name, 336 sha, 337 prettify_sha, 338 get_sha_description, 339 new_reverts, 340 ) 341 if is_dry_run: 342 logging.info( 343 "Would send email:\nSubject: %s\nBody:\n%s\n", 344 email.subject, 345 tiny_render.render_text_pieces(email.body), 346 ) 347 else: 348 logging.info("Sending email with subject %r...", email.subject) 349 _send_revert_email(recipients, email) 350 logging.info("Email sent.") 351 return new_state 352 353 354def parse_args(argv: t.List[str]) -> t.Any: 355 parser = argparse.ArgumentParser( 356 description=__doc__, 357 formatter_class=argparse.RawDescriptionHelpFormatter, 358 ) 359 parser.add_argument( 360 "action", 361 choices=["cherry-pick", "email", "dry-run"], 362 help="Automatically cherry-pick upstream reverts, send an email, or " 363 "write to stdout.", 364 ) 365 parser.add_argument( 366 "--state_file", required=True, help="File to store persistent state in." 367 ) 368 parser.add_argument( 369 "--llvm_dir", required=True, help="Up-to-date LLVM directory to use." 370 ) 371 parser.add_argument("--debug", action="store_true") 372 parser.add_argument( 373 "--reviewers", 374 type=str, 375 nargs="*", 376 help="Requests reviews from REVIEWERS. All REVIEWERS must have existing " 377 "accounts.", 378 ) 379 parser.add_argument( 380 "--cc", 381 type=str, 382 nargs="*", 383 help="CCs the CL to the recipients. All recipients must have existing " 384 "accounts.", 385 ) 386 387 subparsers = parser.add_subparsers(dest="repository") 388 subparsers.required = True 389 390 chromeos_subparser = subparsers.add_parser("chromeos") 391 chromeos_subparser.add_argument( 392 "--chromeos_dir", 393 required=True, 394 help="Up-to-date CrOS directory to use.", 395 ) 396 397 android_subparser = subparsers.add_parser("android") 398 android_subparser.add_argument( 399 "--android_llvm_toolchain_dir", 400 required=True, 401 help="Up-to-date android-llvm-toolchain directory to use.", 402 ) 403 404 return parser.parse_args(argv) 405 406 407def find_chroot( 408 opts: t.Any, reviewers: t.List[str], cc: t.List[str] 409) -> t.Tuple[str, t.List[t.Tuple[str, str]], _EmailRecipients]: 410 recipients = reviewers + cc 411 if opts.repository == "chromeos": 412 chroot_path = opts.chromeos_dir 413 return ( 414 chroot_path, 415 _find_interesting_chromeos_shas(chroot_path), 416 _EmailRecipients(well_known=["mage"], direct=recipients), 417 ) 418 elif opts.repository == "android": 419 if opts.action == "cherry-pick": 420 raise RuntimeError( 421 "android doesn't currently support automatic cherry-picking." 422 ) 423 424 chroot_path = opts.android_llvm_toolchain_dir 425 return ( 426 chroot_path, 427 _find_interesting_android_shas(chroot_path), 428 _EmailRecipients( 429 well_known=[], 430 direct=["android-llvm-dev@google.com"] + recipients, 431 ), 432 ) 433 else: 434 raise ValueError(f"Unknown repository {opts.repository}") 435 436 437def main(argv: t.List[str]) -> int: 438 opts = parse_args(argv) 439 440 logging.basicConfig( 441 format="%(asctime)s: %(levelname)s: %(filename)s:%(lineno)d: %(message)s", 442 level=logging.DEBUG if opts.debug else logging.INFO, 443 ) 444 445 action = opts.action 446 llvm_dir = opts.llvm_dir 447 repository = opts.repository 448 state_file = opts.state_file 449 reviewers = opts.reviewers if opts.reviewers else [] 450 cc = opts.cc if opts.cc else [] 451 452 chroot_path, interesting_shas, recipients = find_chroot(opts, reviewers, cc) 453 logging.info("Interesting SHAs were %r", interesting_shas) 454 455 state = _read_state(state_file) 456 logging.info("Loaded state\n%s", pprint.pformat(state)) 457 458 # We want to be as free of obvious side-effects as possible in case something 459 # above breaks. Hence, action as late as possible. 460 if action == "cherry-pick": 461 new_state = do_cherrypick( 462 chroot_path=chroot_path, 463 llvm_dir=llvm_dir, 464 interesting_shas=interesting_shas, 465 state=state, 466 reviewers=reviewers, 467 cc=cc, 468 ) 469 else: 470 new_state = do_email( 471 is_dry_run=action == "dry-run", 472 llvm_dir=llvm_dir, 473 repository=repository, 474 interesting_shas=interesting_shas, 475 state=state, 476 recipients=recipients, 477 ) 478 479 _write_state(state_file, new_state) 480 return 0 481 482 483if __name__ == "__main__": 484 sys.exit(main(sys.argv[1:])) 485