|
#!/usr/bin/env python3 |
|
"""Cross-model review gate: code opened as a PR from one harness must carry an |
|
adversarial review by the other model for the exact committed diff. |
|
|
|
xreview.py gate --me claude|codex PreToolUse hook entry (JSON on stdin) |
|
xreview.py run codex|claude [--base REF] [focus ...] |
|
xreview.py status |
|
""" |
|
|
|
from __future__ import annotations |
|
|
|
import argparse |
|
import hashlib |
|
import json |
|
import os |
|
import re |
|
import shlex |
|
import shutil |
|
import subprocess |
|
import sys |
|
import tempfile |
|
import time |
|
from dataclasses import dataclass |
|
from pathlib import Path |
|
from urllib.parse import urlsplit |
|
|
|
HERE = Path(__file__).resolve().parent |
|
PROMPT_FILE = HERE / "xreview-prompt.md" |
|
SCHEMA_FILE = HERE / "xreview-schema.json" |
|
OTHER = {"claude": "codex", "codex": "claude"} |
|
# Diffs beyond this are truncated in the prompt; the reviewer can still read files directly. |
|
MAX_DIFF_BYTES = 400_000 |
|
GATED_RE = re.compile(r"\bgh\s+(?:[^\n;&|]*?\s+)?pr\s+(?:create|ready)\b") |
|
ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") |
|
|
|
|
|
@dataclass |
|
class Invocation: |
|
action: str |
|
args: list[str] |
|
env: dict[str, str] |
|
cwd: str |
|
|
|
|
|
def command_invocations(command: str, cwd: str) -> list[Invocation]: |
|
lexer = shlex.shlex((command or "").strip(), posix=True, punctuation_chars=";&|()<>\n") |
|
lexer.whitespace = " \t\r" |
|
lexer.whitespace_split = True |
|
tokens = list(lexer) |
|
segments, segment = [], [] |
|
for token in tokens: |
|
if token and all(c in ";&|()<>\n" for c in token): |
|
if segment: |
|
segments.append(segment) |
|
segment = [] |
|
segments.append(token) |
|
else: |
|
segment.append(token) |
|
if segment: |
|
segments.append(segment) |
|
invocations = [] |
|
for words in segments: |
|
if isinstance(words, str): |
|
continue |
|
i, env = 0, {} |
|
while i < len(words): |
|
word = words[i] |
|
if word in {"env", "command"} or word == "--": |
|
i += 1 |
|
elif ASSIGNMENT_RE.match(word): |
|
key, value = word.split("=", 1) |
|
env[key] = value |
|
i += 1 |
|
else: |
|
break |
|
if i == len(words) or Path(words[i]).name != "gh": |
|
continue |
|
i += 1 |
|
global_args = [] |
|
while i < len(words) and words[i].startswith("-"): |
|
word = words[i] |
|
global_args.append(word) |
|
i += 1 |
|
if word in {"-R", "--repo", "--hostname"} and i < len(words): |
|
global_args.append(words[i]) |
|
i += 1 |
|
if i < len(words) and words[i] == "pr": |
|
i += 1 |
|
while i < len(words) and words[i].startswith("-"): |
|
word = words[i] |
|
global_args.append(word) |
|
i += 1 |
|
if word in {"-R", "--repo", "--hostname"} and i < len(words): |
|
global_args.append(words[i]) |
|
i += 1 |
|
if i < len(words) and words[i] in {"create", "ready"}: |
|
invocations.append(Invocation(words[i], global_args + words[i + 1:], env, cwd)) |
|
if invocations: |
|
if len(segments) != 1 or any("$" in token or "`" in token for token in tokens): |
|
raise RuntimeError("Run gh pr create/ready as a separate command, without shell substitutions or redirections.") |
|
forbidden = set(invocations[0].env) - {"XREVIEW_SKIP", "GH_TOKEN", "GITHUB_TOKEN"} |
|
if forbidden: |
|
raise RuntimeError("Unsupported environment overrides for the PR command: " + ", ".join(sorted(forbidden))) |
|
elif GATED_RE.search(command or ""): |
|
inert = len(segments) == 1 and isinstance(segments[0], list) and segments[0][0] in {"echo", "printf"} |
|
if not inert or "$(" in command or "`" in command: |
|
raise RuntimeError("Run gh pr create/ready directly so its repository and target can be reviewed.") |
|
return invocations |
|
|
|
|
|
def cache_dir() -> Path: |
|
d = Path(os.environ.get("XREVIEW_CACHE") or Path.home() / ".cache" / "xreview") |
|
d.mkdir(parents=True, exist_ok=True) |
|
return d |
|
|
|
|
|
def is_gated_command(command: str) -> bool: |
|
try: |
|
return bool(command_invocations(command, os.getcwd())) |
|
except (ValueError, RuntimeError): |
|
return bool(GATED_RE.search(command or "")) |
|
|
|
|
|
def has_skip(command: str) -> bool: |
|
try: |
|
calls = command_invocations(command, os.getcwd()) |
|
return len(calls) == 1 and calls[0].env.get("XREVIEW_SKIP") == "1" |
|
except (ValueError, RuntimeError): |
|
return False |
|
|
|
|
|
def _git(cwd, *args, check=True) -> str: |
|
r = subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True, timeout=120) |
|
if check and r.returncode != 0: |
|
raise RuntimeError(r.stderr.strip() or f"git {' '.join(args)} failed") |
|
return r.stdout.strip() |
|
|
|
|
|
@dataclass |
|
class BranchContext: |
|
root: str |
|
branch: str |
|
base_ref: str |
|
merge_base: str |
|
head: str |
|
diff: str |
|
diff_hash: str |
|
dirty: bool |
|
base_head: str = "" |
|
target_repo: str = "" |
|
|
|
@property |
|
def has_changes(self) -> bool: |
|
return bool(self.diff.strip()) |
|
|
|
|
|
def default_base_ref(root) -> str: |
|
try: |
|
return _git(root, "symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD") |
|
except RuntimeError: |
|
return "origin/main" |
|
|
|
|
|
def branch_context(cwd, base_ref: str | None = None) -> BranchContext: |
|
root = _git(cwd, "rev-parse", "--show-toplevel") |
|
branch = _git(root, "rev-parse", "--abbrev-ref", "HEAD") |
|
base_ref = base_ref or default_base_ref(root) |
|
head = _git(root, "rev-parse", "HEAD") |
|
merge_base = _git(root, "merge-base", base_ref, "HEAD") |
|
base_head = _git(root, "rev-parse", "--verify", f"{base_ref}^{{commit}}") |
|
diff = _git(root, "diff", "--no-ext-diff", "--no-textconv", "--no-color", merge_base, head) |
|
dirty = bool(_git(root, "status", "--porcelain", "--untracked-files=no")) |
|
digest = hashlib.sha256(diff.encode("utf-8", "surrogateescape")).hexdigest() |
|
return BranchContext(root, branch, base_ref, merge_base, head, diff, digest, dirty, base_head) |
|
|
|
|
|
def _gh(cwd: str, args: list[str], env: dict[str, str]) -> dict: |
|
r = subprocess.run(["gh", *args], cwd=cwd, env={**os.environ, **env}, capture_output=True, text=True, timeout=120) |
|
if r.returncode: |
|
raise RuntimeError(f"Cannot resolve PR target: {r.stderr.strip()[-2000:]}") |
|
return _parse_json_object(r.stdout, "gh") |
|
|
|
|
|
def invocation_options(call: Invocation) -> tuple[dict[str, str], list[str]]: |
|
values = {"-R": "repo", "--repo": "repo", "--hostname": "hostname", "-B": "base", "--base": "base", |
|
"-H": "head", "--head": "head", "-t": "title", "--title": "title", "-b": "body", "--body": "body", |
|
"-F": "body-file", "--body-file": "body-file", "-a": "assignee", "--assignee": "assignee", |
|
"-l": "label", "--label": "label", "-m": "milestone", "--milestone": "milestone", |
|
"-p": "project", "--project": "project", "-r": "reviewer", "--reviewer": "reviewer", "-T": "template", "--template": "template"} |
|
switches = {"-d", "--draft", "--fill", "--fill-first", "--fill-verbose", "--no-maintainer-edit", "--undo"} |
|
options, positional = {}, [] |
|
i = 0 |
|
while i < len(call.args): |
|
arg = call.args[i] |
|
key, sep, value = arg.partition("=") |
|
if key in values: |
|
if not sep: |
|
i += 1 |
|
if i == len(call.args): |
|
raise RuntimeError(f"Missing value for {key}") |
|
value = call.args[i] |
|
options[values[key]] = value |
|
elif arg in switches: |
|
pass |
|
elif arg.startswith("-"): |
|
raise RuntimeError(f"Unsupported PR option {arg}; use explicit options without interactive target selection.") |
|
else: |
|
positional.append(arg) |
|
i += 1 |
|
if "hostname" in options: |
|
raise RuntimeError("Use gh --repo HOST/OWNER/REPO to select a hostname explicitly.") |
|
if (call.action == "create" and positional) or len(positional) > 1: |
|
raise RuntimeError("Cannot resolve PR command arguments.") |
|
return options, positional |
|
|
|
|
|
def repository_key(url: str) -> str: |
|
if "://" in url: |
|
parsed = urlsplit(url) |
|
return f"{parsed.hostname}/{parsed.path.strip('/').removesuffix('.git')}".lower() |
|
if re.match(r"^[^/]+@[^/:]+:", url): |
|
host, path = url.split("@", 1)[1].split(":", 1) |
|
return f"{host}/{path.strip('/').removesuffix('.git')}".lower() |
|
return str(Path(url).resolve()) |
|
|
|
|
|
def target_context(call: Invocation) -> BranchContext: |
|
options, positional = invocation_options(call) |
|
root = _git(call.cwd, "rev-parse", "--show-toplevel") |
|
branch = _git(root, "symbolic-ref", "--quiet", "--short", "HEAD") |
|
head = _git(root, "rev-parse", "HEAD") |
|
fields = "nameWithOwner,defaultBranchRef,url" |
|
repo = _gh(root, ["repo", "view", "--json", fields], call.env) |
|
if "repo" in options: |
|
selected = _gh(root, ["repo", "view", options["repo"], "--json", fields], call.env) |
|
if selected.get("url") != repo.get("url"): |
|
raise RuntimeError("The --repo target differs from this checkout. Run the PR command in the target repository.") |
|
repo = selected |
|
url = repo.get("url") |
|
if not isinstance(url, str) or not url or url.startswith("-"): |
|
raise RuntimeError("Cannot resolve the PR repository URL.") |
|
remotes = _git(root, "remote").splitlines() |
|
remote_urls = [_git(root, "remote", "get-url", remote) for remote in remotes] |
|
if not remote_urls or any(repository_key(remote) != repository_key(url) for remote in remote_urls): |
|
raise RuntimeError("The PR repository does not uniquely match this checkout's remotes. Use a checkout of the target repository.") |
|
base = options.get("base") |
|
if call.action == "ready": |
|
selector = positional or [branch] |
|
pr = _gh(root, ["pr", "view", *selector, "--repo", repo["url"], "--json", "headRefOid,headRefName,baseRefName"], call.env) |
|
if pr.get("headRefOid") != head or pr.get("headRefName") != branch: |
|
raise RuntimeError("The PR head differs from this checkout. Check out its exact branch and commit before marking it ready.") |
|
base = pr.get("baseRefName") |
|
else: |
|
if options.get("head", branch) != branch: |
|
raise RuntimeError("The --head target differs from this checkout. Check out the target branch before creating the PR.") |
|
remote_head = _git(root, "ls-remote", "--", url, f"refs/heads/{branch}") |
|
if remote_head and remote_head.split()[0] != head: |
|
raise RuntimeError("The remote PR branch differs from HEAD. Push the reviewed local commit first, then create the PR separately.") |
|
if "head" in options and not remote_head: |
|
raise RuntimeError("The explicit --head branch does not exist remotely. Push it first, then retry.") |
|
configured = _git(root, "config", "--get", f"branch.{branch}.gh-merge-base", check=False) |
|
base = base or configured or (repo.get("defaultBranchRef") or {}).get("name") |
|
if not isinstance(base, str) or not base: |
|
raise RuntimeError("Cannot resolve the PR base branch.") |
|
if subprocess.run(["git", "check-ref-format", f"refs/heads/{base}"], capture_output=True).returncode: |
|
raise RuntimeError("Invalid PR base branch.") |
|
_git(root, "fetch", "--no-tags", "--", url, f"refs/heads/{base}") |
|
base_head = _git(root, "rev-parse", "FETCH_HEAD") |
|
ctx = branch_context(root, base_head) |
|
if ctx.head != head or ctx.branch != branch: |
|
raise RuntimeError("The checked-out branch changed while resolving the PR target. Retry.") |
|
ctx.base_ref = base |
|
ctx.target_repo = url |
|
return ctx |
|
|
|
|
|
def receipt_identity(ctx: BranchContext, reviewer: str) -> dict: |
|
return {"version": 2, "repo": ctx.root, "branch": ctx.branch, "target_repo": ctx.target_repo, |
|
"base_ref": ctx.base_ref, "base_head": ctx.base_head, "merge_base": ctx.merge_base, |
|
"head": ctx.head, "diff_hash": ctx.diff_hash, "reviewer": reviewer} |
|
|
|
|
|
def receipt_path(ctx: BranchContext, reviewer: str) -> Path: |
|
digest = hashlib.sha256(json.dumps(receipt_identity(ctx, reviewer), sort_keys=True).encode()).hexdigest() |
|
return cache_dir() / f"{digest}.{reviewer}.json" |
|
|
|
|
|
def read_receipt(ctx: BranchContext, reviewer: str): |
|
p = receipt_path(ctx, reviewer) |
|
if not p.exists(): |
|
return None |
|
try: |
|
receipt = json.loads(p.read_text()) |
|
if not isinstance(receipt, dict) or any(receipt.get(k) != v for k, v in receipt_identity(ctx, reviewer).items()): |
|
return None |
|
validate_result({k: receipt.get(k) for k in ("verdict", "summary", "findings", "next_steps")}) |
|
return receipt |
|
except (OSError, ValueError, RuntimeError): |
|
return None |
|
|
|
|
|
def read_any_receipt(ctx: BranchContext): |
|
for r in OTHER: |
|
rec = read_receipt(ctx, r) |
|
if rec: |
|
return rec |
|
return None |
|
|
|
|
|
def write_receipt(ctx: BranchContext, reviewer: str, result: dict) -> Path: |
|
validate_result(result) |
|
receipt = { |
|
**receipt_identity(ctx, reviewer), |
|
"verdict": result.get("verdict"), |
|
"summary": result.get("summary"), |
|
"findings": result.get("findings", []), |
|
"next_steps": result.get("next_steps", []), |
|
"reviewed_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), |
|
} |
|
p = receipt_path(ctx, reviewer) |
|
with tempfile.NamedTemporaryFile("w", dir=p.parent, delete=False) as tmp: |
|
tmp.write(json.dumps(receipt, indent=1)) |
|
os.replace(tmp.name, p) |
|
return p |
|
|
|
|
|
def log(line: str) -> None: |
|
with open(cache_dir() / "log", "a") as f: |
|
f.write(f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {line}\n") |
|
|
|
|
|
def deny(reason: str) -> dict: |
|
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", |
|
"permissionDecision": "deny", |
|
"permissionDecisionReason": reason}} |
|
|
|
|
|
def gate(hook_input: dict, me: str): |
|
"""Return a deny payload, or None to let the command through.""" |
|
if hook_input.get("tool_name", "Bash") != "Bash": |
|
return None |
|
command = (hook_input.get("tool_input") or {}).get("command", "") |
|
reviewer = OTHER[me] |
|
cwd = hook_input.get("cwd") or os.getcwd() |
|
try: |
|
calls = command_invocations(command, cwd) |
|
except (RuntimeError, ValueError) as e: |
|
return deny(f"Blocked: cannot safely resolve PR command. {e}") |
|
if not calls: |
|
return None |
|
call = calls[0] |
|
if call.env.get("XREVIEW_SKIP") == "1": |
|
log(f"SKIP me={me} cwd={cwd} action={call.action}") |
|
return None |
|
try: |
|
ctx = target_context(call) |
|
except (RuntimeError, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as e: |
|
log(f"DENY me={me} cwd={cwd} target-error={e}") |
|
return deny(f"Blocked: cannot resolve the PR's exact repository, head and base. {e}\nFix the target or Git/GitHub access and retry.") |
|
if ctx.dirty: |
|
return deny(f"Blocked: '{ctx.branch}' has uncommitted tracked changes. Commit (or stash) first, then retry.") |
|
if not ctx.has_changes: |
|
return None |
|
receipt = read_receipt(ctx, reviewer) |
|
if receipt: |
|
log(f"ALLOW me={me} branch={ctx.branch} hash={ctx.diff_hash[:12]} verdict={receipt.get('verdict')}") |
|
return None |
|
if shutil.which(reviewer) is None: |
|
log(f"DENY me={me} branch={ctx.branch} reviewer-missing={reviewer}") |
|
return deny(f"Blocked: '{ctx.branch}' needs an adversarial review by {reviewer} before a PR is opened, and the " |
|
f"'{reviewer}' CLI is not on PATH. Install it or ask Marius; XREVIEW_SKIP=1 only if he says to skip.") |
|
log(f"REVIEW-START me={me} by={reviewer} branch={ctx.branch} hash={ctx.diff_hash[:12]}") |
|
print(f"xreview: running {reviewer} adversarial review of {ctx.branch} against {ctx.base_ref}...", file=sys.stderr) |
|
try: |
|
prompt = build_prompt(ctx, reviewer, "") |
|
result = run_codex(ctx, prompt) if reviewer == "codex" else run_claude(ctx, prompt) |
|
validate_result(result) |
|
current = target_context(call) |
|
if current.dirty or receipt_identity(current, reviewer) != receipt_identity(ctx, reviewer): |
|
raise RuntimeError("The PR target or working tree changed during review. Retry against the current commits.") |
|
path = write_receipt(ctx, reviewer, result) |
|
except (RuntimeError, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as e: |
|
log(f"DENY me={me} branch={ctx.branch} review-error={e}") |
|
return deny(f"Blocked: the {reviewer} adversarial review of '{ctx.branch}' failed, so the PR cannot be opened " |
|
f"yet. Error:\n{e}\n\nFix the cause or rerun: python3 {HERE / 'xreview.py'} run {reviewer}") |
|
findings = result.get("findings") or [] |
|
log(f"REVIEWED by={reviewer} branch={ctx.branch} hash={ctx.diff_hash[:12]} verdict={result.get('verdict')} findings={len(findings)}") |
|
if result.get("verdict") == "approve" and not findings: |
|
log(f"ALLOW me={me} branch={ctx.branch} hash={ctx.diff_hash[:12]} verdict=approve") |
|
return None |
|
return deny( |
|
f"Blocked: {reviewer} reviewed '{ctx.branch}' and found material issues. Fix the ones you agree with, commit, " |
|
f"and retry; the retry reviews the new diff. If you disagree with every finding, retry unchanged and say why " |
|
f"in the PR description.\n\n" + render(result, reviewer, path) |
|
) |
|
|
|
|
|
def _any_receipt_for_branch(ctx: BranchContext) -> bool: |
|
for p in cache_dir().glob("*.json"): |
|
try: |
|
r = json.loads(p.read_text()) |
|
except json.JSONDecodeError: |
|
continue |
|
if r.get("repo") == ctx.root and r.get("branch") == ctx.branch: |
|
return True |
|
return False |
|
|
|
|
|
def build_prompt(ctx: BranchContext, reviewer: str, focus: str) -> str: |
|
stat = _git(ctx.root, "diff", "--stat", ctx.merge_base, ctx.head) |
|
diff = ctx.diff |
|
if len(diff) > MAX_DIFF_BYTES: |
|
diff = diff[:MAX_DIFF_BYTES] + "\n\n[diff truncated; read the repository for the rest]\n" |
|
review_input = ( |
|
f"Repository: {ctx.root}\nBranch: {ctx.branch}\nBase: {ctx.base_ref} (merge-base {ctx.merge_base[:12]})\n" |
|
f"Head: {ctx.head[:12]}\n\nChanged files:\n{stat}\n\nDiff:\n{diff}" |
|
) |
|
tpl = PROMPT_FILE.read_text() |
|
return (tpl.replace("{{REVIEWER}}", reviewer.capitalize()) |
|
.replace("{{TARGET_LABEL}}", f"branch {ctx.branch} against {ctx.base_ref}") |
|
.replace("{{USER_FOCUS}}", focus or "none") |
|
.replace("{{REVIEW_INPUT}}", review_input)) |
|
|
|
|
|
def run_codex(ctx: BranchContext, prompt: str) -> dict: |
|
with tempfile.NamedTemporaryFile("r", suffix=".json", delete=False) as out: |
|
out_path = out.name |
|
cmd = ["codex", "exec", "--sandbox", "read-only", "--ephemeral", "--color", "never", |
|
"-C", ctx.root, "--output-schema", str(SCHEMA_FILE), "-o", out_path, "-"] |
|
try: |
|
r = subprocess.run(cmd, input=prompt, capture_output=True, text=True, timeout=720) |
|
text = Path(out_path).read_text().strip() |
|
finally: |
|
Path(out_path).unlink(missing_ok=True) |
|
if r.returncode != 0: |
|
raise RuntimeError(f"codex exec failed ({r.returncode}): {r.stderr.strip()[-2000:]}") |
|
return validate_result(_parse_json_object(text, "codex")) |
|
|
|
|
|
def run_claude(ctx: BranchContext, prompt: str) -> dict: |
|
# claude -p's validator does not resolve the 2020-12 meta-schema URI; codex exec needs the file as is. |
|
schema = json.loads(SCHEMA_FILE.read_text()) |
|
schema.pop("$schema", None) |
|
# --restricted ignores the user's settings (auto mode, kubectl/aws allow rules, hooks), so the |
|
# reviewer can only read the repo and run the git subcommands listed here. |
|
cmd = ["claude", "-p", "--output-format", "json", "--json-schema", json.dumps(schema), |
|
"--no-session-persistence", "--restricted", "--strict-mcp-config", "--permission-mode", "dontAsk", |
|
"--tools", "Read,Grep,Glob,Bash", |
|
"--allowedTools", "Read", "Grep", "Glob", "Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)", |
|
"Bash(git blame:*)", "--", prompt] |
|
r = subprocess.run(cmd, cwd=ctx.root, capture_output=True, text=True, timeout=720) |
|
if r.returncode != 0: |
|
raise RuntimeError(f"claude -p failed ({r.returncode}): {r.stderr.strip()[-2000:]}") |
|
envelope = _parse_json_object(r.stdout, "claude") |
|
if envelope.get("is_error") or ("subtype" in envelope and envelope["subtype"] != "success"): |
|
raise RuntimeError(f"claude review failed: {str(envelope)[-2000:]}") |
|
if isinstance(envelope.get("structured_output"), dict): |
|
return validate_result(envelope["structured_output"]) |
|
result = envelope.get("result") |
|
if isinstance(result, dict): |
|
return validate_result(result) |
|
if isinstance(result, str): |
|
return validate_result(_parse_json_object(result, "claude result")) |
|
return validate_result(envelope) |
|
|
|
|
|
def validate_result(result: dict) -> dict: |
|
def validate(value, schema, path): |
|
kind = schema.get("type") |
|
types = {"object": dict, "array": list, "string": str, "integer": int, "number": (int, float)} |
|
if kind in types and (not isinstance(value, types[kind]) or (kind in {"number", "integer"} and isinstance(value, bool))): |
|
raise RuntimeError(f"Invalid review result: {path} must be {kind}") |
|
if "enum" in schema and value not in schema["enum"]: |
|
raise RuntimeError(f"Invalid review result: {path} has an unsupported value") |
|
if kind == "object": |
|
properties = schema.get("properties", {}) |
|
if any(key not in value for key in schema.get("required", [])) or (schema.get("additionalProperties") is False and set(value) - set(properties)): |
|
raise RuntimeError(f"Invalid review result: {path} has missing or unknown fields") |
|
for key, item in value.items(): |
|
if key in properties: |
|
validate(item, properties[key], f"{path}.{key}") |
|
elif kind == "array": |
|
for i, item in enumerate(value): |
|
validate(item, schema["items"], f"{path}[{i}]") |
|
elif kind == "string" and len(value.strip()) < schema.get("minLength", 0): |
|
raise RuntimeError(f"Invalid review result: {path} is empty") |
|
elif kind in {"number", "integer"}: |
|
if not schema.get("minimum", -float("inf")) <= value <= schema.get("maximum", float("inf")): |
|
raise RuntimeError(f"Invalid review result: {path} is out of range") |
|
|
|
validate(result, json.loads(SCHEMA_FILE.read_text()), "result") |
|
if result["verdict"] == "approve" and result["findings"]: |
|
raise RuntimeError("Invalid review result: approval contains findings") |
|
if any(f["line_end"] < f["line_start"] for f in result["findings"]): |
|
raise RuntimeError("Invalid review result: reversed finding line range") |
|
return result |
|
|
|
|
|
def _parse_json_object(text: str, who: str) -> dict: |
|
text = text.strip() |
|
try: |
|
result = json.loads(text) |
|
if isinstance(result, dict): |
|
return result |
|
except json.JSONDecodeError: |
|
start, end = text.find("{"), text.rfind("}") |
|
if start >= 0 and end > start: |
|
try: |
|
result = json.loads(text[start:end + 1]) |
|
if isinstance(result, dict): |
|
return result |
|
except json.JSONDecodeError: |
|
pass |
|
raise RuntimeError(f"{who} returned no parseable JSON:\n{text[-2000:]}") |
|
|
|
|
|
def render(result: dict, reviewer: str, path: Path) -> str: |
|
lines = [f"{reviewer} adversarial review: {result.get('verdict', '?')}", "", result.get("summary", ""), ""] |
|
findings = result.get("findings") or [] |
|
if findings: |
|
lines.append(f"Findings ({len(findings)}):") |
|
for i, f in enumerate(findings, 1): |
|
lines.append(f"\n{i}. [{f.get('severity')}] {f.get('title')} ({f.get('file')}:{f.get('line_start')}-{f.get('line_end')}, confidence {f.get('confidence')})") |
|
lines.append(f" {f.get('body')}") |
|
if f.get("recommendation"): |
|
lines.append(f" Recommendation: {f['recommendation']}") |
|
else: |
|
lines.append("No findings.") |
|
steps = result.get("next_steps") or [] |
|
if steps: |
|
lines += ["", "Next steps:"] + [f"- {s}" for s in steps] |
|
lines += ["", f"Receipt: {path} (any new commit invalidates it)"] |
|
return "\n".join(lines) |
|
|
|
|
|
def cmd_run(args) -> int: |
|
reviewer = args.reviewer |
|
call = Invocation("create", ["--base", args.base] if args.base else [], {}, os.getcwd()) |
|
ctx = target_context(call) |
|
if ctx.dirty: |
|
print("Working tree has uncommitted tracked changes. Commit first so the review covers what the PR will contain.", file=sys.stderr) |
|
return 2 |
|
if not ctx.has_changes: |
|
print(f"Nothing to review: {ctx.branch} has no committed diff against {ctx.base_ref}.", file=sys.stderr) |
|
return 2 |
|
if shutil.which(reviewer) is None: |
|
print(f"'{reviewer}' CLI not found on PATH.", file=sys.stderr) |
|
return 2 |
|
prompt = build_prompt(ctx, reviewer, " ".join(args.focus)) |
|
print(f"Running {reviewer} adversarial review of {ctx.branch} against {ctx.base_ref} " |
|
f"({len(ctx.diff.splitlines())} diff lines)...", file=sys.stderr) |
|
result = run_codex(ctx, prompt) if reviewer == "codex" else run_claude(ctx, prompt) |
|
current = target_context(call) |
|
if current.dirty or receipt_identity(current, reviewer) != receipt_identity(ctx, reviewer): |
|
raise RuntimeError("The PR target or working tree changed during review. Retry against the current commits.") |
|
path = write_receipt(ctx, reviewer, result) |
|
log(f"REVIEWED by={reviewer} branch={ctx.branch} hash={ctx.diff_hash[:12]} verdict={result.get('verdict')} findings={len(result.get('findings') or [])}") |
|
print(render(result, reviewer, path)) |
|
return 0 |
|
|
|
|
|
def cmd_status(args) -> int: |
|
try: |
|
ctx = target_context(Invocation("create", ["--base", args.base] if args.base else [], {}, os.getcwd())) |
|
except RuntimeError as e: |
|
print(f"cannot resolve PR target: {e}", file=sys.stderr) |
|
return 2 |
|
print(f"branch={ctx.branch} base={ctx.base_ref} hash={ctx.diff_hash[:12]} dirty={ctx.dirty} changes={ctx.has_changes}") |
|
found = False |
|
for reviewer in OTHER: |
|
r = read_receipt(ctx, reviewer) |
|
if r: |
|
found = True |
|
print(f"receipt: reviewer={reviewer} verdict={r['verdict']} findings={len(r.get('findings') or [])} at={r['reviewed_at']}") |
|
if not found: |
|
print("receipt: none for this diff") |
|
return 0 |
|
|
|
|
|
def cmd_gate(args) -> int: |
|
try: |
|
raw = sys.stdin.read().strip() |
|
hook_input = json.loads(raw) if raw else {} |
|
out = gate(hook_input, args.me) |
|
except Exception as e: |
|
out = deny(f"Blocked: xreview could not complete the gate. {e}") |
|
if out: |
|
print(json.dumps(out)) |
|
return 0 |
|
|
|
|
|
def main(argv=None) -> int: |
|
p = argparse.ArgumentParser(prog="xreview") |
|
sub = p.add_subparsers(dest="cmd", required=True) |
|
g = sub.add_parser("gate") |
|
g.add_argument("--me", choices=OTHER, required=True) |
|
g.set_defaults(fn=cmd_gate) |
|
r = sub.add_parser("run") |
|
r.add_argument("reviewer", choices=OTHER) |
|
r.add_argument("--base") |
|
r.add_argument("focus", nargs="*") |
|
r.set_defaults(fn=cmd_run) |
|
s = sub.add_parser("status") |
|
s.add_argument("--base") |
|
s.set_defaults(fn=cmd_status) |
|
args = p.parse_args(argv) |
|
try: |
|
return args.fn(args) |
|
except RuntimeError as e: |
|
print(f"xreview: {e}", file=sys.stderr) |
|
return 1 |
|
|
|
|
|
if __name__ == "__main__": |
|
sys.exit(main()) |