

























@@ -4,6 +4,7 @@ from __future__ import annotations
44import argparse
55import json
66import os
7+import re
78import subprocess
89import sys
910import tempfile
@@ -67,11 +68,19 @@ SCHEMA: dict[str, Any] = {
6768}
6869697070-def run(args: list[str], cwd: Path, *, input_text: str | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
71+def run(
72+args: list[str],
73+cwd: Path,
74+*,
75+input_text: str | None = None,
76+env: dict[str, str] | None = None,
77+check: bool = True,
78+) -> subprocess.CompletedProcess[str]:
7179result = subprocess.run(
7280args,
7381cwd=cwd,
7482input=input_text,
83+env=env,
7584text=True,
7685stdout=subprocess.PIPE,
7786stderr=subprocess.PIPE,
@@ -396,6 +405,127 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
396405return result.stdout
397406398407408+def run_pi(args: argparse.Namespace, repo: Path, prompt: str) -> str:
409+if not args.tools:
410+raise SystemExit("--no-tools is not supported by the pi engine; use --tools read-only allowlist for review")
411+if not args.model:
412+raise SystemExit("--engine pi requires --model because autoreview isolates PI_CODING_AGENT_DIR from user settings")
413+with tempfile.TemporaryDirectory(prefix="autoreview-pi.") as tempdir:
414+temp = Path(tempdir)
415+prompt_path = temp / "prompt.txt"
416+prompt_path.write_text(prompt)
417+os.chmod(prompt_path, 0o600)
418+env = os.environ.copy()
419+agent_dir = temp / "agent"
420+agent_dir.mkdir()
421+env["PI_CODING_AGENT_DIR"] = str(agent_dir)
422+env["PI_CODING_AGENT_SESSION_DIR"] = str(temp / "sessions")
423+env["PI_TELEMETRY"] = "0"
424+cmd = [
425+args.pi_bin,
426+"--no-session",
427+"--no-context-files",
428+"--no-extensions",
429+"--no-skills",
430+"--no-prompt-templates",
431+"--no-themes",
432+"--tools",
433+pi_readonly_tools(args),
434+"--mode",
435+"json",
436+ ]
437+if args.model:
438+cmd.extend(["--model", args.model])
439+cmd.extend(["-p", f"@{prompt_path}", "Read the attached review prompt and follow it exactly."])
440+result = run(cmd, repo, env=env, check=False)
441+if result.returncode != 0:
442+raise SystemExit(f"pi engine failed ({result.returncode})\n{result.stderr or result.stdout}")
443+return result.stdout
444+445+446+def run_opencode(args: argparse.Namespace, repo: Path, prompt: str) -> str:
447+if not args.tools:
448+raise SystemExit("--no-tools is not supported by the opencode engine; opencode requires read-only tools to load the review bundle")
449+with tempfile.TemporaryDirectory(prefix="autoreview-opencode.") as tempdir:
450+temp = Path(tempdir)
451+config_dir = temp / "config"
452+config_dir.mkdir()
453+prompt_path = temp / "prompt.txt"
454+prompt_path.write_text(prompt)
455+os.chmod(prompt_path, 0o600)
456+env = os.environ.copy()
457+env.update(
458+ {
459+"OPENCODE_CONFIG_DIR": str(config_dir),
460+"OPENCODE_CONFIG_CONTENT": json.dumps(opencode_review_config(args)),
461+"OPENCODE_DISABLE_PROJECT_CONFIG": "1",
462+"OPENCODE_PURE": "1",
463+"OPENCODE_DISABLE_AUTOUPDATE": "1",
464+"OPENCODE_DISABLE_AUTOCOMPACT": "1",
465+"OPENCODE_DISABLE_MODELS_FETCH": "1",
466+ }
467+ )
468+cmd = [
469+args.opencode_bin,
470+"run",
471+"--pure",
472+"--format",
473+"json",
474+"--agent",
475+"autoreview",
476+"--dir",
477+str(repo),
478+"-f",
479+str(prompt_path),
480+ ]
481+if args.model:
482+cmd.extend(["--model", args.model])
483+cmd.append("Read the attached review prompt and follow it exactly. Return only the requested JSON object.")
484+result = run(cmd, repo, env=env, check=False)
485+if result.returncode != 0:
486+raise SystemExit(f"opencode engine failed ({result.returncode})\n{result.stderr or result.stdout}")
487+return result.stdout
488+489+490+def pi_readonly_tools(args: argparse.Namespace) -> str:
491+return "read,grep,find,ls"
492+493+494+def opencode_review_config(args: argparse.Namespace) -> dict[str, Any]:
495+permission = {
496+"*": "deny",
497+"read": "allow",
498+"grep": "allow",
499+"glob": "allow",
500+"list": "allow",
501+"edit": "deny",
502+"bash": "deny",
503+"task": "deny",
504+"todowrite": "deny",
505+"question": "deny",
506+"repo_clone": "deny",
507+"repo_overview": "deny",
508+"skill": "deny",
509+ }
510+if args.web_search:
511+permission.update(
512+ {
513+"webfetch": "allow",
514+"websearch": "allow",
515+ }
516+ )
517+return {
518+"agent": {
519+"autoreview": {
520+"description": "Read-only structured code review agent",
521+"mode": "primary",
522+"steps": 8,
523+"permission": permission,
524+ }
525+ }
526+ }
527+528+399529def claude_allowed_tools(args: argparse.Namespace) -> str:
400530tools = [tool.strip() for tool in args.claude_allowed_tools.split(",") if tool.strip()]
401531if not args.web_search:
@@ -434,6 +564,7 @@ def extract_json(text: str) -> dict[str, Any]:
434564435565def extract_json_from_jsonl(text: str) -> dict[str, Any] | None:
436566candidates: list[str] = []
567+assistant_stream: list[str] = []
437568for line in text.splitlines():
438569line = line.strip()
439570if not line:
@@ -444,21 +575,65 @@ def extract_json_from_jsonl(text: str) -> dict[str, Any] | None:
444575continue
445576if not isinstance(event, dict):
446577continue
578+if isinstance(event.get("text"), str):
579+candidates.append(event["text"])
580+assistant_stream.append(event["text"])
581+if isinstance(event.get("delta"), str):
582+assistant_stream.append(event["delta"])
447583part = event.get("part")
448584if isinstance(part, dict) and isinstance(part.get("text"), str):
449585candidates.append(part["text"])
586+assistant_stream.append(part["text"])
587+assistant_event = event.get("assistantMessageEvent")
588+if isinstance(assistant_event, dict):
589+if isinstance(assistant_event.get("content"), str):
590+candidates.append(assistant_event["content"])
591+if isinstance(assistant_event.get("delta"), str):
592+assistant_stream.append(assistant_event["delta"])
593+partial = assistant_event.get("partial")
594+if isinstance(partial, dict):
595+candidates.extend(extract_text_blocks(partial.get("content")))
450596data = event.get("data")
451597if isinstance(data, dict) and isinstance(data.get("content"), str):
452598candidates.append(data["content"])
453599if isinstance(event.get("result"), str):
454600candidates.append(event["result"])
601+message = event.get("message")
602+if isinstance(message, dict):
603+texts = extract_text_blocks(message.get("content"))
604+candidates.extend(texts)
605+if message.get("role") == "assistant":
606+assistant_stream.extend(texts)
607+messages = event.get("messages")
608+if isinstance(messages, list):
609+for item in messages:
610+if not isinstance(item, dict):
611+continue
612+texts = extract_text_blocks(item.get("content"))
613+candidates.extend(texts)
614+if item.get("role") == "assistant":
615+assistant_stream.extend(texts)
616+if assistant_stream:
617+candidates.append("".join(assistant_stream))
455618for candidate in reversed(candidates):
456619parsed = parse_json_candidate(candidate)
457620if isinstance(parsed, dict) and "findings" in parsed:
458621return parsed
459622return None
460623461624625+def extract_text_blocks(value: Any) -> list[str]:
626+if isinstance(value, str):
627+return [value]
628+if not isinstance(value, list):
629+return []
630+result: list[str] = []
631+for item in value:
632+if isinstance(item, dict) and isinstance(item.get("text"), str):
633+result.append(item["text"])
634+return result
635+636+462637def parse_json_candidate(text: str) -> Any | None:
463638stripped = text.strip()
464639if stripped.startswith("```"):
@@ -468,14 +643,30 @@ def parse_json_candidate(text: str) -> Any | None:
468643try:
469644parsed = json.loads(stripped)
470645except json.JSONDecodeError:
471-return None
646+repaired = repair_invalid_json_escapes(stripped)
647+if repaired == stripped:
648+return None
649+try:
650+parsed = json.loads(repaired)
651+except json.JSONDecodeError:
652+return None
472653if isinstance(parsed, str) and parsed != text:
473654nested = parse_json_candidate(parsed)
474655return nested if nested is not None else parsed
475656return parsed
476657477658478-def validate_report(report: dict[str, Any], repo: Path, changed_paths: set[str], required: list[str]) -> None:
659+def repair_invalid_json_escapes(text: str) -> str:
660+return re.sub(r'\\(?!["\\/bfnrtu])', "", text)
661+662+663+def validate_report(
664+report: dict[str, Any],
665+repo: Path,
666+changed_paths: set[str],
667+required: list[str],
668+required_any: list[str],
669+) -> None:
479670allowed_top = {"findings", "overall_correctness", "overall_explanation", "overall_confidence"}
480671extra_top = set(report) - allowed_top
481672if extra_top:
@@ -534,6 +725,10 @@ def validate_report(report: dict[str, Any], repo: Path, changed_paths: set[str],
534725for needle in required:
535726if needle.lower() not in haystack:
536727raise SystemExit(f"required finding text not found: {needle}")
728+for group in required_any:
729+needles = [needle.strip().lower() for needle in group.split(",") if needle.strip()]
730+if needles and not any(needle in haystack for needle in needles):
731+raise SystemExit(f"required finding text not found; need one of: {', '.join(needles)}")
537732538733539734def number_in_range(value: Any) -> bool:
@@ -574,13 +769,15 @@ def parse_args() -> argparse.Namespace:
574769parser.add_argument("--mode", choices=["auto", "local", "branch", "commit"], default="auto")
575770parser.add_argument("--base")
576771parser.add_argument("--commit", default="HEAD")
577-parser.add_argument("--engine", choices=["codex", "claude", "droid", "copilot"], default=os.environ.get("AUTOREVIEW_ENGINE", "codex"))
772+parser.add_argument("--engine", choices=["codex", "claude", "droid", "copilot", "pi", "opencode"], default=os.environ.get("AUTOREVIEW_ENGINE", "codex"))
578773parser.add_argument("--model")
579774parser.add_argument("--codex-bin", default=os.environ.get("CODEX_BIN", "codex"))
580775parser.add_argument("--claude-bin", default=os.environ.get("CLAUDE_BIN", "claude"))
581776parser.add_argument("--droid-bin", default=os.environ.get("DROID_BIN", "droid"))
582777parser.add_argument("--copilot-bin", default=os.environ.get("COPILOT_BIN", "copilot"))
583-parser.add_argument("--no-tools", dest="tools", action="store_false", default=True, help="Disable tools for engines that support it. Codex and copilot reject no-tools review.")
778+parser.add_argument("--pi-bin", default=os.environ.get("PI_BIN", "pi"))
779+parser.add_argument("--opencode-bin", default=os.environ.get("OPENCODE_BIN", "opencode"))
780+parser.add_argument("--no-tools", dest="tools", action="store_false", default=True, help="Disable tools for engines that support it. Codex, copilot, pi, and opencode reject no-tools review.")
584781parser.add_argument("--no-web-search", dest="web_search", action="store_false", default=True)
585782parser.add_argument(
586783"--claude-allowed-tools",
@@ -596,10 +793,11 @@ def parse_args() -> argparse.Namespace:
596793parser.add_argument("--json-output", help="Write validated structured review JSON.")
597794parser.add_argument("--parallel-tests", help="Run a test command concurrently with review; failure fails the helper.")
598795parser.add_argument("--require-finding", action="append", default=[], help="Require finding text to contain this substring.")
796+parser.add_argument("--require-any-finding", action="append", default=[], help="Require finding text to contain at least one comma-separated substring.")
599797parser.add_argument("--expect-findings", action="store_true", help="Treat findings as success; for harness acceptance tests.")
600798parser.add_argument("--dry-run", action="store_true")
601799args = parser.parse_args()
602-if args.engine not in {"codex", "claude", "droid", "copilot"}:
800+if args.engine not in {"codex", "claude", "droid", "copilot", "pi", "opencode"}:
603801raise SystemExit(f"invalid --engine/AUTOREVIEW_ENGINE: {args.engine}")
604802return args
605803@@ -613,6 +811,10 @@ def run_engine(args: argparse.Namespace, repo: Path, prompt: str) -> str:
613811return run_droid(args, repo, prompt)
614812if args.engine == "copilot":
615813return run_copilot(args, repo, prompt)
814+if args.engine == "pi":
815+return run_pi(args, repo, prompt)
816+if args.engine == "opencode":
817+return run_opencode(args, repo, prompt)
616818raise SystemExit(f"unsupported engine: {args.engine}")
617819618820@@ -649,7 +851,7 @@ def main() -> int:
649851try:
650852raw = run_engine(args, repo, prompt)
651853report = extract_json(raw)
652-validate_report(report, repo, changed_paths, args.require_finding)
854+validate_report(report, repo, changed_paths, args.require_finding, args.require_any_finding)
653855if args.json_output:
654856Path(args.json_output).write_text(json.dumps(report, indent=2) + "\n")
655857此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。