mirror of
https://github.com/openai/codex.git
synced 2026-04-25 15:15:15 +00:00
207 lines
7.3 KiB
Python
Executable File
207 lines
7.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import argparse
|
|
import concurrent.futures
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from typing import List, Optional, Tuple
|
|
|
|
|
|
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 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 ensure_dir(path: str):
|
|
os.makedirs(path, exist_ok=True)
|
|
|
|
|
|
def pr_file_paths(out_dir: str) -> List[str]:
|
|
if not os.path.isdir(out_dir):
|
|
return []
|
|
paths = []
|
|
for name in os.listdir(out_dir):
|
|
if re.match(r"PR-\d+\.md$", name):
|
|
paths.append(os.path.join(out_dir, name))
|
|
# Sort by PR number ascending
|
|
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(paths, key=prnum)
|
|
|
|
|
|
def extract_pr_number(path: str) -> int:
|
|
m = re.search(r"(\d+)", os.path.basename(path))
|
|
return int(m.group(1)) if m else 0
|
|
|
|
|
|
def build_prompt(contents: str, reviewer: str, out_path: str) -> str:
|
|
# We rely on `codex exec --output-last-message {out_path}` to write the
|
|
# final message to disk. Instruct the agent to ONLY produce the final
|
|
# document as its last message (no meta commentary), to avoid clutter.
|
|
return (
|
|
f"{contents}\n---\n"
|
|
f"Summarize the takeaways from this PR review by {reviewer} into a concise, generalizable, and practical guide with two checklists: DOs and DON'Ts. "
|
|
f"Add short, accurate code examples in fenced code blocks to illustrate each key point. "
|
|
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 run_codex_exec(prompt: str, last_message_file: Optional[str] = None) -> Tuple[int, str, str]:
|
|
# Prefer a globally installed `codex`; fall back to cargo if needed.
|
|
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)
|
|
# Fallback: use cargo run (may build; slower but reliable in dev)
|
|
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 study_one(pr_md_path: str, reviewer: str, out_dir: str) -> Tuple[str, str]:
|
|
pr_num = extract_pr_number(pr_md_path)
|
|
try:
|
|
with open(pr_md_path, "r", encoding="utf-8") as f:
|
|
contents = f.read()
|
|
ensure_dir(out_dir)
|
|
out_path = os.path.join(out_dir, f"PR-{pr_num}-study.md")
|
|
prompt = build_prompt(contents, reviewer, out_path)
|
|
code, out, err = run_codex_exec(prompt, last_message_file=out_path)
|
|
if code != 0:
|
|
return pr_md_path, f"error: codex exec failed (exit {code}): {err.strip()}"
|
|
# If Codex did not write the file for some reason, fall back to captured stdout.
|
|
# Note: we only fallback when the output file is missing/empty to avoid
|
|
# overwriting a valid summary produced by Codex.
|
|
if (not os.path.isfile(out_path)) or os.path.getsize(out_path) == 0:
|
|
try:
|
|
with open(out_path, "w", encoding="utf-8") as f:
|
|
f.write(out)
|
|
except Exception as e:
|
|
return pr_md_path, f"error: failed to write fallback output: {e}"
|
|
return pr_md_path, "ok"
|
|
except Exception as e:
|
|
return pr_md_path, f"error: {e}"
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
prog="study",
|
|
description=(
|
|
"Generate PR markdowns via lastprs, then summarize each via `codex exec`.\n"
|
|
"Writes summaries to prs/<reviewer>/study/PR-<num>-study.md."
|
|
),
|
|
)
|
|
parser.add_argument("days", type=int, help="Number of days to look back (N)")
|
|
parser.add_argument("reviewer", help="GitHub login of the reviewer")
|
|
parser.add_argument(
|
|
"repo",
|
|
nargs="?",
|
|
help="Repository in 'owner/repo' form; inferred from git origin if omitted (passed through to lastprs)",
|
|
)
|
|
parser.add_argument(
|
|
"--jobs",
|
|
"-j",
|
|
type=int,
|
|
default=10,
|
|
help="Parallel jobs for summaries (default: 10)",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-generate",
|
|
action="store_true",
|
|
help="Skip running lastprs and reuse existing prs/<reviewer>/ files",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.days <= 0:
|
|
print("Error: days must be a positive integer.", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
# Check dependencies
|
|
require("gh", "Install GitHub CLI: https://cli.github.com")
|
|
# lastprs is shipped with this repo; prefer local copy, then PATH
|
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
lastprs_path = os.path.join(script_dir, "lastprs")
|
|
if not (os.path.isfile(lastprs_path) and os.access(lastprs_path, os.X_OK)):
|
|
require("lastprs", "Ensure the lastprs helper script is on PATH or present in this folder.")
|
|
lastprs_path = "lastprs"
|
|
|
|
# Determine paths
|
|
repo_root = detect_repo_root() or os.getcwd()
|
|
prs_dir = os.path.join(repo_root, "prs", args.reviewer)
|
|
summaries_dir = os.path.join(prs_dir, "study")
|
|
|
|
# 1) Generate PR markdowns if not skipping
|
|
if not args.skip_generate:
|
|
cmd = [lastprs_path, str(args.days), args.reviewer]
|
|
if args.repo:
|
|
cmd.append(args.repo)
|
|
print("Generating PR markdowns via lastprs…", file=sys.stderr)
|
|
code, out, err = _run(cmd)
|
|
if code != 0:
|
|
print(f"Error: lastprs failed (exit {code}): {err.strip()}", file=sys.stderr)
|
|
sys.exit(code)
|
|
# Echo a short summary
|
|
sys.stderr.write(out.strip() + "\n")
|
|
|
|
# 2) Discover PR files
|
|
files = pr_file_paths(prs_dir)
|
|
if not files:
|
|
print(f"No PR markdowns found in {prs_dir}.", file=sys.stderr)
|
|
sys.exit(0)
|
|
|
|
print(f"Summarizing {len(files)} PR(s) to {summaries_dir}")
|
|
|
|
# 3) Summarize via codex exec
|
|
results: List[Tuple[str, str]] = []
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as ex:
|
|
futs = [ex.submit(study_one, p, args.reviewer, summaries_dir) for p in files]
|
|
for fut in concurrent.futures.as_completed(futs):
|
|
results.append(fut.result())
|
|
|
|
ok = sum(1 for _, s in results if s == "ok")
|
|
failures = [(p, s) for p, s in results if s != "ok"]
|
|
for p, s in failures:
|
|
print(f"{os.path.basename(p)}: {s}", file=sys.stderr)
|
|
print(f"Done. {ok}/{len(files)} summaries succeeded.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|