mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
1035 lines
40 KiB
Python
Executable File
1035 lines
40 KiB
Python
Executable File
#!/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 per‑PR 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:
|
||
# best‑effort 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()
|