blob: 5df7474eb8869091b26d4b12115f621dbe5da47f [file]
#!/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()