diff --git a/AGENTS.md b/AGENTS.md index 3230376..71ddca2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ - Every directory that contains tools or dossiers also contains an `images/` subdirectory for their paired images. - Every `.md` file pairs with `images/.png` in the same directory group. The image filename always matches the `.md` filename (minus extension). - Directory-style tools (e.g., `voice/voice.md`) key on the parent directory name: `voice/` pairs with `images/voice.png`. Sub-tools inside a directory may have their own images in the same `images/`. +- Skills are directories holding a `SKILL.md` plus the scripts it calls. A skill is registered by adding its directory to the `SKILLS` array in `install.sh`, and installs to `~/.claude/skills/` and `~/.cursor/skills/` rather than `~/.claude/commands/`. Unlike a command, the whole directory ships, so anything the prompt calls must live inside it. ## Image invariant diff --git a/INSTALL.md b/INSTALL.md index 58bf370..2d299ac 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -2,13 +2,15 @@ The installer registers the tools in this repo as user-level Claude Code slash commands by writing them into `~/.claude/commands/`. After install, each tool is invoked as `/` from any Claude Code session. +It also installs **skills**, the directory-based tools that ship a script alongside the prompt. Those go to `~/.claude/skills/` and `~/.cursor/skills/`, since Claude Code and Cursor both read the `SKILL.md` format. They are invoked as `/` in either agent. + ## Install ```bash curl -fsSL https://raw.githubusercontent.com/cppalliance/tools-public/master/install.sh | bash ``` -Drops 37 commands into `~/.claude/commands/`. Re-run anytime to update — existing files are overwritten with the latest version. **Restart Claude Code** afterwards to pick up new commands (Claude Code does not auto-reload `commands/`). +Drops the commands into `~/.claude/commands/` and the skills into `~/.claude/skills/` and `~/.cursor/skills/`. The run prints the exact list and asks before writing anything. Re-run anytime to update — existing files are overwritten with the latest version. **Restart Claude Code** afterwards to pick up new commands (Claude Code does not auto-reload `commands/`). ## Uninstall @@ -64,10 +66,27 @@ Pass these as env vars before the curl pipe (or as flags to a local `bash instal | Env var | Effect | | --- | --- | | `INSTALL_YES=1` | Skip the `[y/N]` confirmation | -| `DEST=/path` | Install elsewhere than `~/.claude/commands` | +| `DEST=/path` | Install commands elsewhere than `~/.claude/commands` | +| `SKILL_DEST=a:b` | Colon-separated skill install roots. Default `~/.claude/skills:~/.cursor/skills`. Set to a single path to install for one agent only | | `LOCAL_SRC=/path` | Use a local checkout instead of downloading the tarball | | `UNINSTALL=1` | Run install.sh in uninstall mode (same as `uninstall.sh`) | +## Skills + +A command is one markdown prompt. A skill is a directory: `SKILL.md` plus whatever scripts it calls. That is the difference that gives skills their own list and their own install path. + +To add one, drop the directory in the repo and list it in the `SKILLS` array in `install.sh`, by directory rather than filename: + +```bash +SKILLS=( + tools-wg21/my-skill +) +``` + +The installer skips any entry without a `SKILL.md`, copies the whole directory to each root in `SKILL_DEST`, and clears the previous copy first so a file dropped upstream does not linger. Uninstall removes a directory only if it exists and still contains a `SKILL.md`. + +Skills that shell out to a tool the user may not have (`gh`, `python3`) should say so in the `SKILL.md` and fail with a clear message rather than a stack trace. + ## What's not included The novelist toolchain (`tools/novelist/`) is intentionally excluded — it's a coupled multi-prompt + Python pipeline that expects a per-book workspace and doesn't fit a one-shot command install. diff --git a/README.md b/README.md index fdc645b..8cfc653 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,10 @@ Given a C++ proposal, produces a scored verdict on whether it embodies the langu _[tools/wg21/papersmith.md](tools/wg21/papersmith.md)_\ Writes WG21 papers through a six-step pipeline (commission, research, skeleton, body, surface, review) and reviews any paper through a reusable Review Process: mechanical scans, citation integrity, fact check, adversarial evaluation, resolution. +**Pick a PR to Review** (skill)\ +_[tools-wg21/pick-pr-review/SKILL.md](tools-wg21/pick-pr-review/SKILL.md)_\ +Scans the open PRs across the wg21 repos and names the single one worth reviewing next, ranked by whether the author is waiting on you and broken on how close the PR sits to your recent work. + **Reform Reviewer**\ _[tools/wg21/reform-reviewer.md](tools/wg21/reform-reviewer.md)_\ Takes a reform document and delivers a green/red framing report with rewrites and a verdict. diff --git a/install.sh b/install.sh index 04893fe..a8de432 100644 --- a/install.sh +++ b/install.sh @@ -15,6 +15,7 @@ # INSTALL_YES=1 skip the [y/N] confirmation # UNINSTALL=1 remove instead of install # DEST=/path override ~/.claude/commands +# SKILL_DEST=a:b override the skill install roots (colon-separated) # LOCAL_SRC=/path use a local checkout instead of downloading the tarball set -euo pipefail @@ -25,6 +26,12 @@ TARBALL_URL="https://github.com/${REPO}/archive/refs/heads/${BRANCH}.tar.gz" DEST="${DEST:-${HOME}/.claude/commands}" LOCAL_SRC="${LOCAL_SRC:-}" +# Skills install to every agent that reads the SKILL.md format, since the same +# directory works unmodified in each. Claude Code and Cursor both also read the +# other's path as a compat fallback, but writing both explicitly avoids relying +# on that. +SKILL_DEST="${SKILL_DEST:-${HOME}/.claude/skills:${HOME}/.cursor/skills}" + # Mode: parse --uninstall flag or UNINSTALL env var. MODE="install" for arg in "$@"; do @@ -57,8 +64,27 @@ TOP_LEVEL=( FAMILIES=(voice interview tutor) +# Skills, as paths relative to the repo root. +# +# A command above is a single prompt file copied into ~/.claude/commands. A skill +# is a whole directory: SKILL.md plus whatever scripts it calls. That distinction +# is why they need their own list and their own install path. Add a skill here by +# its directory, not by a filename. +SKILLS=( + tools-wg21/pick-pr-review +) + +# Split the colon-separated SKILL_DEST into an array. +SKILL_DESTS=() +while IFS= read -r _dest; do + [[ -n "$_dest" ]] && SKILL_DESTS+=("$_dest") +done <<< "${SKILL_DEST//:/$'\n'}" + die() { echo "error: $*" >&2; exit 1; } +# "1 skill" / "2 skills" +plural() { (( $1 == 1 )) && echo "$1 $2" || echo "$1 ${2}s"; } + extract_description() { local file="$1" local desc="" @@ -103,11 +129,15 @@ extract_description() { # NAMES[i] = slash-command name (with leading /) # SOURCES[i] = path to source .md inside the extracted tree # TARGETS[i] = absolute path under $DEST +# SKILL_NAMES[i] / SKILL_SOURCES[i] = skill command name and its source directory plan() { local src="$1" + local root="$2" NAMES=() SOURCES=() TARGETS=() + SKILL_NAMES=() + SKILL_SOURCES=() for f in "${TOP_LEVEL[@]}"; do [[ -f "$src/$f" ]] || continue @@ -134,19 +164,52 @@ plan() { done fi done + + # Guarded because bash 3.2, still the system bash on macOS, treats "${ARR[@]}" + # on an empty array as an unbound variable under set -u. + if (( ${#SKILLS[@]} > 0 )); then + local skill + for skill in "${SKILLS[@]}"; do + # A directory without a SKILL.md is not a skill, skip it rather than + # installing something no agent will load. + [[ -f "$root/$skill/SKILL.md" ]] || continue + SKILL_NAMES+=("/$(basename "$skill")") + SKILL_SOURCES+=("$root/$skill") + done + fi +} + +# Every install path for a skill, one per line. +skill_targets() { + local name="$1" dest_root + for dest_root in "${SKILL_DESTS[@]}"; do + echo "$dest_root/$name" + done +} + +# A skill counts as present if it is installed in any of the destinations. +skill_present() { + local name="$1" target + while IFS= read -r target; do + [[ -d "$target" ]] && return 0 + done < <(skill_targets "$name") + return 1 } print_banner() { cat < inside Claude Code, covering code review, document tightening, plan refinement, persona voices, adaptive interviews, tutorials, and more. +Skills are directory-based tools that ship scripts alongside the prompt. They +install to both Claude Code and Cursor, which share the SKILL.md format. + EOF } @@ -162,6 +225,11 @@ print_plan() { for name in "${NAMES[@]}"; do (( ${#name} > max_width )) && max_width=${#name} done + if (( ${#SKILL_NAMES[@]} > 0 )); then + for name in "${SKILL_NAMES[@]}"; do + (( ${#name} > max_width )) && max_width=${#name} + done + fi if [[ "$action_word" == "install" ]]; then echo "Will install ${#NAMES[@]} commands to $DEST" @@ -188,6 +256,36 @@ print_plan() { done echo + if (( ${#SKILL_NAMES[@]} > 0 )); then + local skill_present_count=0 + for i in "${!SKILL_NAMES[@]}"; do + skill_present "${SKILL_NAMES[$i]#/}" && skill_present_count=$((skill_present_count + 1)) + done + + if [[ "$action_word" == "install" ]]; then + echo "Will install $(plural ${#SKILL_NAMES[@]} skill) to:" + else + echo "Will remove $(plural ${skill_present_count} skill) from:" + fi + local dest_root + for dest_root in "${SKILL_DESTS[@]}"; do + echo " $dest_root" + done + echo + + for i in "${!SKILL_NAMES[@]}"; do + local desc marker=" " + desc="$(extract_description "${SKILL_SOURCES[$i]}/SKILL.md")" + if [[ "$action_word" == "install" ]]; then + skill_present "${SKILL_NAMES[$i]#/}" && marker="↻" || marker="+" + else + skill_present "${SKILL_NAMES[$i]#/}" && marker="-" || marker=" " + fi + printf " %s %-${max_width}s %s\n" "$marker" "${SKILL_NAMES[$i]}" "$desc" + done + echo + fi + if [[ "$action_word" == "install" ]]; then echo "Legend: + new ↻ overwrite (update)" else @@ -219,6 +317,23 @@ do_install() { count=$((count + 1)) done echo "Installed $count commands to $DEST." + + local skill_count=0 + if (( ${#SKILL_NAMES[@]} > 0 )); then + for i in "${!SKILL_NAMES[@]}"; do + local name="${SKILL_NAMES[$i]#/}" target + while IFS= read -r target; do + mkdir -p "$(dirname "$target")" + # Clear the old copy first, so a file dropped from the skill upstream + # does not linger in an install that is otherwise up to date. + [[ -d "$target" ]] && rm -rf "$target" + cp -R "${SKILL_SOURCES[$i]}" "$target" + skill_count=$((skill_count + 1)) + done < <(skill_targets "$name") + done + echo "Installed $(plural ${#SKILL_NAMES[@]} skill) to $(plural ${#SKILL_DESTS[@]} location) ($(plural $skill_count copy | sed 's/copys/copies/'))." + fi + echo "Restart Claude Code to pick them up." } @@ -243,11 +358,31 @@ do_uninstall() { echo "Removed $removed commands from $DEST." (( skipped > 0 )) && echo "Skipped $skipped (not currently installed)." + + local skill_removed=0 + if (( ${#SKILL_NAMES[@]} > 0 )); then + for i in "${!SKILL_NAMES[@]}"; do + local name="${SKILL_NAMES[$i]#/}" target + [[ -n "$name" ]] || continue + while IFS= read -r target; do + # Only ever remove a directory we would have written: it has to exist + # and carry the SKILL.md that made it a skill in the first place. + if [[ -d "$target" && -f "$target/SKILL.md" ]]; then + rm -rf "$target" + skill_removed=$((skill_removed + 1)) + fi + done < <(skill_targets "$name") + done + echo "Removed $skill_removed skill $( (( skill_removed == 1 )) && echo copy || echo copies )." + fi + + return 0 } acquire_source() { if [[ -n "$LOCAL_SRC" ]]; then SRC="$LOCAL_SRC/tools" + ROOT="$LOCAL_SRC" [[ -d "$SRC" ]] || die "LOCAL_SRC=$LOCAL_SRC has no tools/ subdirectory" echo "Source: local checkout at $LOCAL_SRC" return @@ -264,6 +399,8 @@ acquire_source() { SRC="$(find "$TMP" -maxdepth 2 -type d -name tools | head -n 1)" [[ -n "$SRC" && -d "$SRC" ]] || die "could not locate tools/ in extracted tarball" + # Skills are listed relative to the repo root, which is tools/'s parent. + ROOT="$(dirname "$SRC")" } main() { @@ -273,8 +410,8 @@ main() { acquire_source - plan "$SRC" - [[ ${#NAMES[@]} -gt 0 ]] || die "nothing to process" + plan "$SRC" "$ROOT" + [[ $(( ${#NAMES[@]} + ${#SKILL_NAMES[@]} )) -gt 0 ]] || die "nothing to process" echo if [[ "$MODE" == "install" ]]; then diff --git a/tools-wg21/pick-pr-review/SKILL.md b/tools-wg21/pick-pr-review/SKILL.md new file mode 100644 index 0000000..50ad6ac --- /dev/null +++ b/tools-wg21/pick-pr-review/SKILL.md @@ -0,0 +1,120 @@ +--- +name: pick-pr-review +description: Pick the single highest-value open PR to review across the wg21 repos +disable-model-invocation: true +argument-hint: "[repo=owner/name] [--include-drafts] [--explain]" +--- + +# Pick a PR to Review + +Scan the open PRs in `cppalliance/wg21-website` and `cppalliance/wg21-paperflow` +and recommend **exactly one** to review next. The point is to remove the choice, +not to hand back a queue. + +Requires the `gh` CLI, authenticated as the reviewer (`gh auth login`). The +script resolves the current user itself, so it needs no per-person config. + +## 1. Run the triage script + +The script sits next to this SKILL.md. Claude Code exposes that directory as +`${CLAUDE_SKILL_DIR}`; in any other agent, use the directory this SKILL.md was +loaded from (for a user-level install, `~/.cursor/skills/pick-pr-review/`). + +```bash +python3 "${CLAUDE_SKILL_DIR}/triage_prs.py" $ARGUMENTS --json +``` + +It is read-only: it makes one GraphQL call per repo plus one merged-PR listing, +and writes nothing. `$ARGUMENTS` may add `repo=owner/name` (scans that repo on +top of the two defaults), `--include-drafts`, or `--explain` (dumps the derived +area weights to stderr). + +The script ranks by tier, then breaks ties on how close the PR sits to the areas +the user has worked in recently: + +| Tier | Meaning | +|---|---| +| 1 `responded` | The user reviewed it and the author has since replied or pushed. The ball is back in their court. | +| 2 `requested` | Review explicitly requested from the user, no review from them yet. | +| 3 `unreviewed` | Nobody has reviewed it. | +| 4 `other` | Reviewed by someone else, or the user reviewed and the author has gone quiet. | + +Already excluded: the user's own PRs, PRs they approved with no changes since, +and drafts they have no involvement with. Tier 4 never wins the top slot, so +`pick` is null when only tier 4 remains. + +An unsubmitted draft review (the `PENDING` state step 5 leaves behind) is not a +review anyone else can see, so it does not set a tier. It surfaces instead as +`my_pending_review` on the candidate, and it means review work is sitting there +unsent. + +## 2. Report + +Keep it to a handful of lines. No preamble, no tables, no em dashes. + +- **The pick**: `owner/repo#N`, the title, the direct URL on its own line, the + one-line reason from `reason`, and the diff size. If it is a draft, say so. +- **1-2 alternatives**: one line each, with their tier reason, so skipping the + top pick is cheap. +- If `pick` is null, say the queue is clear. Do not promote a tier-4 PR into the + top slot to have something to recommend. +- **Unsubmitted drafts**: any candidate with `my_pending_review` gets its own + line, wherever it ranks, naming the PR and the date the draft was started. + Those comments are invisible until the user submits them, so an old one is + review work already done and going to waste. Say that even when the top pick + is something else. + +## 3. Offer, do not start + +End by offering to review the pick. **Wait for confirmation.** The user asked for +a recommendation, and pulling a large diff into context uninvited is not that. + +## 4. On acceptance, review it + +```bash +gh pr view --repo --json title,body,files +gh pr diff --repo +``` + +For a **tier 1** pick, read the prior exchange first, because the question is not +"is this code good" but "did the author's response actually address what was +raised": + +```bash +gh api repos//pulls//comments --jq '.[] | "\(.user.login) \(.path):\(.line)\n\(.body)\n"' +gh pr view --repo --json reviews --jq '.reviews[] | "\(.author.login) \(.state)\n\(.body)\n"' +``` + +Present the findings in chat first and get agreement on them. + +## 5. Post as a pending review, never a submitted one + +The human submits the review, not the agent. Post the comments as a **pending +draft** so they can be edited and sent from the GitHub UI. + +Write the comments to a JSON file: + +```json +[ + { "path": "relative/path.py", "line": 42, "side": "RIGHT", "body": "Comment text." } +] +``` + +`side` is `RIGHT` for added or changed lines, `LEFT` for removed ones. Line +numbers must land on lines present in the diff, otherwise the API returns 422. + +```bash +gh api repos//pulls//reviews --method POST \ + -f commit_id="$(gh pr view --repo --json headRefOid --jq .headRefOid)" \ + -f body="" \ + -F comments=@comments.json \ + --jq '.state, .html_url' +``` + +**HARD RULE: no `event` field in that payload.** Any value (`COMMENT`, +`APPROVE`, `REQUEST_CHANGES`) submits the review immediately, the comments go +live, and a `COMMENTED` review cannot be dismissed. The call must return +`state: PENDING`. If it returns anything else, say so immediately: the comments +are now public and can only be deleted one at a time. + +Report the returned `html_url` and note it is a draft awaiting their submission. diff --git a/tools-wg21/pick-pr-review/triage_prs.py b/tools-wg21/pick-pr-review/triage_prs.py new file mode 100644 index 0000000..f69d9ff --- /dev/null +++ b/tools-wg21/pick-pr-review/triage_prs.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +"""Rank open PRs across the wg21 repos and pick the one worth reviewing next. + +Read-only. Shells out to `gh` (one GraphQL call per repo, plus one merged-PR +listing per repo for the area tie-break). + +Tiers, highest priority first: + 1 responded I reviewed it, the author has since replied or pushed + 2 requested review is explicitly requested from me + 3 unreviewed nobody has reviewed it + 4 other reviewed by others, or I reviewed and the author is silent +Tier 4 never wins the top slot, it only fills the alternatives list. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +DEFAULT_REPOS = ["cppalliance/wg21-website", "cppalliance/wg21-paperflow"] + +# How far back to look for "areas I've worked in recently", and the weight a +# merged PR (or local commit) contributes at each age. +AFFINITY_WINDOW_DAYS = 90 +AFFINITY_WEIGHTS = ((30, 1.0), (60, 0.6), (90, 0.3)) + +TIER_NAMES = {1: "responded", 2: "requested", 3: "unreviewed", 4: "other"} + +PR_QUERY = """ +query($owner:String!,$name:String!){ + repository(owner:$owner,name:$name){ pullRequests(states:OPEN,first:50,orderBy:{field:UPDATED_AT,direction:DESC}){ nodes{ + number title isDraft url createdAt updatedAt additions deletions changedFiles + author{login} + reviewRequests(first:20){nodes{requestedReviewer{__typename ... on User{login} ... on Team{slug}}}} + reviews(last:30){nodes{author{login} state submittedAt createdAt}} + commits(last:1){nodes{commit{committedDate}}} + comments(last:20){nodes{author{login} createdAt}} + reviewThreads(first:60){nodes{isResolved comments(last:5){nodes{author{login} createdAt}}}} + timelineItems(last:20, itemTypes:[REVIEW_REQUESTED_EVENT]){nodes{... on ReviewRequestedEvent{createdAt requestedReviewer{... on User{login}}}}} + files(first:100){nodes{path}} } } } } +""" + + +# --- shelling out ------------------------------------------------------------ + + +def run(cmd: list[str]) -> str: + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError(f"{' '.join(cmd[:3])}... failed: {proc.stderr.strip()}") + return proc.stdout + + +def check_auth() -> None: + proc = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True) + if proc.returncode != 0: + sys.exit("gh is not authenticated. Run `gh auth login` and retry.") + + +def whoami() -> str: + return run(["gh", "api", "user", "--jq", ".login"]).strip() + + +def fetch_open_prs(repo: str) -> list[dict]: + owner, name = repo.split("/", 1) + out = run( + ["gh", "api", "graphql", "-f", f"query={PR_QUERY}", "-f", f"owner={owner}", "-f", f"name={name}"] + ) + payload = json.loads(out) + if payload.get("errors"): + raise RuntimeError(f"{repo}: {payload['errors']}") + nodes = payload["data"]["repository"]["pullRequests"]["nodes"] + for pr in nodes: + # A PENDING review is a draft you started and never submitted, so it comes + # back with submittedAt null. Nobody else can see it, and a null timestamp + # poisons every downstream comparison, so split it out of the review list + # and keep it as its own signal. + reviews = pr["reviews"]["nodes"] + pr["reviews"]["nodes"] = [r for r in reviews if r.get("submittedAt")] + pr["pendingReviews"] = [r for r in reviews if not r.get("submittedAt")] + return nodes + + +# --- time helpers ------------------------------------------------------------ + + +def parse_ts(value: str | None) -> datetime | None: + if not value: + return None + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def age_days(ts: datetime | None, now: datetime) -> float | None: + return None if ts is None else (now - ts).total_seconds() / 86400 + + +def humanize_age(days: float | None) -> str: + if days is None: + return "unknown" + if days < 1: + return "today" + if days < 2: + return "1 day" + if days < 14: + return f"{int(days)} days" + return f"{int(days / 7)} weeks" + + +# --- area affinity ----------------------------------------------------------- + + +def path_area(path: str) -> str | None: + """Bucket a file path into a coarse area label, or None if it carries no signal. + + Root-level files (CLAUDE.md, .gitignore, pyproject.toml, lockfiles) churn in + almost every PR, so counting them as an area makes everything look related to + everything. Test dirs fold into the area they test. + """ + parts = Path(path).parts + if not parts or len(parts) == 1: + return None # a file at the repo root + if parts[0] == "packages" and len(parts) > 1: + return parts[1] # paperflow: assay, agora, tomd, mailing, pipeline, ... + if parts[0] == "wg21_site": + # wg21_site/mailing/... -> wg21_site/mailing; wg21_site/views.py -> wg21_site + sub = parts[1] if len(parts) > 2 else None + return f"wg21_site/{sub}" if sub and sub not in ("tests", "migrations") else "wg21_site" + if parts[0] == "templates" and len(parts) > 2: + return f"templates/{parts[1]}" + return parts[0] + + +def areas_of(paths) -> set[str]: + return {area for area in (path_area(p) for p in paths) if area} + + +def recency_weight(days: float) -> float: + for cutoff, weight in AFFINITY_WEIGHTS: + if days <= cutoff: + return weight + return 0.0 + + +def my_areas(repos: list[str], now: datetime, quiet: bool) -> dict[str, dict[str, float]]: + """Areas I've touched recently per repo, recency-weighted, each repo normalized to 1.0. + + Normalization is deliberately per repo, not global. Affinity breaks ties + *within* a tier, and that tier mixes PRs from every repo. A global scale + would just mean "whichever repo I committed to most this month wins", which + is not the question being asked. + """ + since = (now - timedelta(days=AFFINITY_WINDOW_DAYS)).date().isoformat() + weights: dict[str, dict[str, float]] = {repo: {} for repo in repos} + local_repo = detect_local_repo(repos) + + def add(repo: str, paths, days: float) -> None: + weight = recency_weight(days) + if weight <= 0: + return + for area in areas_of(paths): + weights[repo][area] = weights[repo].get(area, 0.0) + weight + + for repo in repos: + try: + out = run( + # fmt: off + ["gh", "pr", "list", "--repo", repo, "--author", "@me", "--state", "merged", + "--search", f"merged:>={since}", "--limit", "50", "--json", "number,mergedAt,files"], + # fmt: on + ) + except RuntimeError as exc: + if not quiet: + print(f"note: no merge history for {repo} ({exc})", file=sys.stderr) + out = "[]" + for pr in json.loads(out): + merged = parse_ts(pr.get("mergedAt")) + if merged is None: + continue + add(repo, [f["path"] for f in pr.get("files") or []], age_days(merged, now)) + if repo == local_repo: + add_local_commits(repo, since, now, add) + + return { + repo: {area: round(w / max(areas.values()), 4) for area, w in areas.items()} + for repo, areas in weights.items() + if areas + } + + +def detect_local_repo(repos: list[str]) -> str | None: + """Which target repo, if any, the current directory is a checkout of. + + Matched on the repo name rather than owner/name, since this checkout's + `origin` is usually a personal fork. + """ + try: + remotes = run(["git", "remote", "-v"]) + except RuntimeError: + return None + for repo in repos: + name = repo.split("/", 1)[1] + if f"/{name}.git" in remotes or f"/{name} " in remotes: + return repo + return None + + +def add_local_commits(repo: str, since: str, now: datetime, add) -> None: + """Fold in unmerged local work from the current checkout. + + Merged PRs miss whatever is still sitting on a feature branch, which is + usually the most recent signal there is. + """ + try: + log = run(["git", "log", f"--since={since}", "--name-only", "--pretty=format:@@%aI"]) + except RuntimeError: + return + stamp: datetime | None = None + paths: list[str] = [] + for line in log.splitlines(): + if line.startswith("@@"): + if stamp is not None and paths: + add(repo, paths, age_days(stamp, now)) + stamp, paths = parse_ts(line[2:].strip()), [] + elif line.strip(): + paths.append(line.strip()) + if stamp is not None and paths: + add(repo, paths, age_days(stamp, now)) + + +def affinity(pr_paths: list[str], areas: dict[str, float]) -> tuple[float, list[str]]: + """Weighted overlap between a PR's areas and mine, in 0..1.""" + pr_areas = [area for area in (path_area(p) for p in pr_paths) if area] + if not pr_areas or not areas: + return 0.0, [] + counts: dict[str, int] = {} + for area in pr_areas: + counts[area] = counts.get(area, 0) + 1 + total = len(pr_areas) + score = sum(areas.get(area, 0.0) * (n / total) for area, n in counts.items()) + matched = sorted((a for a in counts if a in areas), key=lambda a: -areas[a]) + return round(score, 4), matched + + +# --- classification ---------------------------------------------------------- + + +def requested_reviewers(pr: dict) -> list[str]: + out = [] + for node in pr["reviewRequests"]["nodes"]: + reviewer = node.get("requestedReviewer") or {} + if reviewer.get("__typename") == "User" and reviewer.get("login"): + out.append(reviewer["login"]) + return out + + +def author_activity_at(pr: dict, author: str) -> datetime | None: + """Latest sign of life from the PR author, across all four event streams. + + Authors reply in different places: a push, an issue comment, a review-thread + reply, or a PR-level review with state COMMENTED. All four count. + """ + stamps: list[datetime] = [] + for node in pr["commits"]["nodes"]: + stamps.append(parse_ts(node["commit"]["committedDate"])) + for node in pr["comments"]["nodes"]: + if (node.get("author") or {}).get("login") == author: + stamps.append(parse_ts(node["createdAt"])) + for node in pr["reviews"]["nodes"]: + if (node.get("author") or {}).get("login") == author: + stamps.append(parse_ts(node["submittedAt"])) + for thread in pr["reviewThreads"]["nodes"]: + for node in thread["comments"]["nodes"]: + if (node.get("author") or {}).get("login") == author: + stamps.append(parse_ts(node["createdAt"])) + stamps = [s for s in stamps if s is not None] + return max(stamps) if stamps else None + + +def my_last_review(pr: dict, me: str) -> dict | None: + mine = [r for r in pr["reviews"]["nodes"] if (r.get("author") or {}).get("login") == me] + return mine[-1] if mine else None + + +def my_pending_review(pr: dict, me: str) -> dict | None: + """A review you drafted and never submitted. Invisible to everyone but you.""" + mine = [r for r in pr["pendingReviews"] if (r.get("author") or {}).get("login") == me] + return mine[-1] if mine else None + + +def requested_from_me_at(pr: dict, me: str) -> datetime | None: + stamps = [ + parse_ts(node["createdAt"]) + for node in pr["timelineItems"]["nodes"] + if (node.get("requestedReviewer") or {}).get("login") == me + ] + return max(stamps) if stamps else None + + +def classify(pr: dict, me: str, include_drafts: bool) -> dict | None: + """Return a candidate dict, or None if the PR should be dropped.""" + author = (pr.get("author") or {}).get("login") + if author == me: + return None # you cannot review your own PR + + reviewers = requested_reviewers(pr) + requested = me in reviewers + last_review = my_last_review(pr, me) + pending_review = my_pending_review(pr, me) + + if pr["isDraft"] and not include_drafts and not (requested or last_review or pending_review): + return None + + responded_at = author_activity_at(pr, author) + my_review_at = parse_ts(last_review["submittedAt"]) if last_review else None + author_moved = bool(my_review_at and responded_at and responded_at > my_review_at) + + if last_review and last_review["state"] == "APPROVED" and not author_moved and not pending_review: + return None # already approved and nothing has changed since + + if author_moved: + tier, waiting_since = 1, responded_at + elif requested: + tier = 2 + waiting_since = requested_from_me_at(pr, me) or parse_ts(pr["createdAt"]) + elif not pr["reviews"]["nodes"]: + tier, waiting_since = 3, parse_ts(pr["createdAt"]) + else: + tier, waiting_since = 4, parse_ts(pr["updatedAt"]) + + return { + "number": pr["number"], + "title": pr["title"], + "url": pr["url"], + "author": author, + "is_draft": pr["isDraft"], + "tier": tier, + "tier_name": TIER_NAMES[tier], + "waiting_since": waiting_since.isoformat() if waiting_since else None, + "changed_files": pr["changedFiles"], + "additions": pr["additions"], + "deletions": pr["deletions"], + "unresolved_threads": sum(1 for t in pr["reviewThreads"]["nodes"] if not t["isResolved"]), + "requested_from_me": requested, + "my_last_review": ( + {"state": last_review["state"], "at": last_review["submittedAt"]} if last_review else None + ), + "my_pending_review": ({"at": pending_review["createdAt"]} if pending_review else None), + "author_activity_at": responded_at.isoformat() if responded_at else None, + "other_reviewers": sorted( + { + (r.get("author") or {}).get("login") + for r in pr["reviews"]["nodes"] + if (r.get("author") or {}).get("login") not in (me, author, None) + } + ), + "paths": [f["path"] for f in pr["files"]["nodes"]], # capped at 100 by the query + } + + +def reason_for(cand: dict) -> str: + if cand["tier"] == 1: + state = cand["my_last_review"]["state"].replace("_", " ").lower() + return ( + f"you left {state} on {cand['my_last_review']['at'][:10]}, " + f"{cand['author']} has since responded ({cand['author_activity_at'][:10]})" + ) + if cand["tier"] == 2: + return f"{cand['author']} requested your review, no review from you yet" + if cand["tier"] == 3: + return "nobody has reviewed it yet" + if cand["my_last_review"]: + return f"you reviewed it on {cand['my_last_review']['at'][:10]}, no response from the author since" + if cand["my_pending_review"]: + return ( + f"you drafted a review on {cand['my_pending_review']['at'][:10]} and never submitted it" + ) + return f"reviewed by {', '.join(cand['other_reviewers']) or 'others'}, not by you" + + +# --- main -------------------------------------------------------------------- + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + # Drafts are always sorted below review-ready PRs of the same tier, flag or not. + parser.add_argument( + "--include-drafts", action="store_true", help="also scan drafts you have no involvement with" + ) + parser.add_argument("--json", dest="as_json", action="store_true", help="emit structured output") + parser.add_argument("--explain", action="store_true", help="show the derived area weights") + parser.add_argument("--limit", type=int, default=6, help="how many candidates to return") + # Positional `repo=owner/name` args add repos on top of the defaults. + parser.add_argument("extra", nargs="*", help="repo=owner/name to scan in addition to the defaults") + args = parser.parse_args(argv) + args.repos = list(DEFAULT_REPOS) + for item in args.extra: + if item.startswith("repo="): + repo = item.split("=", 1)[1] + if repo not in args.repos: + args.repos.append(repo) + else: + parser.error(f"unrecognized argument: {item}") + return args + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + check_auth() + me = whoami() + now = datetime.now(timezone.utc) + + areas = my_areas(args.repos, now, quiet=args.as_json) + + candidates = [] + for repo in args.repos: + for pr in fetch_open_prs(repo): + cand = classify(pr, me, args.include_drafts) + if cand is None: + continue + cand["repo"] = repo + cand["ref"] = f"{repo}#{cand['number']}" + cand["affinity"], cand["matched_areas"] = affinity(cand.pop("paths"), areas.get(repo, {})) + cand["waiting"] = humanize_age(age_days(parse_ts(cand["waiting_since"]), now)) + cand["reason"] = reason_for(cand) + candidates.append(cand) + + candidates.sort( + key=lambda c: ( + c["tier"], + c["is_draft"], # a draft never outranks a review-ready PR in the same tier + -c["affinity"], + c["waiting_since"] or "", # oldest wait first, so nothing rots + c["changed_files"], # a quick win breaks a true tie + ) + ) + top = candidates[: args.limit] + + if args.explain: + # stderr, so this survives --json without corrupting the payload on stdout + for repo in args.repos: + print(f"Recent areas in {repo} (weight):", file=sys.stderr) + ranked = sorted(areas.get(repo, {}).items(), key=lambda kv: -kv[1])[:8] + for area, weight in ranked: + print(f" {weight:>5.2f} {area}", file=sys.stderr) + if not ranked: + print(" (no recent work)", file=sys.stderr) + print(file=sys.stderr) + + if args.as_json: + print( + json.dumps( + { + "me": me, + "repos": args.repos, + "generated_at": now.isoformat(), + "area_weights": areas, + "pick": next((c for c in top if c["tier"] < 4), None), + "candidates": top, + }, + indent=2, + ) + ) + return 0 + + if not top: + print("No open PRs are waiting on you. The queue is clear.") + return 0 + + for i, c in enumerate(top): + marker = "->" if i == 0 and c["tier"] < 4 else " " + draft = " (draft)" if c["is_draft"] else "" + print(f"{marker} [{c['tier']} {c['tier_name']}] {c['ref']}{draft} {c['title']}") + print(f" {c['url']}") + print(f" {c['reason']}; waiting {c['waiting']}") + print( + f" {c['changed_files']} files, +{c['additions']}/-{c['deletions']}, " + f"affinity {c['affinity']:.2f} {c['matched_areas'][:3]}" + ) + if c["my_pending_review"]: + print( + f" ! unsubmitted draft review of yours from " + f"{c['my_pending_review']['at'][:10]}, nobody else can see it" + ) + print() + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv[1:])) + except RuntimeError as exc: + sys.exit(str(exc))