xref: /aosp_15_r20/external/toolchain-utils/auto_abandon_cls.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
1#!/usr/bin/env python3
2# Copyright 2024 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"""Abandons CLs from the current user that haven't been updated recently.
7
8Note that this needs to be run from inside a ChromeOS tree. Otherwise, the
9`gerrit` tool this depends on won't be found.
10"""
11
12import argparse
13import logging
14import subprocess
15import sys
16from typing import List
17
18
19def gerrit_cmd(internal: bool) -> List[str]:
20    cmd = ["gerrit"]
21    if internal:
22        cmd.append("--internal")
23    return cmd
24
25
26def enumerate_old_cls(old_days: int, internal: bool) -> List[int]:
27    """Returns CL numbers that haven't been updated in `old_days` days."""
28    stdout = subprocess.run(
29        gerrit_cmd(internal)
30        + ["--raw", "search", f"owner:me status:open age:{old_days}d"],
31        check=True,
32        stdin=subprocess.DEVNULL,
33        stdout=subprocess.PIPE,
34        encoding="utf-8",
35    ).stdout
36    # Sort for prettier output; it's unclear if Gerrit always sorts, and it's
37    # cheap.
38    lines = stdout.splitlines()
39    if internal:
40        # These are printed as `chrome-internal:NNNN`, rather than `NNNN`.
41        chrome_internal_prefix = "chrome-internal:"
42        assert all(x.startswith(chrome_internal_prefix) for x in lines), lines
43        lines = [x[len(chrome_internal_prefix) :] for x in lines]
44    return sorted(int(x) for x in lines)
45
46
47def abandon_cls(cls: List[int], internal: bool) -> None:
48    subprocess.run(
49        gerrit_cmd(internal) + ["abandon"] + [str(x) for x in cls],
50        check=True,
51        stdin=subprocess.DEVNULL,
52    )
53
54
55def detect_and_abandon_cls(
56    old_days: int, dry_run: bool, internal: bool
57) -> None:
58    old_cls = enumerate_old_cls(old_days, internal)
59    if not old_cls:
60        logging.info("No CLs less than %d days old found; quit", old_days)
61        return
62
63    cl_namespace = "i" if internal else "c"
64    logging.info(
65        "Abandoning CLs: %s", [f"crrev.com/{cl_namespace}/{x}" for x in old_cls]
66    )
67    if dry_run:
68        logging.info("--dry-run specified; skip the actual abandon part")
69        return
70
71    abandon_cls(old_cls, internal)
72
73
74def main(argv: List[str]) -> None:
75    logging.basicConfig(
76        format=">> %(asctime)s: %(levelname)s: %(filename)s:%(lineno)d: "
77        "%(message)s",
78        level=logging.INFO,
79    )
80
81    parser = argparse.ArgumentParser(
82        description=__doc__,
83        formatter_class=argparse.RawDescriptionHelpFormatter,
84    )
85    parser.add_argument(
86        "--old-days",
87        default=14,
88        type=int,
89        help="""
90        How many days a CL needs to go without modification to be considered
91        'old'.
92        """,
93    )
94    parser.add_argument(
95        "--dry-run",
96        action="store_true",
97        help="Don't actually run the abandon command.",
98    )
99    opts = parser.parse_args(argv)
100
101    logging.info("Checking for external CLs...")
102    detect_and_abandon_cls(
103        old_days=opts.old_days,
104        dry_run=opts.dry_run,
105        internal=False,
106    )
107    logging.info("Checking for internal CLs...")
108    detect_and_abandon_cls(
109        old_days=opts.old_days,
110        dry_run=opts.dry_run,
111        internal=True,
112    )
113
114
115if __name__ == "__main__":
116    main(sys.argv[1:])
117