117 lines
4.4 KiB
Python
117 lines
4.4 KiB
Python
"""Assistive AI reviewer: local simulation of a PR-reviewer bot.
|
|
|
|
This stands in for a forge-native reviewer (an app/bot triggered when a PR opens, running on a
|
|
runner from Module 19) without needing any hosted account. It does the two deterministic halves of
|
|
the job and leaves the one judgment call (what actually happens to the PR) to you.
|
|
|
|
python3 reviewer.py prompt # assemble the prompt: rubric + diff, for the agent to review
|
|
python3 reviewer.py apply ai-review.sample.json # ingest the agent's JSON, render it, gate it
|
|
|
|
The point of this module: the agent produces comments and a recommendation. It never approves,
|
|
never requests-changes-as-a-gate, never merges. The `apply` step ends at a HUMAN DECISION, every
|
|
time. Stdlib only, no pip install.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).parent
|
|
|
|
|
|
def load_json_response(path: Path):
|
|
"""Parse the JSON the AI returned.
|
|
|
|
Chat assistants very often wrap their output in a ```json ... ``` code fence (or add a stray
|
|
line of text) even when told to "return only the JSON", so a strict json.loads on the raw paste
|
|
fails on the most likely real output. Try a strict parse first; if that fails, fall back to the
|
|
outermost { ... } block, which survives a code fence or surrounding text. Stdlib only."""
|
|
raw = path.read_text()
|
|
try:
|
|
return json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
start, end = raw.find("{"), raw.rfind("}")
|
|
if start != -1 and end > start:
|
|
return json.loads(raw[start : end + 1])
|
|
raise
|
|
|
|
|
|
PROMPT_HEADER = """\
|
|
You are an assistive code reviewer. Follow the rubric below exactly, then review the diff that
|
|
follows it. Return ONLY the JSON object the rubric specifies, with no extra text before or after.
|
|
|
|
================ REVIEW RUBRIC ================
|
|
{rubric}
|
|
|
|
================ DIFF UNDER REVIEW ============
|
|
{diff}
|
|
"""
|
|
|
|
|
|
def cmd_prompt(args: argparse.Namespace) -> int:
|
|
rubric = Path(args.rubric).read_text()
|
|
diff = Path(args.patch).read_text()
|
|
print(PROMPT_HEADER.format(rubric=rubric, diff=diff))
|
|
return 0
|
|
|
|
|
|
def cmd_apply(args: argparse.Namespace) -> int:
|
|
try:
|
|
review = load_json_response(Path(args.response))
|
|
except (json.JSONDecodeError, FileNotFoundError) as exc:
|
|
print(f"error: could not read a JSON review from {args.response}: {exc}")
|
|
return 1
|
|
|
|
summary = review.get("summary", "(no summary)")
|
|
recommendation = review.get("recommendation", "comment")
|
|
comments = review.get("comments", [])
|
|
|
|
print("=" * 70)
|
|
print("AI REVIEWER: first pass (advisory only)")
|
|
print("=" * 70)
|
|
print(f"\nSummary: {summary}\n")
|
|
|
|
if not comments:
|
|
print("No line comments.\n")
|
|
order = {"blocker": 0, "suggestion": 1, "nit": 2}
|
|
for c in sorted(comments, key=lambda c: order.get(c.get("severity", "nit"), 9)):
|
|
sev = c.get("severity", "nit").upper()
|
|
loc = f"{c.get('file', '?')}:{c.get('line', '?')}"
|
|
print(f" [{sev:10}] {loc}")
|
|
print(f" {c.get('comment', '')}\n")
|
|
|
|
print("-" * 70)
|
|
print(f"Agent's recommendation: {recommendation}")
|
|
print("-" * 70)
|
|
print(
|
|
"\nThis is the human decision gate. The agent did NOT merge, approve, or block.\n"
|
|
"It only commented. You decide what happens next:\n"
|
|
" - merge you read the comments, you disagree or they're addressed\n"
|
|
" - request changes you agree; push the fix on the branch and re-run\n"
|
|
" - dismiss the agent is wrong or noisy; ignore and move on\n"
|
|
"\nNothing in this repo changes until you act. That's the whole point of Module 24.\n"
|
|
)
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
|
|
p = sub.add_parser("prompt", help="assemble the review prompt for the agent to act on")
|
|
p.add_argument("--rubric", default=str(HERE / "review-rubric.md"))
|
|
p.add_argument("--patch", default=str(HERE / "feature.patch"))
|
|
p.set_defaults(func=cmd_prompt)
|
|
|
|
a = sub.add_parser("apply", help="ingest the AI's JSON review and render the decision gate")
|
|
a.add_argument("response", help="path to the JSON the AI returned")
|
|
a.set_defaults(func=cmd_apply)
|
|
|
|
args = parser.parse_args(argv)
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|