9c9a51263c
汇总未提交工作区:Servers 列表 site_url 链接、终端页与选机器 UI、SSH 连通性与 Telegram 通知、agent/offline/unset_path 后台调度与 gate_ai_review 门控;更新 AGENTS 与 agent-exploration 规则(仅本机 commit);同步 web/app 构建产物与相关文档。
367 lines
11 KiB
Python
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())
|