| #!/usr/bin/env fuchsia-vendored-python |
| # Copyright 2026 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Suggests the smallest set of code owners that covers every file in a commit. |
| |
| Gets each changed file's owners from the Gerrit code-owners plugin, then |
| searches for the fewest owners who together cover every file. When several |
| sets are equally small, it prefers owners from closer OWNERS files. Pass --all |
| to see every smallest set. |
| |
| If more than 5 reviewers are needed, it points to the docs for asking for an |
| owners override. |
| |
| Ties between equally good owners are broken randomly to spread out review |
| load. The randomness is seeded by today's date and the commit author, so |
| repeated runs on the same day give the same answer. (Very large changes fall |
| back to a simpler search that doesn't do this.) |
| |
| Root OWNERS members can approve anything, so they'd always be picked. To avoid |
| that, they're only used for files that have no other owners, unless you pass |
| --include-root. Owners whose calendar says they're out of office, and who won't |
| be back within 4 hours, are skipped the same way. That check uses the prebuilt |
| `luci-auth` (or one on PATH), which works without logging in on cloudtops and |
| needs `luci-auth login -scopes-gerrit` elsewhere; without it, the check is |
| skipped with a note. |
| """ |
| |
| import argparse |
| import collections |
| import concurrent.futures |
| import datetime |
| import json |
| import os |
| import random |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import time |
| import urllib.error |
| import urllib.parse |
| import urllib.request |
| from typing import AbstractSet, Any, Callable, Mapping, TypeVar |
| |
| # Gerrit prefixes JSON responses with this to prevent XSSI. |
| XSSI_PREFIX = ")]}'" |
| |
| # A nonexistent top-level path only matches root OWNERS, so its owners are |
| # exactly the root owners. |
| ROOT_PROBE_PATH = "__min_owners_nonexistent__" |
| |
| HTTP_TIMEOUT_SECS = 30 |
| |
| GERRIT_OAUTH_SCOPE = "https://www.googleapis.com/auth/gerritcodereview" |
| |
| # Pinned in //manifests/prebuilts. This file is in //tools/devshell/contrib. |
| PREBUILT_LUCI_AUTH = os.path.join( |
| os.path.dirname(os.path.abspath(__file__)), |
| "../../../prebuilt/tools/luci-auth/luci-auth", |
| ) |
| |
| # Keeps availability request URLs well under Gerrit's URL length limit. |
| AVAILABILITY_BATCH_SIZE = 100 |
| |
| # Short OOO blocks (appointments, errands) shouldn't stop someone from getting |
| # a review they'll see later the same day. |
| OOO_GRACE_SECS = 4 * 60 * 60 |
| |
| # Past this many reviewers, collecting approvals is enough of a burden that an |
| # owners override is worth considering, per the OWNERS docs. |
| OVERRIDE_SUGGESTION_THRESHOLD = 5 |
| OWNERS_OVERRIDE_DOCS = ( |
| "https://fuchsia.dev/fuchsia-src/development/source_code/owners" |
| "#owners-override" |
| ) |
| |
| Getter = Callable[[str], Any] |
| T = TypeVar("T") |
| |
| |
| def git(*args: str) -> str: |
| return subprocess.check_output(["git", *args], text=True).strip() |
| |
| |
| def parse_name_status(out: str) -> list[str]: |
| """Returns the paths in `git diff --name-status -z` output.""" |
| files: set[str] = set() |
| fields = iter(out.split("\0")) |
| for status in fields: |
| if not status: |
| continue |
| files.add(next(fields)) |
| # Renames and copies list a second path. Both need owner approval. |
| if status[0] in "RC": |
| files.add(next(fields)) |
| return sorted(files) |
| |
| |
| def diff_args(rev: str) -> list[str]: |
| """Returns the `git diff` revision args for a commit or range.""" |
| if ".." not in rev: |
| # Gerrit diffs a commit against its first parent, which also gives |
| # a useful file list for merge commits (unlike `rev^!`). |
| return [f"{rev}^1", rev] |
| # Use the merge base so that `main..HEAD` only counts this branch's |
| # changes, even if local main has moved on since the branch was cut. |
| base, tip = re.split(r"\.\.\.?", rev, maxsplit=1) |
| return [f"{base or 'HEAD'}...{tip or 'HEAD'}"] |
| |
| |
| def changed_files(rev: str) -> list[str]: |
| out = subprocess.check_output( |
| ["git", "diff", "--name-status", "-M", "-z", *diff_args(rev)], text=True |
| ) |
| return parse_name_status(out) |
| |
| |
| def parse_remote_url(url: str) -> tuple[str, str]: |
| """Returns the Gerrit review host URL and project for a git remote URL.""" |
| # Matches e.g. https://fuchsia.googlesource.com/fuchsia and the short |
| # sso://turquoise-internal/vendor/google form. |
| m = re.match( |
| r"(?:https?|sso|rpc)://([\w-]+)" |
| r"(?:\.(?:git\.corp\.google|googlesource)\.com)?" |
| r"/(?:a/)?(.+?)(?:\.git)?/?$", |
| url, |
| ) |
| if not m: |
| raise ValueError(f"Can't figure out the Gerrit host from {url!r}") |
| return f"https://{m.group(1)}-review.googlesource.com", m.group(2) |
| |
| |
| def parse_json(body: str) -> Any: |
| if not body.startswith(XSSI_PREFIX): |
| raise RuntimeError(f"Unexpected Gerrit response: {body[:200]}") |
| return json.loads(body[len(XSSI_PREFIX) :]) |
| |
| |
| def gerrit_getter(host: str) -> Getter: |
| """Returns a function that GETs a Gerrit REST path and returns its JSON. |
| |
| Public hosts like fuchsia-review work anonymously. Private hosts like |
| turquoise-internal-review redirect anonymous requests to a login page or |
| reject them, so those go through gob-curl, which handles SSO auth on the |
| /a/ prefix. |
| """ |
| |
| def anonymous(path: str) -> Any: |
| url = host + path |
| try: |
| with urllib.request.urlopen(url, timeout=HTTP_TIMEOUT_SECS) as resp: |
| return parse_json(resp.read().decode()) |
| except urllib.error.URLError as e: |
| raise RuntimeError(f"GET {url} failed: {e}") from e |
| |
| def authenticated(path: str) -> Any: |
| url = f"{host}/a{path}" |
| proc = subprocess.run( |
| ["gob-curl", "--silent", "--show-error", "--fail", url], |
| capture_output=True, |
| text=True, |
| ) |
| if proc.returncode: |
| raise RuntimeError(f"gob-curl {url} failed: {proc.stderr[:200]}") |
| return parse_json(proc.stdout) |
| |
| probe = host + "/config/server/version" |
| try: |
| with urllib.request.urlopen(probe, timeout=HTTP_TIMEOUT_SECS) as resp: |
| # Hosts that need auth either redirect anonymous requests to a |
| # login page or reject them outright. |
| needs_auth = resp.url != probe |
| except urllib.error.HTTPError as e: |
| e.close() |
| if e.code not in (401, 403): |
| raise |
| needs_auth = True |
| if not needs_auth: |
| return anonymous |
| if not shutil.which("gob-curl"): |
| sys.exit(f"{host} needs auth, but gob-curl isn't on PATH") |
| return authenticated |
| |
| |
| def fetch_owners( |
| get: Getter, |
| project: str, |
| branch: str, |
| path: str, |
| account_ids: dict[str, int] | None = None, |
| ) -> dict[str, int] | None: |
| """Returns {owner email: distance} for a path, or None if anyone owns it. |
| |
| Also adds each owner's Gerrit account ID to `account_ids`, if given. |
| """ |
| data = get( |
| "/projects/{}/branches/{}/code_owners/{}?o=DETAILS&limit=1000".format( |
| urllib.parse.quote(project, safe=""), |
| urllib.parse.quote(branch, safe=""), |
| urllib.parse.quote(path, safe=""), |
| ) |
| ) |
| if data.get("owned_by_all_users"): |
| return None |
| owners: dict[str, int] = {} |
| for o in data.get("code_owners", []): |
| account = o["account"] |
| if "email" not in account: |
| continue |
| owners[account["email"]] = o.get("scorings", {}).get("DISTANCE", 0) |
| if account_ids is not None and "_account_id" in account: |
| account_ids[account["email"]] = account["_account_id"] |
| return owners |
| |
| |
| def luci_auth_token() -> str: |
| # Fall back to PATH for checkouts that haven't fetched the prebuilt yet. |
| luci_auth = ( |
| PREBUILT_LUCI_AUTH |
| if os.path.exists(PREBUILT_LUCI_AUTH) |
| else shutil.which("luci-auth") |
| ) |
| if not luci_auth: |
| raise RuntimeError("luci-auth isn't in prebuilt/tools or on PATH") |
| proc = subprocess.run( |
| [luci_auth, "token", "-scopes", GERRIT_OAUTH_SCOPE], |
| capture_output=True, |
| text=True, |
| ) |
| if proc.returncode: |
| msg = proc.stderr.strip().splitlines()[-1:] or ["unknown error"] |
| raise RuntimeError(f"luci-auth token failed: {msg[0]}") |
| return proc.stdout.strip() |
| |
| |
| def out_of_office( |
| host: str, |
| account_ids: Mapping[str, int], |
| token: str, |
| now: float | None = None, |
| ) -> set[str]: |
| """Returns the emails of accounts whose calendar says they're OOO. |
| |
| People who'll be back within OOO_GRACE_SECS don't count. This uses |
| Gerrit's Google-only availability plugin, which needs a Gaia OAuth token. |
| gob-curl's credentials don't work for it. |
| """ |
| if now is None: |
| now = time.time() |
| emails = {i: e for e, i in account_ids.items()} |
| ids = sorted(emails) |
| ooo: set[str] = set() |
| for start in range(0, len(ids), AVAILABILITY_BATCH_SIZE): |
| query = urllib.parse.urlencode( |
| [("id", i) for i in ids[start : start + AVAILABILITY_BATCH_SIZE]] |
| ) |
| req = urllib.request.Request( |
| f"{host}/a/plugins/availability/statuses/?{query}", |
| headers={"Authorization": f"Bearer {token}"}, |
| ) |
| try: |
| with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SECS) as resp: |
| statuses = parse_json(resp.read().decode()) |
| for s in statuses: |
| # OUTSIDE_WORKING_HOURS is ignored on purpose: it's often just |
| # a different time zone, and they'll be back within a day. |
| if s["status"] != "OUT_OF_OFFICE": |
| continue |
| back = s.get("return_time", {}).get("seconds") |
| if back is not None and back - now < OOO_GRACE_SECS: |
| continue |
| ooo.add(emails[s["account_id"]]) |
| # This check is best-effort, so any failure (including timeouts, |
| # which aren't URLErrors, and odd responses) just turns it off. |
| except (OSError, RuntimeError, ValueError, LookupError, TypeError) as e: |
| raise RuntimeError(f"availability lookup failed: {e!r}") from e |
| return ooo |
| |
| |
| def prefer( |
| owners: dict[str, int], keep: Callable[[str], bool] |
| ) -> dict[str, int]: |
| """Returns the owners that pass `keep`, or all of them if none do.""" |
| return {o: d for o, d in owners.items() if keep(o)} or owners |
| |
| |
| def owners_to_cover( |
| file_owners: Mapping[str, Mapping[str, int] | None], |
| root_owners: set[str], |
| author: str, |
| include_root: bool, |
| unavailable: AbstractSet[str] = frozenset(), |
| ) -> dict[str, dict[str, int]]: |
| """Drops files that need no extra approval, and owners we'd rather skip. |
| |
| Unavailable owners, and root owners unless `include_root` is set, are |
| only kept for files that nobody else can approve. |
| """ |
| result: dict[str, dict[str, int]] = {} |
| for f, owners in file_owners.items(): |
| # None means anyone can approve, and the author implicitly approves |
| # files they own, so neither needs another reviewer. |
| if owners is None or author in owners: |
| continue |
| # Availability is checked first because an available root owner is a |
| # better pick than one who's out of office. |
| kept = prefer(dict(owners), lambda o: o not in unavailable) |
| if not include_root: |
| kept = prefer(kept, lambda o: o not in root_owners) |
| result[f] = kept |
| return result |
| |
| |
| def plural(n: int, noun: str) -> str: |
| return f"{n} {noun}" if n == 1 else f"{n} {noun}s" |
| |
| |
| def owner_distances(file_owners: dict[str, dict[str, int]]) -> dict[str, int]: |
| """Returns each owner's total distance over the files they own.""" |
| distance: dict[str, int] = collections.defaultdict(int) |
| for owners in file_owners.values(): |
| for owner, dist in owners.items(): |
| distance[owner] += dist |
| return distance |
| |
| |
| def greedy_cover( |
| file_owners: dict[str, dict[str, int]], |
| ) -> tuple[list[tuple[str, list[str]]], list[str]]: |
| """Picks owners until every file is covered. |
| |
| Returns the picked owners with the files each one covers, plus any files |
| that have no owners at all. |
| """ |
| uncovered = set(file_owners) |
| picks = [] |
| while uncovered: |
| covers: dict[str, list[str]] = collections.defaultdict(list) |
| distance: dict[str, int] = collections.defaultdict(int) |
| for f in uncovered: |
| for owner, dist in file_owners[f].items(): |
| covers[owner].append(f) |
| distance[owner] += dist |
| if not covers: |
| break |
| owner = min(covers, key=lambda o: (-len(covers[o]), distance[o], o)) |
| picks.append((owner, sorted(covers[owner]))) |
| uncovered -= set(covers[owner]) |
| return picks, sorted(uncovered) |
| |
| |
| # A group of interchangeable owners (they cover exactly the same files), and |
| # the files they cover. |
| OwnerGroup = tuple[list[str], list[str]] |
| |
| |
| def all_min_covers( |
| file_owners: dict[str, dict[str, int]], |
| max_steps: int = 200_000, |
| ) -> list[list[OwnerGroup]] | None: |
| """Returns every smallest set of owners that covers all ownable files. |
| |
| Owners who cover exactly the same files are interchangeable, so they're |
| merged into one group. Otherwise a few directories with several owners |
| each would multiply into hundreds of near-identical answers. Each answer |
| is a list of groups; picking any one owner from each group gives a |
| minimal set. |
| |
| Finding the minimum is NP-hard, so this gives up and returns None after |
| `max_steps` search steps. That only happens for very large changes. |
| """ |
| covers: dict[str, set[str]] = collections.defaultdict(set) |
| for f, owners in file_owners.items(): |
| for owner in owners: |
| covers[owner].add(f) |
| distance = owner_distances(file_owners) |
| |
| group_owners: dict[frozenset[str], list[str]] = collections.defaultdict( |
| list |
| ) |
| for owner, owned in covers.items(): |
| group_owners[frozenset(owned)].append(owner) |
| groups = list(group_owners) |
| groups_for_file: dict[str, list[int]] = collections.defaultdict(list) |
| for i, group in enumerate(groups): |
| for f in group: |
| groups_for_file[f].append(i) |
| |
| # The greedy answer is an upper bound on the minimum size, which prunes |
| # most of the search. |
| best = len(greedy_cover(file_owners)[0]) |
| found: set[frozenset[int]] = set() |
| largest = max((len(g) for g in groups), default=1) |
| steps = 0 |
| |
| class TooBig(Exception): |
| pass |
| |
| def search(uncovered: frozenset[str], chosen: frozenset[int]) -> None: |
| nonlocal best, steps |
| steps += 1 |
| if steps > max_steps: |
| raise TooBig() |
| if not uncovered: |
| if len(chosen) < best: |
| best = len(chosen) |
| found.clear() |
| found.add(chosen) |
| return |
| # Each extra group covers at most `largest` files. |
| if len(chosen) + -(-len(uncovered) // largest) > best: |
| return |
| # Branch on the file with the fewest options to keep the tree small. |
| f = min(uncovered, key=lambda f: len(groups_for_file[f])) |
| for i in groups_for_file[f]: |
| search(uncovered - groups[i], chosen | {i}) |
| |
| ownable = frozenset(f for f, owners in file_owners.items() if owners) |
| try: |
| search(ownable, frozenset()) |
| # The recursion depth is bounded by the greedy answer's size, so it only |
| # gets too deep for changes that would need hundreds of reviewers. |
| except (TooBig, RecursionError): |
| return None |
| |
| def owner_key(o: str) -> tuple[int, str]: |
| return distance[o], o |
| |
| results = [ |
| sorted( |
| (sorted(group_owners[groups[i]], key=owner_key), sorted(groups[i])) |
| for i in cover |
| ) |
| for cover in found |
| ] |
| # Show answers with closer owners first. |
| results.sort( |
| key=lambda cover: ( |
| sum(distance[owners[0]] for owners, _ in cover), |
| cover, |
| ) |
| ) |
| return results |
| |
| |
| def pick_reviewers( |
| covers: list[list[OwnerGroup]], |
| distance: Mapping[str, int], |
| rng: random.Random, |
| ) -> list[tuple[str, list[str]]]: |
| """Picks one owner from each group of one of the smallest covers. |
| |
| Closer owners still win, but ties are broken randomly so the same few |
| people (e.g. whoever sorts first alphabetically) don't get every review. |
| """ |
| |
| def closest(items: list[T], dist: Callable[[T], int]) -> T: |
| best = min(dist(x) for x in items) |
| return rng.choice([x for x in items if dist(x) == best]) |
| |
| cover = closest( |
| covers, lambda c: sum(distance[owners[0]] for owners, _ in c) |
| ) |
| picks = [ |
| (closest(owners, lambda o: distance[o]), covered) |
| for owners, covered in cover |
| ] |
| return sorted(picks, key=lambda p: (-len(p[1]), p[0])) |
| |
| |
| def seeded_rng(author: str) -> random.Random: |
| # Seeding with the date keeps reruns stable within a day, and the author |
| # stops everyone's changes from going to the same people on a given day. |
| return random.Random(f"{datetime.date.today().isoformat()} {author}") |
| |
| |
| def suggest( |
| file_owners: dict[str, dict[str, int]], author: str |
| ) -> tuple[list[tuple[str, list[str]]], list[list[OwnerGroup]] | None]: |
| """Returns the suggested owners, plus every smallest cover if known.""" |
| covers = all_min_covers(file_owners) |
| if covers is None: |
| return greedy_cover(file_owners)[0], None |
| rng = seeded_rng(author) |
| return pick_reviewers(covers, owner_distances(file_owners), rng), covers |
| |
| |
| def main() -> None: |
| parser = argparse.ArgumentParser( |
| prog="fx min-owners", |
| description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| ) |
| parser.add_argument( |
| "rev", |
| nargs="?", |
| default="HEAD", |
| help="commit, or range like main..HEAD (default: HEAD)", |
| ) |
| parser.add_argument( |
| "--branch", |
| default="main", |
| help="branch whose OWNERS files to use (default: main)", |
| ) |
| parser.add_argument( |
| "--include-root", |
| action="store_true", |
| help="consider root OWNERS members for every file, not just as a fallback", |
| ) |
| parser.add_argument( |
| "--author", |
| help="email that gets implicit approval (default: the commit author); " |
| "set this if your git email differs from your Gerrit account's", |
| ) |
| parser.add_argument( |
| "-v", |
| "--verbose", |
| action="store_true", |
| help="list the files each owner covers", |
| ) |
| parser.add_argument( |
| "--all", |
| action="store_true", |
| help="list every smallest set of owners instead of just one", |
| ) |
| args = parser.parse_args() |
| |
| try: |
| run(args) |
| except ( |
| RuntimeError, |
| ValueError, |
| urllib.error.URLError, |
| subprocess.CalledProcessError, |
| ) as e: |
| sys.exit(f"Error: {e}") |
| |
| |
| def run(args: argparse.Namespace) -> None: |
| files = changed_files(args.rev) |
| if not files: |
| sys.exit("No changed files.") |
| if any(f == "OWNERS" or f.endswith("/OWNERS") for f in files): |
| print( |
| f"Warning: owners come from OWNERS files on the '{args.branch}' " |
| "branch, so this commit's own OWNERS changes aren't taken into " |
| "account.\n", |
| file=sys.stderr, |
| ) |
| host, project = parse_remote_url(git("remote", "get-url", "origin")) |
| tip = re.split(r"\.\.\.?", args.rev)[-1] or "HEAD" |
| author = args.author or git("log", "-1", "--format=%ae", tip) |
| |
| get = gerrit_getter(host) |
| # Written from several threads, which is fine because each email always |
| # maps to the same ID. |
| account_ids: dict[str, int] = {} |
| with concurrent.futures.ThreadPoolExecutor(max_workers=16) as pool: |
| results = list( |
| pool.map( |
| lambda p: fetch_owners( |
| get, project, args.branch, p, account_ids |
| ), |
| files + [ROOT_PROBE_PATH], |
| ) |
| ) |
| *results, root = results |
| root_owners = set(root or {}) |
| file_owners = dict(zip(files, results)) |
| |
| to_cover = owners_to_cover( |
| file_owners, root_owners, author, args.include_root |
| ) |
| # Check everyone who could approve, not just the preferred owners, since |
| # root owners become the fallback if all the others are out. |
| to_check = { |
| o: account_ids[o] |
| for f in to_cover |
| for o in file_owners[f] or {} |
| if o in account_ids |
| } |
| ooo: set[str] = set() |
| if to_check: |
| try: |
| ooo = out_of_office(host, to_check, luci_auth_token()) |
| except RuntimeError as e: |
| print( |
| f"Note: couldn't check who's out of office: {e}\n", |
| file=sys.stderr, |
| ) |
| skipped_ooo: list[str] = [] |
| if ooo: |
| before, _ = suggest(to_cover, author) |
| to_cover = owners_to_cover( |
| file_owners, root_owners, author, args.include_root, ooo |
| ) |
| listed = {o for owners in to_cover.values() for o in owners} |
| skipped_ooo = sorted( |
| o for o, _ in before if o in ooo and o not in listed |
| ) |
| picks, covers = suggest(to_cover, author) |
| unowned = sorted(f for f, owners in to_cover.items() if not owners) |
| |
| skipped = len(files) - len(to_cover) |
| if skipped: |
| verb = "needs" if skipped == 1 else "need" |
| print( |
| f"({plural(skipped, 'file')} {verb} no extra owner: owned by " |
| f"{author} or by everyone)\n" |
| ) |
| |
| def describe(owner: str) -> str: |
| if owner in root_owners: |
| owner += " [root]" |
| if owner in ooo: |
| owner += " [OOO]" |
| return owner |
| |
| if covers is None: |
| print( |
| "Note: too many files to find the true minimum, so this list may " |
| "have more reviewers than needed.\n", |
| file=sys.stderr, |
| ) |
| if covers is not None and args.all: |
| if covers[0]: |
| print(f"The minimum number of owners is {len(covers[0])}.") |
| if len(covers) == 1: |
| print("Pick one owner from each group.") |
| else: |
| print( |
| f"There are {len(covers)} options; pick one owner from " |
| "each group of any option." |
| ) |
| for n, cover in enumerate(covers, 1): |
| if not cover: |
| continue |
| print(f"\nOption {n}:" if len(covers) > 1 else "") |
| for owners, covered in cover: |
| if len(owners) == 1: |
| print(f" - {plural(len(covered), 'file')}:") |
| else: |
| print(f" - {plural(len(covered), 'file')}, any one of:") |
| for owner in owners: |
| print(f" {describe(owner)}") |
| if args.verbose: |
| print(" Files:") |
| for f in covered: |
| print(f" {f}") |
| else: |
| for owner, covered in picks: |
| print(f"{describe(owner)}: {plural(len(covered), 'file')}") |
| if args.verbose: |
| for f in covered: |
| print(f" {f}") |
| if unowned: |
| print("\nNo owners found for:") |
| for f in unowned: |
| print(f" {f}") |
| if skipped_ooo: |
| print("\nSkipped because they're out of office:") |
| for owner in skipped_ooo: |
| print(f" {owner}") |
| if len(picks) > OVERRIDE_SUGGESTION_THRESHOLD: |
| print( |
| f"\nThat's {plural(len(picks), 'reviewer')}. If this change is " |
| "mostly mechanical, consider\nasking for an owners override " |
| "instead. See:\n" |
| f" {OWNERS_OVERRIDE_DOCS}" |
| ) |
| |
| |
| if __name__ == "__main__": |
| main() |