Files
Nexus/scripts/gate_ai_review.py
T
2026-07-08 22:31:31 +08:00

367 lines
11 KiB
Python

#!/usr/bin/env python3
"""Nexus Gate 8 — AI code review with cursor-agent and head_sha cache.
Usage:
python3 scripts/gate_ai_review.py # gate mode (auto/cache per env)
python3 scripts/gate_ai_review.py --run # force new review for HEAD
Env:
NEXUS_SKIP_AI_REVIEW=1 WARN skip (emergency)
NEXUS_AI_REVIEW_MODE=auto default: run agent when cache missing
NEXUS_AI_REVIEW_MODE=cache only validate cached review for HEAD
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 1
MAX_DIFF_BYTES = 80_000
BLOCK_SEVERITIES = frozenset({"P0", "P1"})
MIN_BLOCK_CONFIDENCE = 75
SKIP_PATH_PREFIXES = (
"docs/",
"deploy/gate_log.jsonl",
"web/app/assets/",
)
SKIP_PATH_EXACT = frozenset({"CLAUDE.md", "AGENTS.md"})
def repo_root() -> Path:
return Path(__file__).resolve().parent.parent
def run_git(*args: str, cwd: Path) -> str:
proc = subprocess.run(
["git", *args],
cwd=cwd,
check=False,
capture_output=True,
text=True,
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or f"git {' '.join(args)} failed")
return proc.stdout.strip()
def head_sha(root: Path) -> str:
return run_git("rev-parse", "HEAD", cwd=root)
def branch_name(root: Path) -> str:
return run_git("rev-parse", "--abbrev-ref", "HEAD", cwd=root)
def changed_files(root: Path) -> list[str]:
out = run_git("diff", "--name-only", "HEAD~1..HEAD", cwd=root)
if not out:
return []
return [line.strip() for line in out.splitlines() if line.strip()]
def is_reviewable(path: str) -> bool:
if path in SKIP_PATH_EXACT:
return False
if path.endswith(".md"):
return False
for prefix in SKIP_PATH_PREFIXES:
if path.startswith(prefix):
return False
return True
def reviewable_files(files: list[str]) -> list[str]:
return [f for f in files if is_reviewable(f)]
def reviews_dir(root: Path) -> Path:
d = root / "docs" / "reviews"
d.mkdir(parents=True, exist_ok=True)
return d
def load_cached_review(root: Path, sha: str) -> dict[str, Any] | None:
rdir = reviews_dir(root)
matches: list[tuple[float, Path]] = []
for path in rdir.glob("*.json"):
try:
doc = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
if doc.get("head_sha") == sha and doc.get("schema_version") == SCHEMA_VERSION:
matches.append((path.stat().st_mtime, path))
if not matches:
return None
matches.sort(key=lambda x: x[0], reverse=True)
return json.loads(matches[0][1].read_text(encoding="utf-8"))
def normalize_finding(raw: dict[str, Any]) -> dict[str, Any]:
sev = str(raw.get("severity", "P3")).upper()
if sev not in {"P0", "P1", "P2", "P3"}:
sev = "P3"
conf = raw.get("confidence", 0)
try:
conf = int(conf)
except (TypeError, ValueError):
conf = 0
conf = max(0, min(100, conf))
line = raw.get("line", 0)
try:
line = int(line)
except (TypeError, ValueError):
line = 0
return {
"severity": sev,
"file": str(raw.get("file", "")),
"line": line,
"title": str(raw.get("title", "")).strip(),
"confidence": conf,
}
def blockers(doc: dict[str, Any]) -> list[dict[str, Any]]:
verdict = str(doc.get("verdict", "")).strip()
if verdict == "Not ready":
return [{"severity": "P0", "file": "", "line": 0, "title": "verdict: Not ready", "confidence": 100}]
out: list[dict[str, Any]] = []
for raw in doc.get("findings") or []:
if not isinstance(raw, dict):
continue
f = normalize_finding(raw)
if f["severity"] in BLOCK_SEVERITIES and f["confidence"] >= MIN_BLOCK_CONFIDENCE:
out.append(f)
return out
def validate_review_doc(doc: dict[str, Any]) -> None:
required = ("schema_version", "head_sha", "verdict", "findings", "reviewed_at")
missing = [k for k in required if k not in doc]
if missing:
raise ValueError(f"review JSON missing fields: {', '.join(missing)}")
if doc["schema_version"] != SCHEMA_VERSION:
raise ValueError(f"unsupported schema_version {doc['schema_version']}")
def slug_from_files(files: list[str]) -> str:
if not files:
return "empty"
base = Path(files[0]).stem
base = re.sub(r"[^a-zA-Z0-9_-]+", "-", base).strip("-").lower()
return (base or "review")[:40]
def find_cursor_agent() -> str | None:
explicit = os.environ.get("CURSOR_AGENT_BIN")
if explicit and Path(explicit).is_file() and os.access(explicit, os.X_OK):
return explicit
local = Path.home() / ".local/bin/cursor-agent"
if local.is_file() and os.access(local, os.X_OK):
return str(local)
for name in ("cursor-agent",):
found = shutil.which(name)
if found:
return found
if shutil.which("cursor"):
proc = subprocess.run(
["cursor", "agent", "--version"],
capture_output=True,
text=True,
)
if proc.returncode == 0:
return "cursor agent"
return None
def build_diff(root: Path) -> tuple[str, bool]:
proc = subprocess.run(
["git", "diff", "-U5", "HEAD~1..HEAD"],
cwd=root,
capture_output=True,
text=True,
)
diff = proc.stdout or ""
truncated = False
if len(diff.encode("utf-8")) > MAX_DIFF_BYTES:
diff = diff[:MAX_DIFF_BYTES]
truncated = True
diff += "\n\n... [diff truncated for review gate] ...\n"
return diff, truncated
def extract_json_blob(text: str) -> dict[str, Any]:
text = text.strip()
parsed: Any = None
try:
parsed = json.loads(text)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, dict):
for key in ("result", "response", "message", "content"):
nested = parsed.get(key)
if isinstance(nested, str):
return extract_json_blob(nested)
if "findings" in parsed or "verdict" in parsed:
return parsed
match = re.search(r"\{[\s\S]*\}", text)
if match:
return extract_json_blob(match.group(0))
raise ValueError("could not parse JSON from agent output")
def run_cursor_review(root: Path, files: list[str], diff: str, truncated: bool) -> dict[str, Any]:
agent = find_cursor_agent()
if not agent:
raise RuntimeError(
"cursor-agent not found; install Cursor CLI or set CURSOR_AGENT_BIN"
)
file_list = "\n".join(f"- {f}" for f in files)
trunc_note = " (diff truncated)" if truncated else ""
prompt = f"""You are Nexus deploy gate AI reviewer. Read-only review{trunc_note}.
Changed files:
{file_list}
Git diff (HEAD~1..HEAD):
```
{diff}
```
Return ONE raw JSON object only (no markdown fence) matching:
{{
"verdict": "Ready to merge" | "Ready with fixes" | "Not ready",
"summary": "one sentence",
"findings": [
{{"severity":"P0|P1|P2|P3","file":"path","line":0,"title":"...","confidence":0|25|50|75|100}}
]
}}
Rules:
- P0/P1 only for real bugs or exploitable security issues in the diff
- confidence anchors: 0, 25, 50, 75, 100
- If no issues: findings=[] and verdict="Ready to merge"
"""
if agent == "cursor agent":
cmd = ["cursor", "agent", "-p", "--output-format", "json", "--mode", "ask", prompt]
else:
cmd = [agent, "-p", "--output-format", "json", "--mode", "ask", prompt]
proc = subprocess.run(
cmd,
cwd=root,
capture_output=True,
text=True,
timeout=int(os.environ.get("NEXUS_AI_REVIEW_TIMEOUT", "600")),
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "cursor-agent failed").strip()
raise RuntimeError(err[:500])
parsed = extract_json_blob(proc.stdout)
if "findings" not in parsed:
raise ValueError("agent JSON missing findings")
return parsed
def write_review(root: Path, sha: str, agent_payload: dict[str, Any], files: list[str]) -> Path:
today = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d")
slug = slug_from_files(files)
out_path = reviews_dir(root) / f"{today}-{slug}.json"
findings = [normalize_finding(f) for f in agent_payload.get("findings") or [] if isinstance(f, dict)]
doc = {
"schema_version": SCHEMA_VERSION,
"head_sha": sha,
"base_ref": "HEAD~1..HEAD",
"branch": branch_name(root),
"reviewed_at": datetime.now(timezone.utc).isoformat(),
"engine": "cursor-agent",
"verdict": str(agent_payload.get("verdict", "Not ready")),
"summary": str(agent_payload.get("summary", "")),
"files_reviewed": files,
"findings": findings,
}
validate_review_doc(doc)
out_path.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return out_path
def print_blockers(blocks: list[dict[str, Any]]) -> None:
for b in blocks:
loc = f"{b['file']}:{b['line']}" if b["file"] else "—"
print(f" └─ [{b['severity']}] {loc}{b['title']} (conf {b['confidence']})")
def main() -> int:
root = repo_root()
force_run = "--run" in sys.argv
skip = os.environ.get("NEXUS_SKIP_AI_REVIEW", "").strip() in ("1", "true", "yes")
mode = os.environ.get("NEXUS_AI_REVIEW_MODE", "auto").strip().lower()
try:
sha = head_sha(root)
except RuntimeError as exc:
print(f"AI review: git error — {exc}")
return 1
files = reviewable_files(changed_files(root))
if not files and not force_run:
print("AI review: skip (no reviewable code in HEAD~1..HEAD)")
return 0
if skip:
print("AI review: SKIP (NEXUS_SKIP_AI_REVIEW=1)")
return 0
doc: dict[str, Any] | None = None
if not force_run:
doc = load_cached_review(root, sha)
if doc is None:
if mode == "cache" and not force_run:
print("AI review: BLOCK — no cached review for HEAD")
print(f" └─ Run: python3 scripts/gate_ai_review.py --run")
print(f" └─ Or set NEXUS_AI_REVIEW_MODE=auto (default)")
return 1
try:
diff, truncated = build_diff(root)
payload = run_cursor_review(root, files, diff, truncated)
out = write_review(root, sha, payload, files)
doc = json.loads(out.read_text(encoding="utf-8"))
print(f"AI review: wrote {out.relative_to(root)}")
except Exception as exc:
print(f"AI review: BLOCK — {exc}")
print(" └─ Fix: ensure cursor-agent + auth, then:")
print(" └─ python3 scripts/gate_ai_review.py --run")
print(" └─ Emergency: NEXUS_SKIP_AI_REVIEW=1")
return 1
try:
validate_review_doc(doc)
except ValueError as exc:
print(f"AI review: BLOCK — invalid review doc: {exc}")
return 1
blocks = blockers(doc)
if blocks:
print(f"AI review: BLOCK — {len(blocks)} blocker(s), verdict={doc.get('verdict')}")
print_blockers(blocks)
return 1
print(f"AI review: PASS — verdict={doc.get('verdict')}, findings={len(doc.get('findings') or [])}")
if doc.get("summary"):
print(f" └─ {doc['summary']}")
return 0
if __name__ == "__main__":
sys.exit(main())