Files
AnyPS5/tools/pr_ready.py
T
klosixgaming-arch c54380f3e1 fix(tools): count only build jobs as a build in the review queue
Ignores the progress report like the other bot jobs, removes the label from closed and merged pull requests and leaves the queue issue alone when its list has not changed.
2026-10-31 09:35:12 -05:00

247 lines
10 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import argparse
import json
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import pr_labels
QUEUE_LABEL = "review queue"
UNBLOCKS_LABEL = pr_labels.UNBLOCKS_LABEL
QUEUE_SIZE = 30
BLOCKING_LABELS = {"needs rebase", "perf numbers missing", "open PR limit"}
CONVENTIONS_CHECK = "conventions"
BOT_CHECKS = {"intake", "labels", "eligibility", "report"}
BUILD_CHECKS = {"linux", "windows", "relinker", "relinker-asan", "register-context", "write-watch"}
FAILED = {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale", "error"}
PASSED = {"success", "neutral"}
MARKER = "<!-- pr-review-queue -->"
WRITE_PAUSE = 1.0
PAGE_SIZE = 25
QUERY = """
query($owner: String!, $name: String!, $after: String, $size: Int!) {
repository(owner: $owner, name: $name) {
pullRequests(states: OPEN, baseRefName: "main", first: $size, after: $after) {
pageInfo { hasNextPage endCursor }
nodes {
number title isDraft mergeable headRefOid createdAt
author { login }
labels(first: 50) { nodes { name } }
reviewRequests(first: 50) { nodes { requestedReviewer { ... on User { login } } } }
latestOpinionatedReviews(first: 50) { nodes { state author { login } } }
commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) {
pageInfo { hasNextPage }
nodes {
__typename
... on CheckRun { databaseId name status conclusion }
... on StatusContext { context state }
}
} } } } }
}
}
}
}
"""
CLOSED_QUERY = """
query($owner: String!, $name: String!, $label: String!) {
repository(owner: $owner, name: $name) {
pullRequests(states: [CLOSED, MERGED], labels: [$label], first: 100) { nodes { number } }
}
}
"""
def listed(connection):
if not isinstance(connection, dict) or not isinstance(connection.get("nodes"), list):
raise ValueError("expected a connection with nodes")
return connection["nodes"]
def check_of(node):
if not isinstance(node, dict):
raise ValueError("check is not an object")
if node.get("__typename") == "StatusContext":
state = str(node.get("state") or "").lower()
done = state not in {"pending", "expected"}
return {"id": 0, "name": node.get("context"), "done": done,
"conclusion": {"success": "success"}.get(state, "failure") if done else None}
return {"id": node.get("databaseId") or 0, "name": node.get("name"),
"done": str(node.get("status") or "").lower() == "completed",
"conclusion": str(node.get("conclusion") or "").lower() or None}
def pull(node):
if not isinstance(node, dict) or not isinstance(node.get("number"), int):
raise ValueError("pull request needs a number")
if not isinstance(node.get("isDraft"), bool):
raise ValueError(f"#{node['number']}: draft flag must be a boolean")
commits = listed(node.get("commits"))
rollup = commits[0]["commit"].get("statusCheckRollup") if commits else None
contexts = (rollup or {}).get("contexts") or {}
author = (node.get("author") or {}).get("login") or "ghost"
reviews = [(item.get("author") or {}).get("login") or "ghost" for item in listed(node.get("latestOpinionatedReviews"))
if item.get("state") == "CHANGES_REQUESTED"]
requested = {(item.get("requestedReviewer") or {}).get("login") for item in listed(node.get("reviewRequests"))}
return {
"number": node["number"],
"title": node.get("title") or "",
"author": author,
"draft": node["isDraft"],
"mergeable": node.get("mergeable"),
"created": node.get("createdAt") or "",
"labels": {item["name"] for item in listed(node.get("labels"))},
"changes": sorted(set(reviews) - requested),
"checks": [check_of(item) for item in listed(contexts)] if contexts else [],
"checks_incomplete": bool((contexts.get("pageInfo") or {}).get("hasNextPage")),
}
def latest_checks(checks):
latest = {}
for check in checks:
name = check["name"]
if name not in latest or check["id"] > latest[name]["id"]:
latest[name] = check
return [check for name, check in latest.items() if name not in BOT_CHECKS]
def blockers(pr):
reasons = []
if pr["draft"]:
reasons.append("draft")
if pr["mergeable"] == "CONFLICTING" or "needs rebase" in pr["labels"]:
reasons.append("conflicts with main")
reasons += [f"label: {label}" for label in sorted(BLOCKING_LABELS - {"needs rebase"} & pr["labels"])]
checks = latest_checks(pr["checks"])
by_name = {check["name"]: check for check in checks}
if pr["checks_incomplete"]:
reasons.append("too many checks to read")
if CONVENTIONS_CHECK not in by_name:
reasons.append("conventions has not run")
elif by_name[CONVENTIONS_CHECK]["done"] and by_name[CONVENTIONS_CHECK]["conclusion"] not in FAILED | {"success"}:
reasons.append("conventions did not pass")
failing = sorted(check["name"] for check in checks if check["done"] and check["conclusion"] in FAILED)
running = sorted(check["name"] for check in checks if not check["done"])
if failing:
reasons.append("failing: " + ", ".join(failing))
if running:
reasons.append("still running: " + ", ".join(running))
if pr["changes"]:
reasons.append("change request not answered by a re-request: " + ", ".join(pr["changes"]))
return reasons
def built(pr):
return any(check["conclusion"] in PASSED for check in latest_checks(pr["checks"])
if str(check["name"]).split(" (")[0] in BUILD_CHECKS)
def ranked(prs):
ready = [pr for pr in prs if not blockers(pr)]
return sorted(ready, key=lambda pr: (UNBLOCKS_LABEL not in pr["labels"], pr["created"], pr["number"]))
def clean(text):
text = "".join(ch for ch in text if ch.isprintable()).replace("@", "@​")
for char in "[]|`<>*_~":
text = text.replace(char, "\\" + char)
return text[:100]
def queue_body(queue, total, ready):
lines = [MARKER, f"Ready for a maintainer first look: the oldest {len(queue)} of {ready} pull requests that pass checks "
f"({total} open). Pull requests with an `{UNBLOCKS_LABEL}` label come first.", ""]
for index, pr in enumerate(queue, 1):
flag = (" (unblocks a title)" if UNBLOCKS_LABEL in pr["labels"] else "") + ("" if built(pr) else " (no build ran)")
lines.append(f"{index}. #{pr['number']} {clean(pr['title'])} by {clean(pr['author'])}{flag}")
return "\n".join(lines) + "\n"
def fetch(repo):
owner, name = repo.split("/", 1)
prs, after = [], None
while True:
args = ["api", "graphql", "-f", f"query={QUERY}", "-F", f"owner={owner}", "-F", f"name={name}", "-F", f"size={PAGE_SIZE}"]
if after:
args += ["-f", f"after={after}"]
page = json.loads(pr_labels.gh(*args))
if page.get("errors"):
raise ValueError(str(page["errors"])[:300])
connection = page["data"]["repository"]["pullRequests"]
prs += [pull(node) for node in listed(connection)]
if not connection["pageInfo"]["hasNextPage"]:
return prs
after = connection["pageInfo"]["endCursor"]
def closed_with_label(repo):
owner, name = repo.split("/", 1)
page = json.loads(pr_labels.gh("api", "graphql", "-f", f"query={CLOSED_QUERY}", "-F", f"owner={owner}",
"-F", f"name={name}", "-f", f"label={QUEUE_LABEL}"))
if page.get("errors"):
raise ValueError(str(page["errors"])[:300])
return [node["number"] for node in listed(page["data"]["repository"]["pullRequests"])]
def apply(repo, prs, queue, dry_run, closed=()):
chosen = {pr["number"] for pr in queue}
changes = [(number, "--remove-label") for number in closed]
for pr in prs:
if pr["number"] in chosen and QUEUE_LABEL not in pr["labels"]:
changes.append((pr["number"], "--add-label"))
elif pr["number"] not in chosen and QUEUE_LABEL in pr["labels"]:
changes.append((pr["number"], "--remove-label"))
for number, flag in changes:
print(f"{'would change' if dry_run else 'changed'} #{number}: {'+' if flag == '--add-label' else '-'}{QUEUE_LABEL}")
if not dry_run:
pr_labels.gh("pr", "edit", str(number), "-R", repo, flag, QUEUE_LABEL)
time.sleep(WRITE_PAUSE)
return changes
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace")
parser = argparse.ArgumentParser(description="Label the pull requests that are next in the review queue.")
parser.add_argument("--dry-run", action="store_true", help="print the planned changes and write nothing")
parser.add_argument("--explain", type=int, metavar="PR", help="print what blocks one pull request and write nothing")
parser.add_argument("--issue", type=int, help="issue whose body lists the queue")
args = parser.parse_args(argv)
try:
repo = pr_labels.repository()
prs = fetch(repo)
closed = closed_with_label(repo) if args.explain is None else []
except subprocess.CalledProcessError as error:
parser.error((error.stderr or str(error)).strip())
except (ValueError, KeyError, TypeError, RecursionError) as error:
parser.error(f"cannot read pull requests: {error}")
if args.explain is not None:
match = [pr for pr in prs if pr["number"] == args.explain]
if not match:
parser.error(f"#{args.explain} is not an open pull request to main")
print("\n".join(blockers(match[0])) or "nothing blocks it")
return 0
ready = ranked(prs)
queue = ready[:QUEUE_SIZE]
try:
apply(repo, prs, queue, args.dry_run, closed)
if args.issue is not None and not args.dry_run:
body = queue_body(queue, len(prs), len(ready))
current = pr_labels.gh("issue", "view", str(args.issue), "-R", repo, "--json", "body", "--jq", ".body")
if current.strip() != body.strip():
pr_labels.gh("issue", "edit", str(args.issue), "-R", repo, "--body", body)
except subprocess.CalledProcessError as error:
sys.exit(f"cannot update the queue: {(error.stderr or '').strip()}")
print(f"{len(ready)} of {len(prs)} open pull requests have no blocker; {len(queue)} queued")
return 0
if __name__ == "__main__":
sys.exit(main())