Files
codex/codex-rs/review
Daniel Edrisian 5060f6900a --hide-errors
2025-09-02 19:57:58 -07:00

1035 lines
40 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
import argparse
import concurrent.futures
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from urllib.request import urlopen
def _run(cmd: List[str], input_text: Optional[str] = None) -> Tuple[int, str, str]:
proc = subprocess.run(
cmd,
input=input_text,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
return proc.returncode, proc.stdout, proc.stderr
def _run_streaming(cmd: List[str]) -> int:
"""Run a subprocess and stream combined stdout/stderr live to the console.
Returns the exit code."""
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True,
)
except Exception as e:
print(f"Failed to spawn: {' '.join(cmd)}: {e}", file=sys.stderr)
return 127
assert proc.stdout is not None
for line in proc.stdout:
print(line.rstrip())
proc.wait()
return proc.returncode
def require(cmd: str, hint: str):
if shutil.which(cmd) is None:
print(f"Error: required command '{cmd}' not found. {hint}", file=sys.stderr)
sys.exit(1)
def detect_repo_root() -> Optional[str]:
code, out, _ = _run(["git", "rev-parse", "--show-toplevel"])
if code != 0:
return None
return out.strip()
def parse_repo_from_url(url: str) -> Optional[str]:
u = url.strip()
if not u:
return None
if "github.com:" in u:
path = u.split("github.com:", 1)[1]
elif "github.com/" in u:
path = u.split("github.com/", 1)[1]
elif u.startswith("github.com/"):
path = u.split("github.com/", 1)[1]
else:
return None
if path.endswith(".git"):
path = path[:-4]
parts = path.strip("/").split("/")
if len(parts) >= 2:
return f"{parts[0]}/{parts[1]}"
return None
def detect_repo_from_git() -> Optional[str]:
code, out, _ = _run(["git", "rev-parse", "--is-inside-work-tree"])
if code != 0 or out.strip() != "true":
return None
code, origin_url, _ = _run(["git", "config", "--get", "remote.origin.url"])
if code != 0:
return None
return parse_repo_from_url(origin_url)
def reviewers_json_path(reviewer: str) -> str:
root = detect_repo_root() or os.getcwd()
path = os.path.join(root, "reviewers")
os.makedirs(path, exist_ok=True)
return os.path.join(path, f"{reviewer}.json")
def load_reviewer_json(reviewer: str) -> Optional[Dict[str, Any]]:
p = reviewers_json_path(reviewer)
if not os.path.isfile(p):
return None
try:
with open(p, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def is_https_url(s: str) -> bool:
try:
return s.startswith("https://")
except Exception:
return False
def load_json_from_path(path: str) -> Optional[Dict[str, Any]]:
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def load_json_from_source(source: str) -> Optional[Dict[str, Any]]:
if is_https_url(source):
try:
with urlopen(source, timeout=30) as resp:
if resp.status != 200:
return None
data = resp.read()
return json.loads(data.decode("utf-8"))
except Exception:
return None
return load_json_from_path(source)
def reviewer_from_source(source: str, data: Optional[Dict[str, Any]]) -> str:
# Prefer explicit name inside JSON
name = None
try:
name = (data or {}).get("reviewer")
except Exception:
name = None
if isinstance(name, str) and name.strip():
return name.strip()
# Fallback: stem from path or URL
if is_https_url(source):
try:
p = urlparse(source)
base = os.path.basename(p.path)
stem = os.path.splitext(base)[0]
return stem or "reviewer"
except Exception:
return "reviewer"
base = os.path.basename(source)
return os.path.splitext(base)[0] or "reviewer"
def save_reviewer_json(reviewer: str, data: Dict[str, Any]):
"""Atomically write reviewers/<reviewer>.json to avoid corruption on interrupts."""
p = reviewers_json_path(reviewer)
dirpath = os.path.dirname(p)
os.makedirs(dirpath, exist_ok=True)
tmp_path = os.path.join(dirpath, f".{os.path.basename(p)}.tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, p)
def save_json_to_path(path: str, data: Dict[str, Any]):
dirpath = os.path.dirname(path)
os.makedirs(dirpath, exist_ok=True)
tmp_path = os.path.join(dirpath, f".{os.path.basename(path)}.tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
def get_current_branch() -> str:
code, out, _ = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
return out.strip() if code == 0 else "HEAD"
def resolve_base_ref() -> str:
# Prefer remote tracking branch if available.
for ref in ("origin/main", "upstream/main", "main"):
code, _, _ = _run(["git", "rev-parse", "--verify", ref])
if code == 0:
return ref
return "main"
def get_diff_text(base_ref: str, head_ref: str) -> str:
# Use merge-base (three-dot) to focus only on changes introduced on the branch.
# Avoid color codes for clean parsing in the model.
code, out, err = _run(["git", "diff", "--no-color", f"{base_ref}...{head_ref}"])
if code != 0:
print(f"Error: failed to compute git diff: {err.strip()}", file=sys.stderr)
sys.exit(2)
return out
def get_changed_files_count(base_ref: str, head_ref: str) -> int:
code, out, _ = _run(["git", "diff", "--name-only", "--no-color", f"{base_ref}...{head_ref}"])
if code != 0:
return 0
return sum(1 for ln in out.splitlines() if ln.strip())
# Approximate token estimation: ~4 characters per token heuristic.
MAX_DIFF_TOKENS = 50_000
def estimate_tokens_approx(text: str) -> int:
# Conservative: 1 token per 4 characters; at least number of whitespace-separated words.
by_chars = (len(text) + 3) // 4
by_words = len(text.split())
return max(by_chars, by_words)
def study_files_in_dir(base: str) -> List[str]:
if not os.path.isdir(base):
return []
files = []
for name in os.listdir(base):
if re.match(r"PR-\d+-study\.md$", name):
files.append(os.path.join(base, name))
# Sort by PR number for determinism
def prnum(p: str) -> int:
m = re.search(r"(\d+)", os.path.basename(p))
return int(m.group(1)) if m else 0
return sorted(files, key=prnum)
def build_prompt(studyguide: str, diff_text: str, branch: str, base_ref: str) -> str:
return (
"You are a senior code reviewer. Evaluate the current branch diff against a study guide.\n\n"
f"Branch: {branch}\nBase: {base_ref}\n\n"
"STUDYGUIDE (Markdown):\n" + studyguide + "\n\n"
"DIFF (unified):\n```diff\n" + diff_text + "\n```\n\n"
"Task: Determine whether this diff adheres to the DOs and DON'Ts from the studyguide.\n"
"- The studyguide might be irrelevant to this diff; mark that clearly.\n"
"- If relevant and the diff violates items, list each failing point.\n"
"- If everything passes, return a single green check.\n\n"
"Output: Respond with EXACTLY one JSON object as RAW JSON (no Markdown, no backticks). Nothing else.\n"
"Schema: {\n \"relevant\": boolean,\n \"passes\": boolean,\n \"failures\": [\n { \"issue\": string, \"file\": string, \"line\": number, \"excerpt\": string }\n ] // empty if passes or irrelevant\n}\n"
"Rules:\n- Use true/false for booleans.\n- Provide best-effort file and 1-based line number from the diff; if unknown, use an empty string for file and -1 for line.\n- excerpt should be a short single-line or trimmed code snippet near the line.\n- When irrelevant, set relevant=false and passes=true and failures=[].\n- Do not wrap output in code fences.\n"
)
def run_codex_exec(prompt: str, last_message_file: Optional[str] = None) -> Tuple[int, str, str]:
# Prefer globally installed codex; fallback to cargo run.
if shutil.which("codex") is not None:
cmd = ["codex", "-c", "model_reasoning_effort=high", "exec"]
if last_message_file:
cmd.extend(["--output-last-message", last_message_file])
return _run(cmd, input_text=prompt)
cmd = [
"cargo",
"run",
"--quiet",
"--bin",
"codex",
"--",
"-c",
"model_reasoning_effort=high",
"exec",
]
if last_message_file:
cmd.extend(["--output-last-message", last_message_file])
return _run(cmd, input_text=prompt)
def build_study_prompt(contents: str, reviewer: str, out_path: str) -> str:
return (
f"{contents}\n---\n"
f"Summarize the takeaways from this PR review by {reviewer} into a concise, practical guide with two checklists: DOs and DON'Ts. "
f"Add short, accurate code examples in fenced code blocks to illustrate key points. "
f"Output ONLY the final document as your final message — no preamble, no status notes, no explanations about saving files. "
f"The CLI will save your final message to {out_path}."
)
def study_one_from_json(pr_number: int, markdown: str, reviewer: str, dump_dir: str, force: bool = False) -> Tuple[int, Optional[str], Optional[str]]:
"""Return (pr_number, studyguide_text_or_none, error_or_none)."""
try:
os.makedirs(dump_dir, exist_ok=True)
out_path = os.path.join(dump_dir, f"PR-{pr_number}-study.md")
if (not force) and os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
with open(out_path, "r", encoding="utf-8") as f:
return pr_number, f.read(), None
prompt = build_study_prompt(markdown, reviewer, out_path)
code, out, err = run_codex_exec(prompt, last_message_file=out_path)
if code != 0:
return pr_number, None, f"codex exec failed (exit {code}): {err.strip()}"
# Fallback to stdout content if file missing/empty
try:
if (not os.path.isfile(out_path)) or os.path.getsize(out_path) == 0:
with open(out_path, "w", encoding="utf-8") as f:
f.write(out)
except Exception:
pass
with open(out_path, "r", encoding="utf-8") as f:
return pr_number, f.read(), None
except Exception as e:
return pr_number, None, str(e)
def _study_fill_studyguides(
data: Dict[str, Any],
reviewer: str,
jobs: int = 10,
limit: Optional[int] = None,
debug: bool = False,
force: bool = False,
save_path: Optional[str] = None,
):
prs = list(data.get("prs") or [])
items = []
for p in prs:
n = p.get("number")
md = p.get("markdown", "")
if not isinstance(n, int) or not isinstance(md, str) or not md.strip():
continue
if (not force) and isinstance(p.get("studyguide"), str) and p.get("studyguide").strip():
continue
items.append((n, md))
if limit is not None:
items = items[:limit]
repo_root = detect_repo_root() or os.getcwd()
dump_dir = os.path.join(repo_root, "reviewers", "dump", reviewer)
if (not debug) and os.path.isdir(dump_dir):
shutil.rmtree(dump_dir, ignore_errors=True)
os.makedirs(dump_dir, exist_ok=True)
total = len(items)
print(f"Generating studyguides for {total} PR(s) → {dump_dir}")
lock = threading.Lock()
completed = 0
print_study_progress(completed, total, lock)
results: List[Tuple[int, Optional[str], Optional[str]]] = []
# Build a map for quick updates
num_to_obj: Dict[int, Dict[str, Any]] = {}
for p in prs:
try:
n = int(p.get("number"))
num_to_obj[n] = p
except Exception:
continue
save_lock = threading.Lock()
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, jobs)) as ex:
futs = [ex.submit(study_one_from_json, n, md, reviewer, dump_dir, force) for (n, md) in items]
for fut in concurrent.futures.as_completed(futs):
n, sg, err = fut.result()
results.append((n, sg, err))
# Persist incrementally on success
if sg:
obj = num_to_obj.get(n)
if obj is not None:
obj["studyguide"] = sg
with save_lock:
if save_path:
save_json_to_path(save_path, data)
else:
save_reviewer_json(reviewer, data)
# After saving into JSON, delete the perPR study file unless in debug mode
if not debug:
try:
os.remove(os.path.join(dump_dir, f"PR-{n}-study.md"))
except FileNotFoundError:
pass
except Exception:
# besteffort cleanup; ignore failures
pass
completed += 1
print_study_progress(completed, total, lock)
# Apply
number_to_study = {n: sg for (n, sg, err) in results if sg}
errors = [(n, err) for (n, sg, err) in results if err]
for p in prs:
n = p.get("number")
if n in number_to_study:
p["studyguide"] = number_to_study[n]
ok = len(number_to_study)
print(f"Done. {ok}/{total} studyguides generated.")
if errors:
print("Errors:")
for n, e in errors:
print(f"- PR-{n}: {e}")
def parse_json_from_text(text: str) -> Optional[Dict]:
# Accept raw JSON or a fenced ```json block; return parsed dict if possible.
text = text.strip()
# Prefer raw JSON
if text.startswith("{") and text.endswith("}"):
try:
return json.loads(text)
except Exception:
pass
# Fallback: fenced code block
m = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", text, re.IGNORECASE)
if m:
try:
return json.loads(m.group(1))
except Exception:
return None
return None
def _format_failure_display(item: Dict[str, Any]) -> str:
issue = str(item.get("issue", "")).strip()
file = str(item.get("file", "")).strip()
line = item.get("line")
try:
line = int(line) if line is not None else -1
except Exception:
line = -1
excerpt = str(item.get("excerpt", "")).strip()
header = f"@{file}:{line}" if file else "@"
lines: List[str] = [header]
if excerpt:
lines.append(excerpt)
if issue:
lines.append(f"> {issue}")
return "\n".join(lines).strip()
def _to_structured_failure(guide: str, item: Any) -> Dict[str, Any]:
if isinstance(item, str):
return {
"guide": guide,
"issue": item.strip(),
"file": "",
"line": -1,
"excerpt": "",
}
if isinstance(item, dict):
issue = str(item.get("issue", "")).strip()
file = str(item.get("file", "")).strip()
line = item.get("line")
try:
line = int(line) if line is not None else -1
except Exception:
line = -1
excerpt = str(item.get("excerpt", "")).strip()
return {"guide": guide, "issue": issue, "file": file, "line": line, "excerpt": excerpt}
# Fallback
return {"guide": guide, "issue": str(item).strip(), "file": "", "line": -1, "excerpt": ""}
def review_one(
study_path: str,
diff_text: str,
branch: str,
base_ref: str,
out_dir: str,
force: bool = False,
) -> Tuple[str, bool, List[str], List[Dict[str, Any]], Optional[str]]:
# Returns (study_filename, passes, failures, error)
try:
with open(study_path, "r", encoding="utf-8") as f:
studyguide = f.read()
prompt = build_prompt(studyguide, diff_text, branch, base_ref)
os.makedirs(out_dir, exist_ok=True)
tmp_outfile = os.path.join(out_dir, os.path.basename(study_path).replace("-study.md", "-review.json"))
# Reuse cached result unless forcing a recompute
content = None
if (not force) and os.path.isfile(tmp_outfile) and os.path.getsize(tmp_outfile) > 0:
try:
with open(tmp_outfile, "r", encoding="utf-8") as f:
content = f.read()
except Exception:
content = None
if content is None:
code, out, err = run_codex_exec(prompt, last_message_file=tmp_outfile)
if code != 0:
return (os.path.basename(study_path), False, [], [], f"codex exec failed (exit {code}): {err.strip()}")
# Prefer file written by codex; fall back to captured stdout
try:
if os.path.isfile(tmp_outfile) and os.path.getsize(tmp_outfile) > 0:
with open(tmp_outfile, "r", encoding="utf-8") as f:
content = f.read()
else:
content = out
except Exception:
content = out
data = parse_json_from_text(content)
if not data:
return (os.path.basename(study_path), False, [], [], "could not parse JSON from model output")
# Normalize file on disk to pretty-printed raw JSON for future reuse.
# Normalize cache file to pretty JSON
try:
with open(tmp_outfile, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
except Exception:
pass
relevant = bool(data.get("relevant", True))
passes = bool(data.get("passes", False))
raw_failures = list(data.get("failures") or [])
structured = [_to_structured_failure(os.path.basename(study_path), x) for x in raw_failures]
failures = [_format_failure_display(x) for x in structured]
# If irrelevant, treat as pass-by-default (per schema instructions)
if not relevant:
passes = True
failures = []
structured = []
return (os.path.basename(study_path), passes, failures, structured, None)
except Exception as e:
return (os.path.basename(study_path), False, [], [], str(e))
def review_one_from_json(
pr_number: int,
studyguide_text: str,
diff_text: str,
branch: str,
base_ref: str,
out_dir: str,
force: bool = False,
) -> Tuple[str, bool, List[str], List[Dict[str, Any]], Optional[str]]:
label = f"PR-{pr_number}-study.md"
try:
os.makedirs(out_dir, exist_ok=True)
tmp_outfile = os.path.join(out_dir, f"PR-{pr_number}-review.json")
content = None
if (not force) and os.path.isfile(tmp_outfile) and os.path.getsize(tmp_outfile) > 0:
try:
with open(tmp_outfile, "r", encoding="utf-8") as f:
content = f.read()
except Exception:
content = None
if content is None:
prompt = build_prompt(studyguide_text, diff_text, branch, base_ref)
code, out, err = run_codex_exec(prompt, last_message_file=tmp_outfile)
if code != 0:
return (label, False, [], [], f"codex exec failed (exit {code}): {err.strip()}")
try:
if os.path.isfile(tmp_outfile) and os.path.getsize(tmp_outfile) > 0:
with open(tmp_outfile, "r", encoding="utf-8") as f:
content = f.read()
else:
content = out
except Exception:
content = out
data = parse_json_from_text(content)
if not data:
return (label, False, [], [], "could not parse JSON from model output")
try:
with open(tmp_outfile, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
except Exception:
pass
relevant = bool(data.get("relevant", True))
passes = bool(data.get("passes", False))
raw_failures = list(data.get("failures") or [])
structured = [_to_structured_failure(label, x) for x in raw_failures]
failures = [_format_failure_display(x) for x in structured]
if not relevant:
passes = True
failures = []
structured = []
return (label, passes, failures, structured, None)
except Exception as e:
return (label, False, [], [], str(e))
def aggregate_deduplicate(failures_all: List[Dict[str, Any]], diff_text: str, out_dir: str) -> Tuple[str, Optional[List[Dict[str, Any]]], Optional[str]]:
"""Run Codex to deduplicate failures. Returns (outfile_path, dedup_list_or_none, error_or_none)."""
if not failures_all:
return ("", [], None)
out_path = os.path.join(out_dir, "aggregate-dedup.json")
issues_json = json.dumps(failures_all, indent=2)
prompt = (
"You are assisting with de-duplicating code review issues.\n\n"
"DIFF (unified):\n```diff\n" + diff_text + "\n```\n\n"
"Issues (JSON array where each item has keys: guide, issue, file, line, excerpt):\n"
+ issues_json + "\n\n"
"Task: Deduplicate issues that are semantically the same, ignoring differences in file, line, or excerpt.\n"
"Keep the single most descriptive 'issue' text for each group and retain its metadata (guide, file, line, excerpt).\n"
"Output: EXACT RAW JSON array (no Markdown, no backticks) with the same object shape as the input."
)
code, out, err = run_codex_exec(prompt, last_message_file=out_path)
if code != 0:
return (out_path, None, f"codex exec failed (exit {code}): {err.strip()}")
# Read result (file or stdout) and parse JSON
content = None
try:
if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
with open(out_path, "r", encoding="utf-8") as f:
content = f.read()
else:
content = out
except Exception:
content = out
try:
data = json.loads(content)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
return (out_path, data, None)
except Exception as e:
return (out_path, None, f"failed to parse dedup JSON: {e}")
def aggregate_rank(dedup_list: List[Dict[str, Any]], diff_text: str, out_dir: str) -> Tuple[str, Optional[str]]:
out_path = os.path.join(out_dir, "aggregate-ranked.json")
issues_json = json.dumps(dedup_list, indent=2)
prompt = (
"You are assisting with triage and prioritization of code review issues.\n\n"
"DIFF (unified):\n```diff\n" + diff_text + "\n```\n\n"
"Issues (JSON array; each item has guide, issue, file, line, excerpt):\n"
+ issues_json + "\n\n"
"Task: For each issue, assign a category: P0, P1, P2, NIT, WRONG, IRRELEVANT.\n"
"Output: EXACT RAW JSON object mapping category -> array of issues, preserving the same fields for each issue.\n"
"Schema: { \"P0\": Issue[], \"P1\": Issue[], \"P2\": Issue[], \"NIT\": Issue[], \"WRONG\": Issue[], \"IRRELEVANT\": Issue[] }"
)
code, out, err = run_codex_exec(prompt, last_message_file=out_path)
if code != 0:
return (out_path, f"codex exec failed (exit {code}): {err.strip()}")
# Parse and normalize JSON
content = None
try:
if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
with open(out_path, "r", encoding="utf-8") as f:
content = f.read()
else:
content = out
except Exception:
content = out
try:
data = json.loads(content)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
return (out_path, None)
except Exception as e:
return (out_path, f"failed to parse ranked JSON: {e}")
def print_progress(passed: int, completed: int, total: int, lock: threading.Lock):
pct = int((completed / total) * 100) if total else 0
width = 30
filled = int((completed / total) * width) if total else 0
bar = "#" * filled + "-" * (width - filled)
failed = max(0, completed - passed)
with lock:
print(f"[{bar}] {completed}/{total} completed ({pct}%), {failed} failed")
def print_study_progress(completed: int, total: int, lock: threading.Lock):
pct = int((completed / total) * 100) if total else 0
width = 30
filled = int((completed / total) * width) if total else 0
bar = "#" * filled + "-" * (width - filled)
with lock:
print(f"[{bar}] {completed}/{total} studyguides ({pct}%)")
def study_cli(argv: List[str]):
parser = argparse.ArgumentParser(prog="review study", description="Generate studyguides into reviewers/<user>.json and dump files.")
parser.add_argument("reviewer", nargs="?", help="GitHub login")
parser.add_argument("--profile", "-p", help="Path to reviewers JSON; overrides reviewer and save path")
parser.add_argument("--days", "-d", type=int, default=100, help="Look back N days for PRs (default: 30)")
parser.add_argument("--jobs", "-j", type=int, default=200, help="Parallel jobs (default: 10)")
parser.add_argument("--limit", "-n", type=int, default=None, help="Limit number of PRs processed")
parser.add_argument("--debug", action="store_true", help="Do not clear reviewers/dump/<reviewer> before running")
parser.add_argument("--force", action="store_true", help="Regenerate studyguides even if present")
args = parser.parse_args(argv)
# Ensure reviewers JSON exists via lastprs (skipped when --profile is used)
script_dir = os.path.dirname(os.path.abspath(__file__))
local_lastprs = os.path.join(script_dir, "lastprs")
lastprs_cmd = local_lastprs if os.path.isfile(local_lastprs) and os.access(local_lastprs, os.X_OK) else "lastprs"
profile_path: Optional[str] = args.profile
reviewer_arg = args.reviewer
data: Optional[Dict[str, Any]] = None
reviewer: Optional[str] = None
if profile_path:
data = load_json_from_source(profile_path)
if data is None:
print(f"Error: profile not found or invalid JSON: {profile_path}", file=sys.stderr)
sys.exit(2)
reviewer = reviewer_from_source(profile_path, data)
else:
if not reviewer_arg:
print("Error: reviewer is required when --profile is not provided.", file=sys.stderr)
sys.exit(2)
reviewer = reviewer_arg
data = load_reviewer_json(reviewer)
if data is None:
print(f"Running lastprs for {reviewer} (days={args.days})…")
code, out, err = _run([lastprs_cmd, str(args.days), reviewer])
if code != 0:
print(f"lastprs failed: {err.strip()}", file=sys.stderr)
sys.exit(2)
if out.strip():
print(out.strip())
data = load_reviewer_json(reviewer)
if data is None:
print("Failed to load reviewers JSON after lastprs.", file=sys.stderr)
sys.exit(2)
# If requested days exceeds what's in the JSON, refresh via lastprs automatically
try:
existing_days = int((data or {}).get("days", 0))
except Exception:
existing_days = 0
if (not profile_path) and args.days is not None and args.days > existing_days:
print(
f"Dataset is {existing_days} day(s); requested --days={args.days}. Refreshing via lastprs…"
)
code = _run_streaming([lastprs_cmd, str(args.days), reviewer])
if code != 0:
print("lastprs failed (see output above)", file=sys.stderr)
else:
data2 = load_reviewer_json(reviewer)
if data2 is not None:
data = data2
# If a limit was requested that exceeds dataset size, refresh via lastprs automatically
current_count = len(list(data.get("prs") or []))
if (not profile_path) and args.limit is not None and args.limit > current_count:
print(
f"Dataset has {current_count} PR(s), but --limit is {args.limit}. Refreshing via lastprs (days={args.days})…"
)
code = _run_streaming([lastprs_cmd, str(args.days), reviewer])
if code != 0:
print("lastprs failed (see output above)", file=sys.stderr)
# Continue with what we have rather than aborting
else:
data2 = load_reviewer_json(reviewer)
if data2 is not None:
data = data2
_study_fill_studyguides(
data,
reviewer,
jobs=args.jobs,
limit=args.limit,
debug=args.debug,
force=args.force,
save_path=None if (profile_path and is_https_url(profile_path)) else profile_path,
)
if profile_path:
if is_https_url(profile_path):
save_reviewer_json(reviewer, data)
print(f"Source was remote. Updated reviewers/{reviewer}.json with studyguides.")
else:
save_json_to_path(profile_path, data)
print(f"Updated {profile_path} with studyguides.")
else:
save_reviewer_json(reviewer, data)
print(f"Updated reviewers/{reviewer}.json with studyguides.")
def main():
# Subcommand dispatch (lightweight to preserve existing flags)
if len(sys.argv) > 1 and sys.argv[1] == "study":
study_cli(sys.argv[2:])
return
parser = argparse.ArgumentParser(
prog="review",
description=(
"Evaluate the current branch diff against studyguides stored in reviewers/<user>.json.\n"
"Aggregates results, shows a progress bar, and writes outputs to reviewers/dump/<user>/."
),
)
parser.add_argument("reviewer", nargs="?", help="GitHub login whose studyguides to use (from reviewers/<user>.json)")
parser.add_argument("--profile", "-p", help="Path to reviewers JSON; overrides reviewer and dataset path")
parser.add_argument("--jobs", "-j", type=int, default=10, help="Parallel jobs (default: 10)")
parser.add_argument("--base", default=None, help="Base ref to diff against (default: auto: origin/main or main)")
parser.add_argument(
"--limit",
"-n",
type=int,
default=None,
help="Use only the first N study guides after sorting (like head -n)",
)
parser.add_argument(
"--hide-errors",
action="store_true",
help="Do not print per-guide errors encountered",
)
parser.add_argument(
"--force",
action="store_true",
help="Recompute review JSONs even if cached results exist",
)
parser.add_argument("--debug", action="store_true", help="Do not clear reviewers/dump/<reviewer> before running")
# Default behavior: clear reviewers/dump/<reviewer> unless --debug is set in either run or study mode.
args = parser.parse_args()
require("gh", "Install GitHub CLI: https://cli.github.com (used by other tools in this repo)")
repo_root = detect_repo_root() or os.getcwd()
profile_path: Optional[str] = args.profile
data: Optional[Dict[str, Any]] = None
# Resolve reviewer and dataset
if profile_path:
data = load_json_from_source(profile_path)
if data is None:
print(f"Error: profile not found or invalid JSON: {profile_path}", file=sys.stderr)
sys.exit(2)
reviewer = reviewer_from_source(profile_path, data)
else:
reviewer = args.reviewer
if not reviewer:
print("Error: reviewer is required when --profile is not provided.", file=sys.stderr)
sys.exit(2)
# Preferred source: reviewers/<reviewer>.json
data = load_reviewer_json(reviewer)
if data is None:
repo = detect_repo_from_git() or "owner/repo"
print(f"No reviewers/{reviewer}.json found.")
try:
resp = input(f"Study {reviewer}'s last 100 days of PRs under {repo}? [y/N] ").strip().lower()
except EOFError:
resp = "n"
if resp == "y":
code, out, err = _run([os.path.join(repo_root, "lastprs"), "100", reviewer])
if code != 0:
print(f"lastprs failed: {err.strip()}", file=sys.stderr)
sys.exit(2)
data = load_reviewer_json(reviewer)
if data is None:
print("Failed to load reviewers JSON after lastprs.", file=sys.stderr)
sys.exit(2)
# Prompt to run study now
try:
resp2 = input("Generate studyguides now? [Y/n] ").strip().lower()
except EOFError:
resp2 = "y"
if resp2 in ("", "y"):
_study_fill_studyguides(data, reviewer, jobs=args.jobs, limit=args.limit, debug=False)
save_reviewer_json(reviewer, data)
else:
print("Aborting: reviewer dataset not found.", file=sys.stderr)
sys.exit(2)
# Build list of (pr_number, studyguide)
prs = list(data.get("prs") or [])
# Optional: array of PR numbers to ignore when reviewing
ignore_list = data.get("ignore") or []
ignore_set = set()
try:
for x in ignore_list:
try:
ignore_set.add(int(x))
except Exception:
continue
except Exception:
ignore_set = set()
pairs: List[Tuple[int, str]] = []
for p in prs:
n = p.get("number")
sg = p.get("studyguide", "")
if isinstance(n, int) and isinstance(sg, str) and sg.strip():
if n in ignore_set:
continue
pairs.append((n, sg))
if not pairs:
msg = f"No studyguides present in reviewers/{reviewer}.json. Try: ./review study {reviewer}"
if ignore_set:
msg = f"No studyguides to review after applying ignore list ({len(ignore_set)} ignored). Try: ./review study {reviewer}"
print(msg, file=sys.stderr)
sys.exit(2)
total_available = len(pairs)
if args.limit is not None:
if args.limit <= 0:
print("Error: --limit must be a positive integer.", file=sys.stderr)
sys.exit(2)
pairs = pairs[: args.limit]
# Output dir: reviewers/dump/<reviewer> (clear unless --debug)
out_dir = os.path.join(repo_root, "reviewers", "dump", reviewer)
if not getattr(args, "debug", False) and os.path.isdir(out_dir):
shutil.rmtree(out_dir, ignore_errors=True)
os.makedirs(out_dir, exist_ok=True)
branch = get_current_branch()
base_ref = args.base or resolve_base_ref()
diff_text = get_diff_text(base_ref, "HEAD")
files_changed = get_changed_files_count(base_ref, "HEAD")
est_tokens = estimate_tokens_approx(diff_text)
if not diff_text.strip():
print("Warning: empty diff vs base; all guides may be irrelevant or pass.", file=sys.stderr)
# out_dir already set earlier (reviewers/dump/<reviewer>)
total = len(pairs)
passed = 0
completed = 0
lock = threading.Lock()
failures_all: List[Dict[str, Any]] = [] # structured failures
errors_all: List[Tuple[str, str]] = [] # (guide, error)
print(f"Running {total} review(s) against {branch} vs {base_ref}")
print(f"Files changed: {files_changed}")
print(f"Estimated diff tokens: {est_tokens} (limit {MAX_DIFF_TOKENS})")
if est_tokens > MAX_DIFF_TOKENS:
print(
f"Error: diff is too large to review (estimated {est_tokens} tokens > limit {MAX_DIFF_TOKENS}).",
file=sys.stderr,
)
sys.exit(2)
print(f"Output dir: {out_dir}")
if args.limit is not None and args.limit < total_available:
print(f"Limit: using first {total} of {total_available} guides")
print_progress(passed, completed, total, lock)
# Worker for ThreadPool: evaluate a single (pr_number, studyguide_text)
def task(it: Tuple[int, str]):
pr_number, studyguide_text = it
return review_one_from_json(
pr_number,
studyguide_text,
diff_text,
branch,
base_ref,
out_dir,
force=args.force,
)
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as ex:
futs = [ex.submit(task, it) for it in pairs]
for fut in concurrent.futures.as_completed(futs):
guide_name, ok, failures_display, failures_structured, err = fut.result()
with lock:
completed += 1
if ok:
passed += 1
else:
if err:
errors_all.append((guide_name, err))
for item in failures_structured:
failures_all.append(item)
print_progress(passed, completed, total, lock)
print("")
failures_total = max(0, total - passed)
print(f"Summary: {failures_total} failed")
if (not args.hide_errors) and errors_all:
print("\nErrors:")
for g, e in errors_all:
print(f"- {g}: {e}")
if failures_all:
print("\nFailed points:")
for item in failures_all:
print(f"- [{item.get('guide','?')}] {_format_failure_display(item)}")
else:
print("\nNo failed points detected.")
# 4) Aggregate via Codex: deduplicate (optional), then rank
if failures_all:
print("\nAggregating failed points…")
dedup_path = os.path.join(out_dir, "aggregate-dedup.json")
dedup_list: List[Dict[str, Any]] = []
if len(failures_all) == 1:
# Skip model deduplication for a single issue; still write a trace file.
single = failures_all[0]
dedup_list = [single]
try:
with open(dedup_path, 'w', encoding='utf-8') as f:
json.dump(dedup_list, f, indent=2)
f.write("\n")
except Exception as e:
print(f"Failed to write dedup file: {e}", file=sys.stderr)
else:
path, data, dedup_err = aggregate_deduplicate(failures_all, diff_text, out_dir)
if dedup_err:
print(f"Dedup error: {dedup_err}", file=sys.stderr)
else:
dedup_path = path
try:
with open(dedup_path, 'r', encoding='utf-8') as f:
dedup_list = json.load(f)
except Exception as e:
print(f"Failed to read dedup file: {e}", file=sys.stderr)
dedup_list = []
if dedup_list:
print(f"\nDeduplicated issues written to: {dedup_path}\n")
preview = json.dumps(dedup_list, indent=2)[:2000]
print(preview)
ranked_path, rank_err = aggregate_rank(dedup_list, diff_text, out_dir)
if rank_err:
print(f"Ranking error: {rank_err}", file=sys.stderr)
else:
try:
with open(ranked_path, 'r', encoding='utf-8') as f:
ranked_text = f.read()
print(f"\nRanked issues written to: {ranked_path}\n")
print(ranked_text.strip()[:2000])
except Exception as e:
print(f"Failed to read ranked file: {e}", file=sys.stderr)
if __name__ == "__main__":
main()