#!/usr/bin/env python3 """Autonomous batch PR reviewer. Auto-discovers repos in ~/projects that have an orchard remote, fetches open PRs via the gh CLI, generates structured reviews via the Claude API, and saves them to ~/pr-reviews//. Usage: python review_prs.py [--dry-run] [--repo REPO_NAME] """ import argparse import json import os import re import subprocess import sys from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import date from pathlib import Path import anthropic import yaml from dotenv import load_dotenv SCRIPT_DIR = Path(__file__).parent load_dotenv(SCRIPT_DIR / ".env") CONFIG_PATH = SCRIPT_DIR / "pr_review_config.yaml" ORCHARD_ORG = "theorchard" ORCHARD_REMOTE = "orchard" # ── Repo discovery ───────────────────────────────────────────────────────────── def discover_repos(projects_dir: Path) -> dict[str, str]: """Scan projects_dir for git repos with an orchard remote. Returns a dict of {local_name: gh_repo_name}. """ repos = {} for path in sorted(projects_dir.iterdir()): if not (path / ".git").is_dir(): continue result = subprocess.run( ["git", "-C", str(path), "remote", "get-url", ORCHARD_REMOTE], capture_output=True, text=True, ) if result.returncode != 0: continue remote_url = result.stdout.strip() # Extract repo name from git@github.com:theorchard/NAME.git or https://... m = re.search(rf"{ORCHARD_ORG}/([^/]+?)(?:\.git)?$", remote_url) if m: repos[path.name] = m.group(1) return repos # ── GitHub helpers ───────────────────────────────────────────────────────────── def run_gh(*args: str, timeout: int = 30) -> tuple[bool, str]: try: result = subprocess.run( ["gh", *args], capture_output=True, text=True, timeout=timeout, ) return result.returncode == 0, result.stdout.strip() except subprocess.TimeoutExpired: return False, "timeout" except Exception as e: return False, str(e) def list_open_prs(gh_repo: str) -> list[dict]: ok, out = run_gh( "pr", "list", "--repo", f"{ORCHARD_ORG}/{gh_repo}", "--state", "open", "--json", "number,title,author,isDraft,headRefName,body,createdAt,url", "--limit", "50", ) if not ok or not out: return [] try: return json.loads(out) except json.JSONDecodeError: return [] def get_pr_diff(gh_repo: str, pr_number: int) -> str: ok, out = run_gh( "pr", "diff", str(pr_number), "--repo", f"{ORCHARD_ORG}/{gh_repo}", timeout=60, ) return out if ok else "" # ── Review generation ────────────────────────────────────────────────────────── REVIEW_PROMPT = """\ You are a senior software engineer conducting a structured code review. Repository: {repo} PR #{number}: {title} Author: {author} Branch: {branch} Description: {body} Diff: {diff} Produce a structured review in exactly this format: ## PR #{number}: {title} **Repo:** `{repo}` **Author:** {author} **URL:** {url} ### Summary 2–3 sentence summary of what this PR does and why. ### Key Changes - Bullet list of the most important changes (files changed, logic added/modified) ### Issues & Risks List any bugs, security concerns, missing error handling, performance issues, \ unclear logic, or missing tests. Be specific — include file names and line \ context where relevant. If none, write "None identified." ### Suggestions Non-blocking improvements or style notes. If none, write "None." ### Verdict **VERDICT:** One sentence justification. **RISK:** """ def generate_review(client: anthropic.Anthropic, repo: str, pr: dict, diff: str) -> str: body = (pr.get("body") or "(no description)").strip()[:3000] if len(diff) > 40000: diff = diff[:40000] + "\n\n[... diff truncated due to size ...]" prompt = REVIEW_PROMPT.format( repo=repo, number=pr["number"], title=pr["title"], author=pr["author"].get("login", "unknown"), branch=pr.get("headRefName", ""), body=body, diff=diff, url=pr.get("url", ""), ) message = client.messages.create( model="claude-sonnet-4-6", max_tokens=2048, messages=[{"role": "user", "content": prompt}], ) return message.content[0].text def extract_verdict_and_risk(review_text: str) -> tuple[str, str]: verdict = "Unknown" risk = "Unknown" if m := re.search(r"\*\*VERDICT:\*\*\s*(.+)", review_text): verdict = m.group(1).strip() if m := re.search(r"\*\*RISK:\*\*\s*(.+)", review_text): risk = m.group(1).strip() return verdict, risk # ── Config helpers ───────────────────────────────────────────────────────────── def resolve_setting(repo_cfg: dict, defaults: dict, key: str): if key in repo_cfg: return repo_cfg[key] return defaults.get(key, True) def filter_prs( prs: list[dict], repo_cfg: dict, defaults: dict, skip_drafts: bool, skip_dependabot: bool, ) -> list[dict]: # repo-level authors takes full precedence; [] at repo level means all authors if "authors" in repo_cfg: authors = repo_cfg["authors"] or [] else: authors = defaults.get("authors") or [] result = [] for pr in prs: login = pr["author"].get("login", "") name = pr["author"].get("name", "") if skip_dependabot and login in ("dependabot[bot]", "dependabot"): continue if skip_drafts and pr.get("isDraft", False): continue if authors and login not in authors and name not in authors: continue result.append(pr) return result def prompt_yes_no(question: str, default: bool = False) -> bool: suffix = "[Y/n]" if default else "[y/N]" try: answer = input(f"{question} {suffix}: ").strip().lower() except (EOFError, KeyboardInterrupt): return default return answer in ("y", "yes") if answer else default # ── Output helpers ───────────────────────────────────────────────────────────── def load_meta(today_dir: Path) -> dict: meta_path = today_dir / "meta.json" if meta_path.exists(): try: return json.loads(meta_path.read_text()) except json.JSONDecodeError: return {} return {} def save_meta(today_dir: Path, meta: dict): (today_dir / "meta.json").write_text(json.dumps(meta, indent=2)) def build_summary(meta: dict, today_dir: Path): risk_order = {"High": 0, "Medium": 1, "Low": 2} rows = sorted( meta.values(), key=lambda r: (risk_order.get(r.get("risk", ""), 3), r.get("repo", "")), ) lines = [ f"# PR Review Summary — {date.today()}", "", f"_{len(rows)} PR(s) reviewed_", "", "| Repo | PR | Title | Author | Verdict | Risk |", "|------|----|-------|--------|---------|------|", ] for r in rows: lines.append( f"| {r['repo']} " f"| [#{r['number']}]({r['url']}) " f"| {r['title']} " f"| {r['author']} " f"| {r['verdict']} " f"| {r['risk']} |" ) lines += ["", f"_Generated {date.today()}_", ""] (today_dir / "SUMMARY.md").write_text("\n".join(lines)) # ── Main ─────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="Autonomous batch PR reviewer") parser.add_argument( "--dry-run", action="store_true", help="List matching PRs without generating reviews", ) parser.add_argument( "--repo", metavar="NAME", help="Only process a specific repo (local directory name)", ) args = parser.parse_args() with open(CONFIG_PATH) as f: config = yaml.safe_load(f) defaults = config.get("defaults", {}) repo_overrides = config.get("repos") or {} exclude = set(config.get("exclude") or []) settings = config.get("settings", {}) projects_dir = Path(settings.get("projects_dir", "~/projects")).expanduser() output_dir = Path(settings.get("output_dir", "~/pr-reviews")).expanduser() parallelism = settings.get("parallelism", 5) today_dir = output_dir / str(date.today()) today_dir.mkdir(parents=True, exist_ok=True) if not args.dry_run: api_key = os.environ.get(settings.get("api_key_env", "ANTHROPIC_API_KEY")) if not api_key: print("ERROR: ANTHROPIC_API_KEY not set. Add it to code_reviewer/.env") sys.exit(1) client = anthropic.Anthropic(api_key=api_key) # ── Discover repos ───────────────────────────────────────────────────────── all_repos = discover_repos(projects_dir) # {local_name: gh_repo_name} if args.repo: if args.repo not in all_repos: print(f"ERROR: '{args.repo}' not found or has no orchard remote.") sys.exit(1) all_repos = {args.repo: all_repos[args.repo]} active_repos = {k: v for k, v in all_repos.items() if k not in exclude} # ── Fetch open PRs ───────────────────────────────────────────────────────── print(f"Scanning {len(active_repos)} repos for open PRs...") raw_by_repo: dict[str, tuple[str, dict, list[dict]]] = {} for local_name, gh_name in active_repos.items(): repo_cfg = repo_overrides.get(local_name) or {} prs = list_open_prs(gh_name) raw_by_repo[local_name] = (gh_name, repo_cfg, prs) if prs: draft_count = sum(1 for p in prs if p.get("isDraft")) dep_count = sum( 1 for p in prs if p["author"].get("login", "") in ("dependabot[bot]", "dependabot") ) parts = [f"{len(prs)} open"] if draft_count: parts.append(f"{draft_count} draft") if dep_count: parts.append(f"{dep_count} dependabot") print(f" {local_name}: {', '.join(parts)}") print() # ── Resolve global prompts ───────────────────────────────────────────────── def any_matching(raw_by_repo, setting_key, check_fn): return any( check_fn(prs) for _, (_, repo_cfg, prs) in raw_by_repo.items() if resolve_setting(repo_cfg, defaults, setting_key) == "prompt" ) global_skip_drafts: bool | None = None global_skip_dependabot: bool | None = None if any_matching(raw_by_repo, "skip_drafts", lambda prs: any(p.get("isDraft") for p in prs)): global_skip_drafts = not prompt_yes_no( "Found draft PRs. Include them?", default=False ) if any_matching(raw_by_repo, "skip_dependabot", lambda prs: any( p["author"].get("login", "") in ("dependabot[bot]", "dependabot") for p in prs )): global_skip_dependabot = not prompt_yes_no( "Found Dependabot PRs. Include them?", default=False ) # ── Filter and de-duplicate against today's reviews ─────────────────────── meta = load_meta(today_dir) to_review: list[tuple[str, str, dict]] = [] skipped_already = 0 for local_name, (gh_name, repo_cfg, prs) in raw_by_repo.items(): skip_drafts_setting = resolve_setting(repo_cfg, defaults, "skip_drafts") skip_drafts = ( global_skip_drafts if skip_drafts_setting == "prompt" and global_skip_drafts is not None else True if skip_drafts_setting == "prompt" else bool(skip_drafts_setting) ) skip_dep_setting = resolve_setting(repo_cfg, defaults, "skip_dependabot") skip_dep = ( global_skip_dependabot if skip_dep_setting == "prompt" and global_skip_dependabot is not None else True if skip_dep_setting == "prompt" else bool(skip_dep_setting) ) for pr in filter_prs(prs, repo_cfg, defaults, skip_drafts, skip_dep): key = f"{local_name}-PR{pr['number']}" if key in meta: skipped_already += 1 else: to_review.append((local_name, gh_name, pr)) if skipped_already: print(f"Skipping {skipped_already} PR(s) already reviewed today.") if not to_review: print("No new PRs to review.") if meta: build_summary(meta, today_dir) print(f"Summary: {today_dir}/SUMMARY.md") return total_repos = len({r[0] for r in to_review}) print(f"Reviewing {len(to_review)} PR(s) across {total_repos} repo(s)...\n") # ── Dry run ──────────────────────────────────────────────────────────────── if args.dry_run: for local_name, _, pr in to_review: draft_tag = " [DRAFT]" if pr.get("isDraft") else "" author = pr["author"].get("login", "unknown") print(f" {local_name} #{pr['number']}: {pr['title']}{draft_tag} — {author}") return # ── Review PRs in parallel ───────────────────────────────────────────────── def review_one(local_name: str, gh_name: str, pr: dict) -> dict | None: label = f"[{local_name} #{pr['number']}]" print(f" {label} Fetching diff...") diff = get_pr_diff(gh_name, pr["number"]) if not diff: print(f" {label} WARNING: empty diff, skipping") return None print(f" {label} Generating review...") review_text = generate_review(client, local_name, pr, diff) verdict, risk = extract_verdict_and_risk(review_text) filename = f"{local_name}-PR{pr['number']}.md" (today_dir / filename).write_text(review_text + "\n") entry = { "repo": local_name, "number": pr["number"], "title": pr["title"], "author": pr["author"].get("login", "unknown"), "url": pr.get("url", ""), "verdict": verdict, "risk": risk, "file": filename, } print(f" {label} Done — {verdict} | {risk} risk") return entry with ThreadPoolExecutor(max_workers=parallelism) as executor: futures = {executor.submit(review_one, *item): item for item in to_review} for future in as_completed(futures): result = future.result() if result: key = f"{result['repo']}-PR{result['number']}" meta[key] = result save_meta(today_dir, meta) build_summary(meta, today_dir) print(f"\nDone! {len(meta)} PR(s) in today's review.") print(f"Summary : {today_dir}/SUMMARY.md") print(f"Reviews : {today_dir}/") if __name__ == "__main__": main()