feat: opencode review engine + .opencode factory

Replace the single Python model-call reviewer with an opencode agent
factory. A primary 'pragent' agent reads a brief (title/body/diff/config/
prior reviews), inspects the checked-out repo, runs the repo's own linters
via bash, loads review-methodology + findings-schema skills, and emits a
{summary, findings} JSON with per-finding severity/path/line/problem/fix/
suggestion/reference. Dormant security/tests/perf subagent lenses fan out
only on large/risky diffs (lean by default).

pilot/opencode_review.py: fetches the repo archive at the head sha into a
temp workdir, writes .pragent/brief.md, drops the factory, runs
'opencode run --pure --agent pragent --dir <workdir>' headlessly. Isolates
HOME (shared, warmed), strips ANTHROPIC_* env (leaked host vars caused
ProviderModelNotFoundError), stdin=DEVNULL (opencode blocks on stdin),
maps the bare OLLAMA_MODEL to the provider-prefixed ref. No Gitea I/O —
ai_review.review_pr parses + anchors + posts (reuses all v2 logic/tests).

PRAGENT_ENGINE=opencode (default) selects it; =ollama keeps the legacy
direct-call path. Verified end-to-end: posts a real review with a summary
section, inline [CRITICAL]/[HIGH] comments + apply-able suggestions +
reference links, and the sha dedupe marker. 49 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Marcos
2026-08-17 23:01:59 +00:00
parent 8758b22802
commit 6e3a9eb5b0
13 changed files with 1336 additions and 48 deletions
+149 -47
View File
@@ -130,19 +130,25 @@ def parse_text_blocks(content: list) -> str:
return "\n".join(out).strip()
def format_review_body(findings: str, model: str, sha: str) -> str:
def format_review_body(findings: str, model: str, sha: str, summary: str = "") -> str:
"""Format the posted review summary body.
`findings` is the bullet text for findings that could NOT be anchored inline
(or, on the legacy/no-inline path, the whole review). Empty -> "No issues
found.". The hidden sha marker is always appended for the dedupe pass.
found.". `summary` (optional, opencode engine) is rendered as a "Summary"
section right under the header. The hidden sha marker is always appended
for the dedupe pass.
"""
header = REVIEW_HEADER.format(model=model, sha=sha[:8] if sha else "unknown")
findings = (findings or "").strip()
if not findings:
findings = "No issues found."
marker = SHA_MARKER.format(sha=sha) if sha else ""
body = f"{header}\n\n{findings}"
parts = [header]
if summary:
parts.append(summary.strip())
parts.append(findings)
body = "\n\n".join(parts)
if marker:
body += f"\n{marker}"
return body
@@ -258,6 +264,43 @@ def _strip_path_prefix(p: str) -> str:
# ---------------------------------------------------------------------------
def _normalize_finding(f: dict) -> dict | None:
"""Validate + normalize one raw finding dict. Returns None if it's unusable
(missing path/line). Normalises severity, keeps `reference` (default "")."""
if not isinstance(f, dict):
return None
path = f.get("path")
line = f.get("line")
if not isinstance(path, str) or not path.strip():
return None
if not isinstance(line, int) or line < 1:
return None
sev = str(f.get("severity", "medium")).strip().lower()
if sev not in SEVERITIES:
sev = "medium"
reference = str(f.get("reference", "") or "").strip()
return {
"severity": sev,
"path": path.strip(),
"line": line,
"problem": str(f.get("problem", "")).strip(),
"fix": str(f.get("fix", "")).strip(),
"suggestion": str(f.get("suggestion", "") or "").strip(),
"reference": reference,
}
def _last_json_block(text: str) -> str | None:
"""Return the substring of the last fenced ```json block in text, or None.
Falls back to _extract_first_json_object when no fence is present."""
s = text or ""
# Find all ```json ... ``` fenced blocks; take the last.
blocks = list(re.finditer(r"```(?:json)?\s*(\{.*?\})\s*```", s, re.DOTALL))
if blocks:
return blocks[-1].group(1)
return _extract_first_json_object(s)
def parse_findings(text: str) -> list[dict]:
"""Parse the model's JSON response into a list of finding dicts.
@@ -266,23 +309,7 @@ def parse_findings(text: str) -> list[dict]:
Drops findings missing path/line or with an unknown severity (normalised).
Never raises — returns [] on any parse failure.
"""
if not text:
return []
s = text.strip()
# Strip a single wrapping code fence if present.
if s.startswith("```"):
s = re.sub(r"^```[a-zA-Z]*\n?", "", s)
s = re.sub(r"\n?```$", "", s).strip()
data = None
try:
data = json.loads(s)
except json.JSONDecodeError:
obj = _extract_first_json_object(s)
if obj is not None:
try:
data = json.loads(obj)
except json.JSONDecodeError:
data = None
data = _parse_json_tolerant(text)
if not isinstance(data, dict):
return []
findings = data.get("findings")
@@ -290,28 +317,74 @@ def parse_findings(text: str) -> list[dict]:
return []
out = []
for f in findings:
if not isinstance(f, dict):
continue
path = f.get("path")
line = f.get("line")
if not isinstance(path, str) or not path.strip():
continue
if not isinstance(line, int) or line < 1:
continue
sev = str(f.get("severity", "medium")).strip().lower()
if sev not in SEVERITIES:
sev = "medium"
out.append({
"severity": sev,
"path": path.strip(),
"line": line,
"problem": str(f.get("problem", "")).strip(),
"fix": str(f.get("fix", "")).strip(),
"suggestion": str(f.get("suggestion", "") or "").strip(),
})
n = _normalize_finding(f)
if n is not None:
out.append(n)
return out
def parse_review_output(text: str) -> tuple[str, list[dict]]:
"""Parse the opengine's stdout into (summary, findings).
Accepts `{"summary": "...", "findings": [...]}` (the opencode pragent agent)
or a bare `{"findings": [...]}`. `summary` defaults to "". Uses the LAST
```json fenced block (the pragent agent emits JSON as the final block), with
a tolerant fallback. Never raises.
"""
blob = _last_json_block(text)
if blob is None:
return "", []
try:
data = json.loads(blob)
except json.JSONDecodeError:
return "", []
if not isinstance(data, dict):
return "", []
summary = str(data.get("summary", "") or "").strip()
findings = data.get("findings")
out = []
if isinstance(findings, list):
for f in findings:
n = _normalize_finding(f)
if n is not None:
out.append(n)
return summary, out
def _parse_json_tolerant(text: str) -> dict | None:
"""Parse a JSON object from text: try the last fenced block, then a direct
parse, then the first balanced object. Returns None on any failure."""
if not text:
return None
blob = _last_json_block(text)
if blob is not None:
try:
d = json.loads(blob)
if isinstance(d, dict):
return d
except json.JSONDecodeError:
pass
s = text.strip()
if s.startswith("```"):
s = re.sub(r"^```[a-zA-Z]*\n?", "", s)
s = re.sub(r"\n?```$", "", s).strip()
try:
d = json.loads(s)
if isinstance(d, dict):
return d
except json.JSONDecodeError:
pass
obj = _extract_first_json_object(text)
if obj is not None:
try:
d = json.loads(obj)
if isinstance(d, dict):
return d
except json.JSONDecodeError:
pass
return None
def _extract_first_json_object(s: str) -> str | None:
"""Return the substring of the first balanced top-level `{ ... }` in s."""
start = s.find("{")
@@ -362,7 +435,8 @@ def inline_comment_body(f: dict) -> str:
"""Render one finding as a positional review-comment body.
Includes a ```suggestion fence only if the model produced non-empty
replacement code. Gitea renders that as an apply-able suggestion.
replacement code. Gitea renders that as an apply-able suggestion. Appends a
`📎 ref:` link when the finding carries a `reference` URL.
"""
sev = f["severity"].upper()
body = f"**[{sev}]** {f['problem']}"
@@ -370,6 +444,9 @@ def inline_comment_body(f: dict) -> str:
body += f"\n\nFix: {f['fix']}"
if f["suggestion"]:
body += f"\n\n```suggestion\n{f['suggestion']}\n```"
ref = f.get("reference", "")
if ref:
body += f"\n\n📎 ref: {ref}"
return body
@@ -379,7 +456,8 @@ def summary_bullets(findings: list[dict]) -> str:
for f in findings:
loc = f"{f['path']}:{f['line']}" if f["line"] else f["path"]
fix = f" — fix: {f['fix']}" if f["fix"] else ""
lines.append(f"- **[{f['severity'].upper()}]** `{loc}` — {f['problem']}{fix}")
ref = f" ({f.get('reference', '')})" if f.get("reference") else ""
lines.append(f"- **[{f['severity'].upper()}]** `{loc}` — {f['problem']}{fix}{ref}")
return "\n".join(lines)
@@ -621,16 +699,40 @@ def review_pr(
config = fetch_repo_config(api, repo, sha, token)
prior = prior_review_bodies(reviews, sha)
user_prompt = build_user_prompt(title, body, diff, config, prior)
raw_findings = call_model(ollama_url, model, SYSTEM_PROMPT, user_prompt, max_tokens)
findings = parse_findings(raw_findings)
engine = os.environ.get("PRAGENT_ENGINE", "opencode").strip().lower()
review_summary = ""
if engine == "opencode":
# The review "brain" runs on opencode: it gets the checked-out repo,
# the brief, and the pragent agent factory; returns stdout with a
# summary + findings JSON. We parse + anchor + post here.
import opencode_review # local import keeps the ollama path dep-free
# opencode wants a provider-prefixed model ref (headroom/glm-5.2:cloud);
# `model` here is the bare id (OLLAMA_MODEL). OPENCODE_MODEL overrides
# with the full ref; otherwise we prefix the configured provider.
oc_model = os.environ.get("OPENCODE_MODEL") or f"headroom/{model}"
stdout = opencode_review.run(
api=api, repo=repo, index=index, sha=sha, token=token,
title=title, body=body, diff=diff, config=config,
prior_reviews=prior, model=oc_model,
)
review_summary, findings = parse_review_output(stdout)
if not findings and not review_summary:
# opencode produced nothing parseable — fall back to a note.
post_review(api, repo, index, token, format_review_body(
"AI review produced no parseable output.", model, sha))
return True
else:
user_prompt = build_user_prompt(title, body, diff, config, prior)
raw_findings = call_model(ollama_url, model, SYSTEM_PROMPT, user_prompt, max_tokens)
findings = parse_findings(raw_findings)
anchors = parse_diff_anchors(diff)
anchored, unanchored = split_findings(findings, anchors)
# Summary body: the unanchored bullets (or "No issues found."), plus a
# one-line note when inline comments were posted so the summary isn't
# empty-looking.
# empty-looking. The opencode engine also carries a prose summary.
bullets = summary_bullets(unanchored)
summary_parts = []
if anchored:
@@ -639,12 +741,12 @@ def review_pr(
summary_parts.append(bullets)
if not summary_parts:
summary_parts.append("No issues found.")
summary_body = format_review_body("\n\n".join(summary_parts), model, sha)
summary_body = format_review_body("\n\n".join(summary_parts), model, sha, summary=review_summary)
post_inline_review(api, repo, index, token, summary_body, anchored)
print(
f"pragent: reviewed {repo}#{index} sha={sha[:8]} "
f"findings={len(findings)} inline={len(anchored)}",
f"engine={engine} findings={len(findings)} inline={len(anchored)}",
flush=True,
)
return True
+422
View File
@@ -0,0 +1,422 @@
#!/usr/bin/env python3
"""pragent pilot — opencode review engine (the "brain" host).
When `PRAGENT_ENGINE=opencode` (the default), `ai_review.review_pr` delegates the
analysis to this module instead of making one direct model call. It:
1. fetches the target repo's archive at the PR head sha into a temp workdir
(so the reviewer has the real files, not just the diff text);
2. writes a `.pragent/brief.md` (title, description, diff, repo config, prior
reviews, sha, anchor hint) for the `pragent` agent to read;
3. drops pragent's `opencode.json` + `.opencode/` factory into the workdir;
4. runs `opencode run --pure --agent pragent --dir <workdir> --model <model>`
headlessly and returns the agent's stdout (the summary + findings JSON).
The caller (`ai_review.review_pr`) parses that stdout into `(summary, findings)`,
validates the findings against diff anchors, and posts the review to Gitea — so
this module does NO Gitea I/O and NO parsing. It is pure review-engine glue.
Stdlib only. Fail-open: `run()` raises on failure; `review_pr` catches and posts
a short failure note.
Env:
PRAGENT_FACTORY_DIR repo root holding opencode.json + .opencode/ (default:
this file's parent's parent — the pragent repo root).
PRAGENT_OPENCODE_BIN path to the opencode CLI (default: shutil.which / the
known linuxbrew path).
PRAGENT_RTK_DIR dir holding the `rtk` binary, prepended to PATH for the
agent's bash tool (default: /home/marcos/.headroom/bin).
PRAGENT_WORK_ROOT parent for temp workdirs (default: /tmp/pragent-work).
PRAGENT_KEEP_WORK if set, leave the workdir on disk for debugging.
PRAGENT_REVIEW_TIMEOUT seconds to allow opencode to run (default: 480).
"""
import io
import json
import os
import shutil
import subprocess
import tarfile
import tempfile
import urllib.error
import urllib.request
# Where the factory lives (opencode.json + .opencode/). Default: the pragent
# repo root (this file is at <root>/pilot/opencode_review.py).
_DEFAULT_FACTORY = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RTK_DIR = os.environ.get("PRAGENT_RTK_DIR", "/home/marcos/.headroom/bin")
WORK_ROOT = os.environ.get("PRAGENT_WORK_ROOT", "/tmp/pragent-work")
TIMEOUT = int(os.environ.get("PRAGENT_REVIEW_TIMEOUT", "480"))
def _factory_dir() -> str:
return os.environ.get("PRAGENT_FACTORY_DIR", _DEFAULT_FACTORY)
def _opencode_bin() -> str:
b = os.environ.get("PRAGENT_OPENCODE_BIN")
if b:
return b
found = shutil.which("opencode")
if found:
return found
return "/home/linuxbrew/.linuxbrew/bin/opencode"
# ---------------------------------------------------------------------------
# Archive fetch + untar
# ---------------------------------------------------------------------------
def fetch_archive(api: str, repo: str, sha: str, token: str, dest: str) -> None:
"""Download `GET {api}/api/v1/repos/{repo}/archive/{sha}.tar.gz` and extract
into `dest`, stripping the archive's single top-level directory so the repo
files sit directly at `dest/` (matching the diff's `+++ b/foo` paths).
"""
url = f"{api.rstrip('/')}/api/v1/repos/{repo}/archive/{sha}.tar.gz"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req, timeout=120) as r:
blob = r.read()
_extract_tar_strip_one(blob, dest)
def _extract_tar_strip_one(blob: bytes, dest: str) -> None:
"""Extract a tar.gz blob into dest, stripping one common top-level dir.
If every member shares a single top-level prefix, that prefix is removed
(so `repo-sha/foo` -> `dest/foo`). If members have no common prefix, extract
as-is. Handles dirs, files, symlinks; ignores absolute paths / `..` for safety.
"""
os.makedirs(dest, exist_ok=True)
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
members = tar.getmembers()
# Find the common top-level prefix (the part before the first '/').
top_levels = set()
for m in members:
name = m.name.lstrip("/")
if not name:
continue
top_levels.add(name.split("/", 1)[0])
prefix = ""
if len(top_levels) == 1:
(prefix,) = top_levels
prefix += "/" # strip "topdir/"
for m in members:
name = m.name.lstrip("/")
if not name:
continue
# Safety: no absolute, no parent traversal.
if ".." in name.split("/"):
continue
rel = name[len(prefix):] if prefix else name
if not rel or rel == "/":
continue
target = os.path.join(dest, rel)
if m.isdir():
os.makedirs(target, exist_ok=True)
continue
if m.issym():
parent = os.path.dirname(target)
os.makedirs(parent, exist_ok=True)
try:
if os.path.lexists(target):
os.remove(target)
os.symlink(m.linkname, target)
except OSError:
pass
continue
if m.isreg():
parent = os.path.dirname(target)
os.makedirs(parent, exist_ok=True)
f = tar.extractfile(m)
if f is None:
continue
with open(target, "wb") as out:
shutil.copyfileobj(f, out)
# ---------------------------------------------------------------------------
# Brief + factory drop
# ---------------------------------------------------------------------------
BRIEF_PATH = ".pragent/brief.md"
_BRIEF_TEMPLATE = """\
# pragent review brief
- **repo:** {repo}
- **pr:** #{index}
- **head_sha:** `{sha}`
## Title
{title}
## Description
{description}
## Repo review config (.pr-review.json)
{config}
## Prior reviews (already posted — do NOT repeat these points)
{prior}
## How to anchor inline comments
Each finding `line` MUST be a line that exists in the POST-CHANGE version of
`path` — a context line (leading space in the diff) or an added `+` line. Never
a removed `-` line. Use the closest context line you can see if unsure.
## Diff
```diff
{diff}
```
"""
def write_brief(
workdir: str,
*,
repo: str,
index: str,
sha: str,
title: str,
description: str,
diff: str,
config: dict | None,
prior_reviews: list[str] | None,
) -> str:
"""Render `.pragent/brief.md` in the workdir. Returns the path written."""
path = os.path.join(workdir, ".pragent")
os.makedirs(path, exist_ok=True)
brief = os.path.join(path, "brief.md")
cfg = "_(none)_"
if config:
cfg = json.dumps(config, indent=2, ensure_ascii=False)
prior = "_(none)_"
if prior_reviews:
prior = "\n\n---\n\n".join(prior_reviews)
if len(prior) > 8000:
prior = prior[:8000] + "\n…[prior reviews truncated]"
content = _BRIEF_TEMPLATE.format(
repo=repo or "?",
index=index or "?",
sha=sha or "?",
title=title or "(none)",
description=description.strip() or "_(none)_",
config=cfg,
prior=prior,
diff=diff or "_(empty)_",
)
with open(brief, "w", encoding="utf-8") as f:
f.write(content)
return brief
def drop_factory(workdir: str) -> None:
"""Copy the pragent `opencode.json` + `.opencode/` into the workdir so
`opencode run --dir <workdir>` discovers them as project config. Overwrites
any existing ones (the workdir is a throwaway archive checkout)."""
src = _factory_dir()
oc_json = os.path.join(src, "opencode.json")
if os.path.isfile(oc_json):
shutil.copy2(oc_json, os.path.join(workdir, "opencode.json"))
src_oc = os.path.join(src, ".opencode")
dst_oc = os.path.join(workdir, ".opencode")
if os.path.isdir(dst_oc):
shutil.rmtree(dst_oc)
if os.path.isdir(src_oc):
shutil.copytree(src_oc, dst_oc)
# ---------------------------------------------------------------------------
# opencode invocation
# ---------------------------------------------------------------------------
_PROMPT = (
"Read .pragent/brief.md and review this pull request as pragent. "
"Load the review-methodology and findings-schema skills, inspect the "
"changed files and surrounding code in this repo, run any available "
"linters/typecheck on the changed files via bash, and delegate to the "
"security/tests/perf subagents only if the diff is large or "
"security-sensitive. End your message with a short prose summary followed "
"by the findings JSON code block per the findings-schema skill."
)
def _shared_home() -> str:
"""A persistent shared HOME for opencode across reviews.
opencode bootstraps its runtime (bun-installs `@opencode-ai` into
`$HOME/.config/opencode/node_modules` + fetches a models cache) on its FIRST
run in a fresh HOME — and that first run exits WITHOUT producing the answer.
A shared, warmed HOME makes every review a warm run (fast + reliable) and is
safe for this single-reviewer bot (one review at a time).
The provider/model/permission config (`opencode.json`) is installed here as
the isolated home's GLOBAL config; the `.opencode/` agents/skills/commands
are dropped per-workdir as PROJECT config. Clean split: infra shared, the
review factory per-PR.
"""
home = os.path.join(WORK_ROOT, ".opencode-home")
os.makedirs(home, exist_ok=True)
return home
def _ensure_global_config(home: str) -> None:
"""Install the pragent opencode.json as the isolated home's global config so
the provider/model/permission are always present (warm-up + every review),
regardless of --dir. Idempotent."""
dst_dir = os.path.join(home, ".config", "opencode")
os.makedirs(dst_dir, exist_ok=True)
dst = os.path.join(dst_dir, "opencode.json")
src = os.path.join(_factory_dir(), "opencode.json")
if not os.path.isfile(src):
return
# Copy if missing or changed (compare mtime/size to avoid pointless writes).
if not os.path.isfile(dst) or os.path.getmtime(src) > os.path.getmtime(dst):
shutil.copy2(src, dst)
def _build_env(home: str) -> dict:
"""Build the subprocess env for an opencode run.
- HOME -> the isolated shared home (so the host user's ~/.config/opencode is
not merged; the pragent opencode.json is installed there as the global
config by _ensure_global_config).
- Drop XDG_*_HOME (force config resolution under the isolated HOME).
- Drop ANTHROPIC_* (host vars like ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN /
ANTHROPIC_DEFAULT_*_MODEL leak from the user's shell and confuse opencode's
@ai-sdk/anthropic provider — ANTHROPIC_DEFAULT_SONNET_MODEL=glm-5.2:cloud
makes opencode look for provider "glm-5.2:cloud" → ProviderModelNotFoundError.
The headroom provider's config options.baseURL/apiKey are self-contained).
- Drop stray OPENCODE_* except the LSP flag (set explicitly below).
- Prepend the rtk dir to PATH so the agent's bash tool can call `rtk`.
"""
env = dict(os.environ)
env["HOME"] = home
for k in ("XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"):
env.pop(k, None)
for k in list(env):
if k.startswith("ANTHROPIC_") or (
k.startswith("OPENCODE_") and k != "OPENCODE_EXPERIMENTAL_LSP_TOOL"
):
env.pop(k, None)
path = env.get("PATH", "")
env["PATH"] = (RTK_DIR + os.pathsep + path) if RTK_DIR else path
env.setdefault("OPENCODE_EXPERIMENTAL_LSP_TOOL", "true")
return env
def _warm_opencode(home: str, model: str) -> None:
"""One-time warm-up: trigger opencode's runtime install so the real run is a
warm run. Runs with the global config present (provider resolvable) so it
doesn't poison the models cache with a negative entry. Idempotent via a
marker file. stdin=DEVNULL + a trivial prompt make this a fast no-op once
the runtime is installed."""
marker = os.path.join(home, ".pragent.warmed")
if os.path.exists(marker):
return
_ensure_global_config(home)
env = _build_env(home)
try:
subprocess.run(
[_opencode_bin(), "run", "--pure", "--model", model, "ok"],
cwd=home, env=env, capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=240,
)
except (subprocess.TimeoutExpired, Exception):
pass # warm-up output is discarded; the install is what matters
try:
open(marker, "w").close()
except OSError:
pass
def run_opencode(workdir: str, model: str, timeout: int | None = None) -> str:
"""Run the pragent agent headlessly in `workdir`. Returns the agent's stdout.
Isolates from the host user's global opencode config by pointing HOME at a
shared temp dir (so ~/.config/opencode is not merged) and passing --pure
(no external plugins). The workdir's opencode.json + .opencode/ (dropped by
drop_factory) are the only project config discovered; the shared home's
global opencode.json supplies the provider/model/permission. PATH prepends
the rtk dir so the agent's bash tool can call `rtk`. Warms the HOME first
(cold runs produce no output) and retries once on empty stdout.
stdin=DEVNULL is critical: opencode blocks on stdin (permission prompt /
interactive input) when run headlessly via subprocess, hanging until timeout.
"""
bin_ = _opencode_bin()
home = _shared_home()
_warm_opencode(home, model)
env = _build_env(home)
cmd = [
bin_,
"run",
"--pure",
"--agent", "pragent",
"--dir", workdir,
"--model", model,
_PROMPT,
]
last_err = ""
for attempt in range(2):
try:
proc = subprocess.run(
cmd, cwd=workdir, env=env, capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=timeout or TIMEOUT,
)
except subprocess.TimeoutExpired as e:
last_err = f"opencode timed out after {e.timeout}s"
continue
out = (proc.stdout or "").strip()
if out:
return proc.stdout
last_err = f"opencode empty stdout (rc={proc.returncode}); stderr: {(proc.stderr or '')[-1500:]}"
raise RuntimeError(last_err or "opencode produced no output")
# ---------------------------------------------------------------------------
# Orchestrator entry point
# ---------------------------------------------------------------------------
def run(
*,
api: str,
repo: str,
index: str,
sha: str,
token: str,
title: str,
body: str,
diff: str,
config: dict | None,
prior_reviews: list[str] | None,
model: str,
) -> str:
"""End-to-end: checkout archive → brief → drop factory → opencode → stdout.
Returns the raw opencode stdout (summary + findings JSON). Raises on any
failure; the caller (review_pr) fails open. The workdir is removed unless
PRAGENT_KEEP_WORK is set.
"""
os.makedirs(WORK_ROOT, exist_ok=True)
workdir = tempfile.mkdtemp(prefix=f"{repo.replace('/', '_')}-{sha[:8]}-", dir=WORK_ROOT)
keep = bool(os.environ.get("PRAGENT_KEEP_WORK"))
try:
fetch_archive(api, repo, sha, token, workdir)
write_brief(
workdir,
repo=repo, index=index, sha=sha, title=title, description=body,
diff=diff, config=config, prior_reviews=prior_reviews,
)
drop_factory(workdir)
stdout = run_opencode(workdir, model)
if not stdout.strip():
raise RuntimeError("opencode produced no output")
return stdout
finally:
if not keep:
shutil.rmtree(workdir, ignore_errors=True)