• 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 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