blob: b1243ee94638dd3ae6cf09b4dfa072e5863dfef2 [file] [edit]
# /// script
# requires-python = ">=3.13"
# dependencies = []
# ///
"""Request a reviewer from repository pools using shared round-robin credits."""
from __future__ import annotations
import argparse
import json
import math
import os
import random
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.error import HTTPError
from urllib.request import Request, urlopen
REPOSITORY = "astral-sh/ruff"
REPOSITORY_ID = 523043277
API_ROOT = f"https://api.github.com/repos/{REPOSITORY}"
State = dict[str, dict[str, float]]
@dataclass(frozen=True)
class Pool:
name: str
patterns: tuple[str, ...]
reviewers: tuple[str, ...]
def api(endpoint: str, *, data: dict | None = None) -> Any:
request = Request(
f"{API_ROOT}/{endpoint}",
data=json.dumps(data).encode() if data is not None else None,
headers={
"Authorization": f"Bearer {os.environ['GH_TOKEN']}",
"Accept": "application/vnd.github+json",
"Content-Type": "application/json",
"X-GitHub-Api-Version": "2022-11-28",
},
method="POST" if data is not None else "GET",
)
with urlopen(request, timeout=30) as response:
return json.load(response)
def changed_files(pull_request: int) -> list[str]:
files = []
page = 1
while True:
batch = api(f"pulls/{pull_request}/files?per_page=100&page={page}")
files.extend(item["filename"] for item in batch)
if len(batch) < 100:
return files
page += 1
def parse_pools(contents: str) -> list[Pool]:
return [
Pool(
name=pool["name"],
patterns=tuple(path.lstrip("/") for path in pool["paths"]),
reviewers=tuple(pool["reviewers"]),
)
for pool in tomllib.loads(contents)["pools"]
]
def matching_pools(pools: list[Pool], files: list[str]) -> list[tuple[Pool, int]]:
matched = []
for pool in pools:
weight = sum(
any(
PurePosixPath(filename).full_match(pattern) for pattern in pool.patterns
)
for filename in files
)
if weight:
matched.append((pool, weight))
return matched
def parse_state(contents: str) -> State:
parsed = json.loads(contents)
if not isinstance(parsed, dict):
raise TypeError("Reviewer state must be an object.")
state: State = {}
for name, value in parsed.items():
if not isinstance(value, dict):
raise TypeError(f"Reviewer state for pool {name} must be an object.")
credits = {}
for login, credit in value.items():
if type(credit) not in (int, float) or not math.isfinite(credit):
raise ValueError(f"Invalid reviewer credit in pool {name}.")
credits[login] = float(credit)
state[name] = credits
return state
def load_state(path: Path) -> State:
if path.exists():
return parse_state(path.read_text())
return {}
def eligible_pull(pull: dict, pull_request: int, head_sha: str) -> bool:
return (
pull["number"] == pull_request
and pull["state"] == "open"
and not pull["draft"]
and pull["base"]["repo"]["id"] == REPOSITORY_ID
and (pull["head"]["repo"] or {}).get("id") == REPOSITORY_ID
and pull["head"]["sha"] == head_sha
)
def has_reviewer(pull: dict) -> bool:
author = pull["user"]["login"].lower()
return any(user["login"].lower() != author for user in pull["requested_reviewers"])
def assign_reviewer(
pull_request: int,
head_sha: str,
pools: list[Pool],
state: State,
*,
dry_run: bool = False,
rng: random.Random | None = None,
) -> tuple[str, str] | None:
rng = rng or random.Random()
pull = api(f"pulls/{pull_request}")
if not eligible_pull(pull, pull_request, head_sha) or has_reviewer(pull):
return None
author = pull["user"]["login"].lower()
matched = matching_pools(pools, changed_files(pull_request))
while matched:
eligible_pools, weights = zip(*matched, strict=True)
pool = rng.choices(eligible_pools, weights=weights)[0]
stored = state.get(pool.name, {})
credits = {
login: min(1.0, stored.get(login, 0.0) + 1 / len(pool.reviewers))
for login in pool.reviewers
}
candidates = [login for login in pool.reviewers if login.lower() != author]
while candidates:
highest = max(credits[login] for login in candidates)
reviewer = rng.choice(
[login for login in candidates if credits[login] == highest]
)
candidates.remove(reviewer)
current = api(f"pulls/{pull_request}")
if not eligible_pull(current, pull_request, head_sha) or has_reviewer(
current
):
return None
if dry_run:
print(f"Would request review from {reviewer} in pool {pool.name}.")
return pool.name, reviewer
try:
api(
f"pulls/{pull_request}/requested_reviewers",
data={"reviewers": [reviewer]},
)
except HTTPError as error:
error.close()
if error.code == 422:
print(f"GitHub rejected reviewer {reviewer}.", file=sys.stderr)
continue
raise
credits[reviewer] = max(-1.0, credits[reviewer] - 1)
state[pool.name] = credits
print(f"Requested review from {reviewer} in pool {pool.name}.")
return pool.name, reviewer
matched = [entry for entry in matched if entry[0] != pool]
return None
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pull-request", required=True, type=int)
parser.add_argument("--head-sha", required=True)
parser.add_argument("--state", required=True, type=Path)
parser.add_argument("--output", type=Path)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if os.environ.get("GITHUB_REPOSITORY", REPOSITORY) != REPOSITORY:
parser.error(f"This script only supports {REPOSITORY}.")
if args.pull_request <= 0:
parser.error("The pull request number must be positive.")
if not args.dry_run and args.output is None:
parser.error("--output is required when requesting a review.")
pools = parse_pools(Path(".github/pr-reviewer-pools.toml").read_text())
state = load_state(args.state)
assign_reviewer(
args.pull_request, args.head_sha, pools, state, dry_run=args.dry_run
)
if not args.dry_run:
# Rewriting unchanged state also renews its retention after a no-op request.
args.output.write_text(json.dumps(state, sort_keys=True, separators=(",", ":")))
if __name__ == "__main__":
main()