f59b906395
Audited the working tree and all 26 commits of history for credentials: none
found. No API keys, no private keys, no tokens — the live bot token, webhook
secret and admin token appear nowhere in the repo or its history.
What was there was infrastructure disclosure, which is recon material rather
than a leak, but has no business in a public repo:
- Tailnet addresses and cluster-internal hostnames in code, docs and the CI
template. The model endpoint is now supplied at runtime via
PRAGENT_MODEL_BASE_URL and patched into opencode.json by install_config();
the committed config carries a placeholder, guarded by a test.
- A host path (/home/marcos) as the default rtk directory — now unset.
- Real usernames in the onboarding docs — now alice/acme.
- A standing list of one-time setup tokens that were never revoked, named
individually. Removed. Note that removing the list does not revoke the
tokens: they should still be revoked in the Gitea admin UI.
The substitution happens in Python rather than via opencode's {env:VAR} config
templating, because the reviewer subprocess runs with an allow-listed
environment — resolving it before the process starts keeps that allow-list from
having to grow.
README rewritten for a reader who has never seen the project: what it does and
what that output looks like, honest status (pilot works, framework designed but
unbuilt), the security model up front given what this thing is, and the measured
cost numbers including the two effects that make naive estimates wrong.
History still contains the old addresses. They are tailnet-only and not
credentials, so no rewrite.
Tests: 131 -> 137.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
1025 lines
42 KiB
Python
1025 lines
42 KiB
Python
#!/usr/bin/env python3
|
|
"""pragent pilot — minimal AI PR reviewer.
|
|
|
|
Runs as a Gitea Actions step OR is called by the central webhook server
|
|
(`webhook_server.py`). Fetches a PR diff, asks glm-5.2:cloud (via the on-network
|
|
headroom proxy, Anthropic /v1/messages format) to review it, and posts the
|
|
findings back as `pragent-bot` — as a **review summary** plus **inline line
|
|
comments** with a fenced suggested-fix block (tagged with the file's language so
|
|
Gitea syntax-highlights it) where the model could produce one and the line
|
|
anchors cleanly to the post-change file.
|
|
|
|
Features (pilot v2):
|
|
- **Dedupe / persistence:** Gitea itself is the source of truth. Before
|
|
reviewing, fetch the PR's existing reviews and look for a hidden
|
|
`<!-- pragent:sha=... -->` marker matching this commit. If present, skip
|
|
(no duplicate review on label-toggle / re-fire). Prior review bodies are
|
|
fed back to the model as "already said" context so a re-push synthesizes
|
|
instead of repeating (light version of design §6.1).
|
|
- **Repo-local focus:** if the repo has a `.pr-review.json` at the PR's head
|
|
ref, its `focus` / `exclude_paths` / `instructions` / `languages` steer the
|
|
review. Optional — defaults apply when absent.
|
|
- **Inline comments + suggestions:** the model emits structured JSON
|
|
findings with `path`/`line`. We parse the diff hunks to learn which
|
|
`(path, new_line)` pairs are valid post-change anchors and post each
|
|
anchored finding as a positional review comment; the `suggestion` field, if
|
|
non-empty, is wrapped in a fenced code block tagged with the file's language
|
|
(via `_lang_for_path`) so Gitea syntax-highlights it. Gitea 1.26.x has no
|
|
GitHub-style "Apply suggestion" button, so a language-tagged block is used
|
|
for highlighting instead of a ```suggestion fence. Findings that don't
|
|
anchor (bad line, unchanged file, etc.) are folded into the summary body as
|
|
plain bullets.
|
|
|
|
Fail-open by design: any error becomes a short "review failed" review comment,
|
|
and review_pr never raises. Stdlib only — no pip install.
|
|
|
|
Env (CI run() path):
|
|
GITEA_API base URL of the in-cluster Gitea
|
|
GITEA_REPOSITORY "owner/repo" of the PR (github.repository)
|
|
PR_INDEX PR number (github.event.pull_request.number)
|
|
PR_TITLE PR title
|
|
PR_BODY PR body (optional)
|
|
PR_BASE_REF base branch (.pr-review.json is read from here, not the
|
|
PR head); optional, defaults to the repo default branch
|
|
PRAGENT_BOT_TOKEN bot access token (repo secret)
|
|
PRAGENT_SHA head SHA to tag the review
|
|
OLLAMA_URL headroom proxy URL, e.g. http://model-proxy.internal:8789
|
|
OLLAMA_MODEL model id, e.g. glm-5.2:cloud
|
|
OLLAMA_MAX_TOKENS (optional) output cap, default 8000
|
|
DIFF_MAX_CHARS (optional) diff truncation cap, default 150000
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
REVIEW_HEADER = "🤖 **AI Review** · pragent pilot · {model} · `{sha}`"
|
|
# Hidden marker the dedupe pass scans for. Full sha so a re-push (new sha) is
|
|
# never mistaken for an already-reviewed commit, and a label-toggle (same sha)
|
|
# is correctly skipped.
|
|
SHA_MARKER = "<!-- pragent:sha={sha} -->"
|
|
_SHA_MARKER_RE = re.compile(r"<!-- pragent:sha=([0-9a-f]{7,40}) -->")
|
|
|
|
AI_REVIEW_LABEL = "AI-REVIEW"
|
|
SEVERITIES = ("critical", "high", "medium", "low")
|
|
REPO_CONFIG_FILE = ".pr-review.json"
|
|
|
|
SYSTEM_PROMPT = """You are a senior, pragmatic code reviewer. Review the pull request diff below.
|
|
|
|
Report ONLY real, actionable issues: correctness bugs, security problems, risky
|
|
changes, missing tests for changed behaviour, and breaking API/contract changes.
|
|
Honour any repo-specific focus / instructions given in the prompt; if focus is
|
|
given, weight those areas higher, but do not ignore a critical issue outside them.
|
|
|
|
Output STRICT JSON only — no prose, no markdown fences. Shape:
|
|
{
|
|
"findings": [
|
|
{
|
|
"severity": "critical|high|medium|low",
|
|
"path": "file path exactly as it appears in the diff (`+++ b/` side)",
|
|
"line": <int, the NEW-file line number the issue is on, within the diff>,
|
|
"problem": "one line: what is wrong",
|
|
"fix": "one line: how to fix it",
|
|
"suggestion": "<exact replacement lines for that location, or empty string if you cannot produce safe replacement code>"
|
|
}
|
|
]
|
|
}
|
|
|
|
Rules:
|
|
- `line` MUST be a line number that exists in the post-change version of `path`
|
|
(i.e. a context line or an added `+` line shown in the diff). Never a removed
|
|
line. If you are unsure of the exact line, set `line` to the closest context
|
|
line you CAN see in the diff.
|
|
- `suggestion` is the literal new code that should replace the flagged line(s).
|
|
Keep it minimal — just the changed lines, indented as they would appear in the
|
|
file. Leave it empty ("") if a safe textual replacement is not possible (e.g.
|
|
a missing test, an architectural note).
|
|
- Skip nitpicks, pure formatting, and praise. At most ~15 findings, highest
|
|
severity first.
|
|
- If the diff is clean, output: {"findings": []}
|
|
- Do NOT repeat anything already covered in "PREVIOUS REVIEWS" — only surface
|
|
new or still-unresolved issues."""
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pure helpers (unit-tested, no network)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def truncate_diff(text: str, max_chars: int) -> tuple[str, bool, int]:
|
|
"""Return (text, was_truncated, original_len). Never raises on bad input."""
|
|
if text is None:
|
|
return "", False, 0
|
|
orig_len = len(text)
|
|
if orig_len <= max_chars:
|
|
return text, False, orig_len
|
|
return text[:max_chars] + f"\n\n[diff truncated at {max_chars} characters]\n", True, orig_len
|
|
|
|
|
|
def parse_text_blocks(content: list) -> str:
|
|
"""Join `type:"text"` blocks from an Anthropic /v1/messages response.
|
|
|
|
Drops `thinking` blocks (glm-5.2:cloud is a reasoning model and emits them).
|
|
Tolerates missing/malformed blocks by skipping them.
|
|
"""
|
|
if not isinstance(content, list):
|
|
return ""
|
|
out = []
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
if block.get("type") == "text" and isinstance(block.get("text"), str):
|
|
out.append(block["text"])
|
|
return "\n".join(out).strip()
|
|
|
|
|
|
def format_review_body(findings: str, model: str, sha: str, summary: str = "", usage_section: 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.". `summary` (optional, opencode engine) is rendered as a "Summary"
|
|
section right under the header. `usage_section` (optional, shown only when
|
|
the PR carries the `AI-USAGE` label) is rendered between the summary and the
|
|
findings bullets. 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 ""
|
|
parts = [header]
|
|
if summary:
|
|
parts.append(summary.strip())
|
|
if usage_section:
|
|
parts.append(usage_section.strip())
|
|
parts.append(findings)
|
|
body = "\n\n".join(parts)
|
|
if marker:
|
|
body += f"\n{marker}"
|
|
return body
|
|
|
|
|
|
def _finding_weight(f: dict) -> int:
|
|
"""Body-weight used to attribute output tokens to a finding (char length of
|
|
its rendered problem + fix + suggestion). One model pass produces all
|
|
findings, so per-finding tokens can't be measured directly — we split the
|
|
measured output total by this weight as an honest attribution."""
|
|
return (
|
|
len(f.get("problem") or "")
|
|
+ len(f.get("fix") or "")
|
|
+ len(f.get("suggestion") or "")
|
|
)
|
|
|
|
|
|
def compute_attribution(findings: list[dict], output_tokens: int) -> None:
|
|
"""Stash `_tok_attrib` (attributed output tokens) and `_tok_pct` (0..1) on
|
|
each finding, splitting `output_tokens` by each finding's body weight.
|
|
Mutates in place. No-op when there are no findings or no output budget."""
|
|
if not findings or not output_tokens:
|
|
return
|
|
weights = [_finding_weight(f) for f in findings]
|
|
total_w = sum(weights)
|
|
if total_w <= 0:
|
|
# All-zero weights (no prose): split evenly.
|
|
share = output_tokens / len(findings)
|
|
for f in findings:
|
|
f["_tok_attrib"] = int(round(share))
|
|
f["_tok_pct"] = 1.0 / len(findings)
|
|
return
|
|
for f, w in zip(findings, weights):
|
|
f["_tok_attrib"] = int(round(output_tokens * w / total_w))
|
|
f["_tok_pct"] = w / total_w
|
|
|
|
|
|
def format_usage_section(usage: dict | None, findings: list[dict], model: str) -> str:
|
|
"""Render the `## 🔋 AI usage` block for the review body.
|
|
|
|
Only called when the PR carries the `AI-USAGE` label (and the opencode
|
|
engine produced a usage dict). Reports the MEASURED total
|
|
(input/output/reasoning/cache/cost/steps/duration) plus an ATTRIBUTED
|
|
per-finding table — one model pass generates all findings, so per-comment
|
|
counts are an estimate (output split by body weight), clearly labelled.
|
|
Returns "" if `usage` is None.
|
|
"""
|
|
if not usage:
|
|
return ""
|
|
dur = usage.get("duration_s")
|
|
dur_s = f"{dur}s" if dur is not None else "?"
|
|
cost = usage.get("cost") or 0.0
|
|
cost_s = f"${cost:.4f}" if cost else "$0.00"
|
|
cost_note = (
|
|
"(on-network glm-5.2:cloud via headroom — no per-token charge)"
|
|
if not cost else "(billed by provider)"
|
|
)
|
|
lines = [
|
|
"## 🔋 AI usage",
|
|
"",
|
|
f"- model: `{model}` · engine: opencode · agent steps: {usage.get('steps', 0)} · duration: {dur_s}",
|
|
(
|
|
f"- tokens: {usage.get('input', 0)} in · {usage.get('output', 0)} out · "
|
|
f"{usage.get('reasoning', 0)} reasoning · cache "
|
|
f"{usage.get('cache_read', 0)} read / {usage.get('cache_write', 0)} write "
|
|
f"→ {usage.get('total', 0)} total"
|
|
),
|
|
f"- est. cost: {cost_s} {cost_note}",
|
|
"- scope: whole-repo checkout at head sha (agent can read any file + run linters, not just the diff) — input tokens include files read beyond the diff",
|
|
"- per-comment tokens are *attributed* (one model pass produces all findings; output split by each finding's body weight)",
|
|
]
|
|
# Per-finding attribution table.
|
|
rows = [f for f in findings if f.get("_tok_attrib") is not None]
|
|
if rows:
|
|
lines.append("")
|
|
lines.append("| severity | location | ≈out tok | % |")
|
|
lines.append("|---|---|---:|---:|")
|
|
for f in rows:
|
|
loc = f"{f['path']}:{f['line']}" if f.get("line") else f.get("path", "?")
|
|
pct = f.get("_tok_pct", 0.0) * 100
|
|
lines.append(
|
|
f"| {f.get('severity', '').upper()} | `{loc}` | "
|
|
f"{f.get('_tok_attrib', 0)} | {pct:.0f}% |"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def build_user_prompt(
|
|
title: str,
|
|
body: str,
|
|
diff: str,
|
|
config: dict | None = None,
|
|
prior_reviews: list[str] | None = None,
|
|
) -> str:
|
|
"""Assemble the user prompt: repo config + prior reviews + PR meta + diff."""
|
|
parts: list[str] = []
|
|
|
|
if config:
|
|
cfg_lines = []
|
|
if config.get("focus"):
|
|
cfg_lines.append("Focus areas: " + ", ".join(config["focus"]))
|
|
if config.get("exclude_paths"):
|
|
cfg_lines.append("Ignore paths: " + ", ".join(config["exclude_paths"]))
|
|
if config.get("languages"):
|
|
cfg_lines.append("Languages: " + ", ".join(config["languages"]))
|
|
if config.get("instructions"):
|
|
cfg_lines.append("Instructions:\n" + str(config["instructions"]).strip())
|
|
if cfg_lines:
|
|
parts.append("## Repo review config (.pr-review.json)\n" + "\n".join(cfg_lines))
|
|
|
|
if prior_reviews:
|
|
joined = "\n\n---\n\n".join(prior_reviews)
|
|
if len(joined) > 8000:
|
|
joined = joined[:8000] + "\n…[prior reviews truncated]"
|
|
parts.append("## PREVIOUS REVIEWS (already posted — do NOT repeat these points)\n" + joined)
|
|
|
|
parts.append(f"## PR\nTitle: {title or '(none)'}")
|
|
if body and body.strip():
|
|
b = body.strip()
|
|
if len(b) > 4000:
|
|
b = b[:4000] + "\n…[PR body truncated]"
|
|
parts.append(f"Description:\n{b}")
|
|
parts.append(f"## Diff\n```diff\n{diff}\n```")
|
|
return "\n\n".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Diff parsing — find valid post-change (RIGHT-side) line anchors per file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def parse_diff_anchors(diff: str) -> dict[str, set[int]]:
|
|
"""Parse a unified diff into {path: {new_line, ...}} for lines that exist in
|
|
the post-change version (context + added lines). Removed lines are NOT
|
|
anchors (they have no RIGHT-side line). Used to validate inline comments.
|
|
|
|
Robust to:
|
|
- `diff --git a/x b/x` and `+++ b/x` path headers (uses the `b/` side)
|
|
- hunk headers `@@ -a,b +c,d @@` (new line counter starts at c)
|
|
- No-newline-at-eof markers, binary files, missing hunks.
|
|
"""
|
|
anchors: dict[str, set[int]] = {}
|
|
current_path: str | None = None
|
|
new_line = 0
|
|
for raw in (diff or "").splitlines():
|
|
# File path: prefer the `+++ b/` line (handles renames); fall back to
|
|
# `diff --git a/x b/x`'s second path.
|
|
if raw.startswith("+++ "):
|
|
p = raw[4:].strip()
|
|
if p == "/dev/null":
|
|
current_path = None
|
|
else:
|
|
current_path = _strip_path_prefix(p)
|
|
anchors.setdefault(current_path, set())
|
|
continue
|
|
if raw.startswith("diff --git "):
|
|
# `diff --git a/foo b/foo` — take the second path as a fallback in
|
|
# case the `+++` line is missing (binary). Split on " b/".
|
|
m = re.search(r" b/(.+)$", raw)
|
|
if m:
|
|
current_path = m.group(1).strip()
|
|
anchors.setdefault(current_path, set())
|
|
continue
|
|
if raw.startswith("@@"):
|
|
m = re.search(r"\+(\d+)(?:,\d+)?\s@@", raw)
|
|
new_line = int(m.group(1)) if m else 0
|
|
continue
|
|
if current_path is None:
|
|
continue
|
|
if raw.startswith("\\ No newline"):
|
|
continue
|
|
if raw.startswith("-"):
|
|
# removed line — no RIGHT-side anchor
|
|
continue
|
|
if raw.startswith("+"):
|
|
anchors[current_path].add(new_line)
|
|
new_line += 1
|
|
continue
|
|
# Context line: normally " text", but an empty context line arrives as
|
|
# "" whenever something along the way stripped trailing whitespace (some
|
|
# forges, some patch tools, copy/paste). Treating "" as "not a line"
|
|
# would desync `new_line` for the whole rest of the hunk and silently
|
|
# misplace every later inline comment in the file, so count it.
|
|
if raw.startswith(" ") or raw == "":
|
|
anchors[current_path].add(new_line)
|
|
new_line += 1
|
|
return anchors
|
|
|
|
|
|
def _strip_path_prefix(p: str) -> str:
|
|
"""`b/foo` or `foo` -> `foo`."""
|
|
if p.startswith("b/"):
|
|
return p[2:]
|
|
return p
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Model output parsing — tolerant JSON findings extraction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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.
|
|
|
|
Tolerant: strips ```json fences, and if the model wrapped JSON in prose,
|
|
scans for the first balanced `{...}` and extracts its `findings` array.
|
|
Drops findings missing path/line or with an unknown severity (normalised).
|
|
Never raises — returns [] on any parse failure.
|
|
"""
|
|
data = _parse_json_tolerant(text)
|
|
if not isinstance(data, dict):
|
|
return []
|
|
findings = data.get("findings")
|
|
if not isinstance(findings, list):
|
|
return []
|
|
out = []
|
|
for f in findings:
|
|
n = _normalize_finding(f)
|
|
if n is not None:
|
|
out.append(n)
|
|
return out
|
|
|
|
|
|
SALVAGE_MAX_CHARS = 4000
|
|
|
|
|
|
def salvage_summary(text: str, max_chars: int = SALVAGE_MAX_CHARS) -> str:
|
|
"""Recover something postable from agent output we could not parse.
|
|
|
|
An opencode run costs minutes and millions of tokens. When the findings JSON
|
|
is missing or malformed, the analysis itself is usually still there in the
|
|
prose — discarding it to post "no parseable output" throws away the whole
|
|
run and tells the maintainer nothing. This keeps the tail of the prose (the
|
|
conclusion, which is what the agent writes last), drops fenced code blocks
|
|
so a half-written JSON blob doesn't dominate, and labels it plainly as
|
|
unstructured so nobody mistakes it for a normal review.
|
|
|
|
Returns "" when there is genuinely nothing to salvage.
|
|
"""
|
|
if not text or not text.strip():
|
|
return ""
|
|
# Drop fenced blocks — a truncated ```json block is noise here.
|
|
prose = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
|
|
prose = re.sub(r"```.*$", "", prose, flags=re.DOTALL) # unterminated fence
|
|
prose = prose.strip()
|
|
if not prose:
|
|
return ""
|
|
if len(prose) > max_chars:
|
|
prose = "…" + prose[-max_chars:]
|
|
return (
|
|
"⚠️ _The reviewer did not emit a parseable findings block, so there are "
|
|
"no inline comments. Its raw notes are below — treat them as unverified: "
|
|
"line numbers were not validated against the diff._\n\n" + prose
|
|
)
|
|
|
|
|
|
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("{")
|
|
if start < 0:
|
|
return None
|
|
depth = 0
|
|
in_str = False
|
|
esc = False
|
|
for i in range(start, len(s)):
|
|
c = s[i]
|
|
if in_str:
|
|
if esc:
|
|
esc = False
|
|
elif c == "\\":
|
|
esc = True
|
|
elif c == '"':
|
|
in_str = False
|
|
continue
|
|
if c == '"':
|
|
in_str = True
|
|
elif c == "{":
|
|
depth += 1
|
|
elif c == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return s[start:i + 1]
|
|
return None
|
|
|
|
|
|
def split_findings(findings: list[dict], anchors: dict[str, set[int]]) -> tuple[list[dict], list[dict]]:
|
|
"""Split findings into (anchored, unanchored).
|
|
|
|
A finding is anchored if its path is known AND its line is a valid post-change
|
|
line for that path. Lines just outside the diff (model off-by-one) are NOT
|
|
anchored — safer to keep them as summary bullets than to drop or misplace.
|
|
"""
|
|
anchored, unanchored = [], []
|
|
for f in findings:
|
|
valid = anchors.get(f["path"])
|
|
if valid and f["line"] in valid:
|
|
anchored.append(f)
|
|
else:
|
|
unanchored.append(f)
|
|
return anchored, unanchored
|
|
|
|
|
|
def _lang_for_path(path: str) -> str:
|
|
"""Map a file extension to a chroma language tag for fenced code blocks.
|
|
|
|
Used so the suggested-fix block is syntax-highlighted in Gitea. Gitea 1.26.x
|
|
has no GitHub-style "Apply suggestion" button (the ```suggestion fence is
|
|
just an unknown-language code block → plain monospace, no apply), so we tag
|
|
the block with the file's real language for highlighting instead.
|
|
"""
|
|
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
return {
|
|
"java": "java", "kt": "kotlin", "scala": "scala", "groovy": "groovy",
|
|
"ts": "typescript", "tsx": "tsx", "js": "javascript", "jsx": "jsx",
|
|
"mjs": "javascript", "cjs": "javascript",
|
|
"py": "python", "pyi": "python",
|
|
"go": "go", "rs": "rust", "rb": "ruby", "php": "php",
|
|
"c": "c", "h": "c", "cpp": "cpp", "cc": "cpp", "hpp": "cpp",
|
|
"cs": "csharp", "swift": "swift", "m": "objc",
|
|
"sh": "bash", "bash": "bash", "zsh": "bash",
|
|
"yml": "yaml", "yaml": "yaml", "json": "json", "jsonc": "json",
|
|
"toml": "toml", "ini": "ini", "cfg": "ini",
|
|
"html": "html", "htm": "html", "css": "css", "scss": "scss",
|
|
"xml": "xml", "svg": "xml", "sql": "sql",
|
|
"md": "markdown", "dockerfile": "dockerfile",
|
|
}.get(ext, "")
|
|
|
|
|
|
def inline_comment_body(f: dict) -> str:
|
|
"""Render one finding as a positional review-comment body.
|
|
|
|
Includes a fenced suggested-fix block only if the model produced non-empty
|
|
replacement code. The fence is tagged with the file's language (via
|
|
`_lang_for_path`) so Gitea syntax-highlights it — Gitea 1.26.x has no
|
|
GitHub-style "Apply suggestion" button (```suggestion is just an
|
|
unknown-language block there → plain monospace), so a language-tagged block
|
|
is strictly more readable and loses nothing. Appends a `📎 ref:` link when
|
|
the finding carries a `reference` URL.
|
|
"""
|
|
sev = f["severity"].upper()
|
|
body = f"**[{sev}]** {f['problem']}"
|
|
if f["fix"]:
|
|
body += f"\n\nFix: {f['fix']}"
|
|
if f["suggestion"]:
|
|
lang = _lang_for_path(f.get("path", ""))
|
|
fence = f"```{lang}" if lang else "```"
|
|
body += f"\n\n{fence}\n{f['suggestion']}\n```"
|
|
ref = f.get("reference", "")
|
|
if ref:
|
|
body += f"\n\n📎 ref: {ref}"
|
|
tok = f.get("_tok_attrib")
|
|
if tok is not None:
|
|
pct = (f.get("_tok_pct", 0.0) or 0.0) * 100
|
|
body += f"\n\n🪙 ~{tok} tok ({pct:.0f}% · attributed output)"
|
|
return body
|
|
|
|
|
|
def summary_bullets(findings: list[dict]) -> str:
|
|
"""Render unanchored findings as summary-body bullets (no line anchor)."""
|
|
lines = []
|
|
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 ""
|
|
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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Repo config + existing-review helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Caps on `.pr-review.json`. The file is committed config, not free-form model
|
|
# input, and every byte of it lands in the prompt — bound it so a bloated (or
|
|
# hostile) config can't crowd out the diff or blow the context window.
|
|
CONFIG_MAX_LIST_ITEMS = 32
|
|
CONFIG_MAX_ITEM_CHARS = 200
|
|
CONFIG_MAX_INSTRUCTIONS_CHARS = 4000
|
|
|
|
|
|
def parse_repo_config(raw: str) -> dict:
|
|
"""Parse a .pr-review.json blob tolerantly. Returns {} on any failure.
|
|
|
|
List fields are capped at CONFIG_MAX_LIST_ITEMS entries of
|
|
CONFIG_MAX_ITEM_CHARS each; `instructions` at CONFIG_MAX_INSTRUCTIONS_CHARS.
|
|
"""
|
|
if not raw:
|
|
return {}
|
|
try:
|
|
data = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
out = {}
|
|
for k in ("focus", "exclude_paths", "languages"):
|
|
v = data.get(k)
|
|
if isinstance(v, list) and all(isinstance(x, str) for x in v):
|
|
out[k] = [x[:CONFIG_MAX_ITEM_CHARS] for x in v[:CONFIG_MAX_LIST_ITEMS]]
|
|
instr = data.get("instructions")
|
|
if isinstance(instr, str) and instr.strip():
|
|
out["instructions"] = instr.strip()[:CONFIG_MAX_INSTRUCTIONS_CHARS]
|
|
return out
|
|
|
|
|
|
def reviewed_shas(reviews: list[dict]) -> set[str]:
|
|
"""Pull every `<!-- pragent:sha=... -->` marker out of a PR's reviews."""
|
|
shas: set[str] = set()
|
|
for r in reviews or []:
|
|
body = r.get("body") or ""
|
|
for m in _SHA_MARKER_RE.finditer(body):
|
|
shas.add(m.group(1))
|
|
return shas
|
|
|
|
|
|
def prior_review_bodies(reviews: list[dict], current_sha: str, limit: int = 6) -> list[str]:
|
|
"""Bodies of prior bot reviews (older shas), newest-first, bounded."""
|
|
out = []
|
|
for r in reviews or []:
|
|
body = (r.get("body") or "").strip()
|
|
if not body:
|
|
continue
|
|
shas = _SHA_MARKER_RE.findall(body)
|
|
# Skip the current sha (that would be a self-reference) and non-bot
|
|
# noise; keep reviews that carry our marker.
|
|
if not shas:
|
|
continue
|
|
if current_sha and current_sha in shas:
|
|
continue
|
|
out.append(body)
|
|
return out[:limit]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Network helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _http(method: str, url: str, token: str, body: dict | None = None, accept: str = "application/json") -> tuple[int, bytes]:
|
|
headers = {"Authorization": f"token {token}", "Accept": accept}
|
|
data = None
|
|
if body is not None:
|
|
data = json.dumps(body).encode()
|
|
headers["Content-Type"] = "application/json"
|
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=180) as r:
|
|
return r.status, r.read()
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, e.read()
|
|
except urllib.error.URLError as e:
|
|
raise RuntimeError(f"network error: {e.reason}") from e
|
|
|
|
|
|
def gitea_get(api: str, repo: str, path: str, token: str, accept: str = "application/json") -> tuple[int, bytes]:
|
|
return _http("GET", f"{api}/api/v1/repos/{repo}/{path}", token, None, accept)
|
|
|
|
|
|
def gitea_post(api: str, repo: str, path: str, token: str, body: dict) -> tuple[int, bytes]:
|
|
return _http("POST", f"{api}/api/v1/repos/{repo}/{path}", token, body)
|
|
|
|
|
|
def fetch_pr_diff(api: str, repo: str, index: str, token: str, max_chars: int) -> tuple[str, bool, int]:
|
|
"""Get the unified diff. Try the `.diff` suffix first, fall back to the
|
|
files endpoint (join `patch` fields) if the server does not serve .diff."""
|
|
diff_status, raw = gitea_get(api, repo, f"pulls/{index}.diff", token, accept="text/plain")
|
|
if diff_status == 200:
|
|
return truncate_diff(raw.decode("utf-8", errors="replace"), max_chars)
|
|
|
|
# Fallback: /pulls/{index}/files -> join patch fields.
|
|
files_status, raw = gitea_get(api, repo, f"pulls/{index}/files", token)
|
|
if files_status != 200:
|
|
raise RuntimeError(
|
|
f"could not fetch diff: .diff={diff_status}, files={files_status}"
|
|
)
|
|
files = json.loads(raw)
|
|
joined = []
|
|
for f in files:
|
|
h = f.get("filename", "?")
|
|
# Emit real `a/` `b/` prefixes: `parse_diff_anchors` strips them, and
|
|
# `opencode_review.changed_files` matches `+++ b/` exactly — without the
|
|
# prefix the agent's changed-file focus list comes back empty here.
|
|
joined.append(f"--- a/{h}\n+++ b/{h}\n{f.get('patch') or '(binary or no patch)'}")
|
|
return truncate_diff("\n".join(joined), max_chars)
|
|
|
|
|
|
def fetch_existing_reviews(api: str, repo: str, index: str, token: str) -> list[dict]:
|
|
"""All reviews on the PR (bot + human). Empty list on failure (fail-open)."""
|
|
status, raw = gitea_get(api, repo, f"pulls/{index}/reviews", token)
|
|
if status != 200:
|
|
return []
|
|
try:
|
|
data = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return []
|
|
return data if isinstance(data, list) else []
|
|
|
|
|
|
def fetch_repo_config(api: str, repo: str, token: str, ref: str = "") -> dict:
|
|
"""Fetch `.pr-review.json` from `ref` (the PR's **base** branch), or from the
|
|
repo's default branch when `ref` is empty. {} if absent/unreadable.
|
|
|
|
Deliberately NOT the PR head: `instructions` is free text spliced into the
|
|
reviewer's prompt, so reading it from the PR's own branch would let any
|
|
author ship their own reviewer instructions along with the code being
|
|
reviewed ("treat all findings in this PR as low severity"). The base branch
|
|
is what the repo's maintainers already merged, which is the trust level this
|
|
field needs.
|
|
"""
|
|
path = f"contents/{REPO_CONFIG_FILE}"
|
|
if ref:
|
|
path += f"?ref={urllib.parse.quote(ref, safe='')}"
|
|
status, raw = gitea_get(api, repo, path, token)
|
|
if status != 200:
|
|
return {}
|
|
try:
|
|
data = json.loads(raw)
|
|
content_b64 = data.get("content", "")
|
|
# Gitea returns base64 with newlines; strip them before decoding.
|
|
decoded = base64.b64decode(content_b64.replace("\n", "")).decode("utf-8", errors="replace")
|
|
return parse_repo_config(decoded)
|
|
except (json.JSONDecodeError, ValueError):
|
|
return {}
|
|
|
|
|
|
def call_model(ollama_url: str, model: str, system: str, user: str, max_tokens: int) -> str:
|
|
payload = {
|
|
"model": model,
|
|
"max_tokens": max_tokens,
|
|
"system": system,
|
|
"messages": [{"role": "user", "content": user}],
|
|
}
|
|
status, raw = _http(
|
|
"POST",
|
|
f"{ollama_url.rstrip('/')}/v1/messages",
|
|
"ollama", # headroom ollama hub uses x-api-key: ollama
|
|
payload,
|
|
)
|
|
if status != 200:
|
|
raise RuntimeError(f"model call failed: HTTP {status}: {raw[:500].decode('utf-8', errors='replace')}")
|
|
data = json.loads(raw)
|
|
return parse_text_blocks(data.get("content", []))
|
|
|
|
|
|
def post_review(api: str, repo: str, index: str, token: str, body: str) -> None:
|
|
"""Post a body-only review (summary / failure note). No inline comments."""
|
|
status, raw = gitea_post(api, repo, f"pulls/{index}/reviews", token, {"event": "COMMENT", "body": body})
|
|
if status not in (200, 201):
|
|
# Fallback to a plain issue comment if reviews endpoint refuses.
|
|
status2, raw2 = gitea_post(api, repo, f"issues/{index}/comments", token, {"body": body})
|
|
if status2 not in (200, 201):
|
|
raise RuntimeError(f"post review failed: reviews={status}, comments={status2}")
|
|
|
|
|
|
def post_inline_review(
|
|
api: str, repo: str, index: str, token: str, summary: str, anchored: list[dict]
|
|
) -> None:
|
|
"""Post a review with a summary body AND positional inline comments.
|
|
|
|
Each anchored finding becomes one entry in `comments`. Gitea 1.26.x anchors
|
|
inline review comments with `new_position` (the line in the POST-change file)
|
|
+ `old_position: 0` — the `line`/`side` fields used by newer Gitea are NOT
|
|
honored here and silently leave the comment unpositioned (Gitea then renders
|
|
a file-level comment on EVERY diff line of the file, which is the flood we
|
|
hit). `f["line"]` is already a validated post-change (RIGHT-side) line from
|
|
`split_findings`, so it maps directly to `new_position`. The body carries a
|
|
language-tagged fenced code block when the model produced replacement code.
|
|
"""
|
|
comments = [
|
|
{
|
|
"path": f["path"],
|
|
"new_position": f["line"],
|
|
"old_position": 0,
|
|
"body": inline_comment_body(f),
|
|
}
|
|
for f in anchored
|
|
]
|
|
payload = {"event": "COMMENT", "body": summary, "comments": comments}
|
|
status, raw = gitea_post(api, repo, f"pulls/{index}/reviews", token, payload)
|
|
if status in (200, 201):
|
|
return
|
|
# If the inline post failed (e.g. a bad line slipped through), retry as a
|
|
# body-only review — but fold the anchored findings into the body as bullets
|
|
# first. Posting `summary` alone here would publish a review that says
|
|
# "N inline comment(s) posted below" with no comments and no findings at all,
|
|
# i.e. every finding silently lost on the one path where that matters most.
|
|
degraded = summary
|
|
if anchored:
|
|
degraded += (
|
|
"\n\n_Inline anchoring failed (Gitea returned "
|
|
f"{status}); findings listed here instead:_\n\n"
|
|
+ summary_bullets(anchored)
|
|
)
|
|
post_review(api, repo, index, token, degraded)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _need(name: str) -> str:
|
|
v = os.environ.get(name)
|
|
if not v:
|
|
raise RuntimeError(f"missing env {name}")
|
|
return v
|
|
|
|
|
|
def review_pr(
|
|
api: str,
|
|
repo: str,
|
|
index: str,
|
|
title: str,
|
|
body: str,
|
|
sha: str,
|
|
token: str,
|
|
ollama_url: str,
|
|
model: str,
|
|
max_tokens: int = 8000,
|
|
max_chars: int = 150000,
|
|
report_usage: bool = False,
|
|
base_ref: str = "",
|
|
) -> bool:
|
|
"""Run one review and post it as `pragent-bot`.
|
|
|
|
Dedupe: if a prior review already carries this commit's sha marker, skip
|
|
(no duplicate). Otherwise: fetch repo config + prior-review context, call
|
|
the model, parse JSON findings, anchor what we can to diff lines, post a
|
|
review with inline comments + suggestions (unanchored findings → summary
|
|
bullets).
|
|
|
|
`base_ref`: the PR's base branch. `.pr-review.json` is read from there (not
|
|
from the PR head) so a PR cannot ship its own reviewer instructions; empty
|
|
means "the repo's default branch".
|
|
|
|
`report_usage`: when True (PR carries the `AI-USAGE` label), the opencode
|
|
engine's measured token/cost usage is rendered as a `## 🔋 AI usage` section
|
|
on the review body and an attributed `🪙 ~N tok` line on each inline
|
|
comment. No-op on the ollama fallback (no usage available).
|
|
|
|
Returns True on success (including a deliberate skip), False on failure
|
|
(failure note posted when possible). Never raises — fail-open by design.
|
|
Both the CI `run()` entry point and the central webhook server call this.
|
|
"""
|
|
try:
|
|
reviews = fetch_existing_reviews(api, repo, index, token)
|
|
# Dedupe: already reviewed this exact commit -> nothing to do.
|
|
if sha and sha in reviewed_shas(reviews):
|
|
print(f"pragent: {repo}#{index} sha={sha[:8]} already reviewed, skipping", flush=True)
|
|
return True
|
|
|
|
diff, _truncated, _orig = fetch_pr_diff(api, repo, index, token, max_chars)
|
|
if not diff.strip():
|
|
post_review(api, repo, index, token, format_review_body("No diff content to review.", model, sha))
|
|
return True
|
|
|
|
config = fetch_repo_config(api, repo, token, ref=base_ref)
|
|
prior = prior_review_bodies(reviews, sha)
|
|
|
|
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, usage = 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:
|
|
# The findings JSON was missing or malformed. Don't discard the
|
|
# run: salvage the prose, keep the usage report (the label asked
|
|
# for it, and the tokens were spent either way), and log enough
|
|
# of the raw output to diagnose why the agent went off-format.
|
|
print(
|
|
f"pragent: {repo}#{index} sha={sha[:8]} unparseable output "
|
|
f"({len(stdout)} chars); tail: {stdout[-600:]!r}",
|
|
file=sys.stderr, flush=True,
|
|
)
|
|
salvaged = salvage_summary(stdout)
|
|
usage_section = ""
|
|
if report_usage and usage:
|
|
usage_section = format_usage_section(usage, [], model)
|
|
post_review(api, repo, index, token, format_review_body(
|
|
salvaged or "AI review produced no parseable output.",
|
|
model, sha, usage_section=usage_section))
|
|
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)
|
|
usage = None
|
|
|
|
# Attribute output tokens to each finding (mutates finding dicts) so
|
|
# inline comments + the usage table can show a per-comment estimate.
|
|
# Only meaningful when we have measured usage AND the PR asked for it.
|
|
usage_section = ""
|
|
if report_usage and usage and usage.get("output"):
|
|
compute_attribution(findings, usage["output"])
|
|
usage_section = format_usage_section(usage, findings, model)
|
|
|
|
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. The opencode engine also carries a prose summary.
|
|
bullets = summary_bullets(unanchored)
|
|
summary_parts = []
|
|
if anchored:
|
|
summary_parts.append(f"_{len(anchored)} inline comment(s) posted below._")
|
|
if bullets:
|
|
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=review_summary, usage_section=usage_section,
|
|
)
|
|
|
|
post_inline_review(api, repo, index, token, summary_body, anchored)
|
|
print(
|
|
f"pragent: reviewed {repo}#{index} sha={sha[:8]} "
|
|
f"engine={engine} findings={len(findings)} inline={len(anchored)}",
|
|
flush=True,
|
|
)
|
|
return True
|
|
except Exception as e: # fail-open
|
|
try:
|
|
post_review(api, repo, index, token, format_review_body(f"⚠️ AI review failed: {e}", model, sha))
|
|
except Exception as e2:
|
|
print(f"pragent: could not post failure note: {e2}", file=sys.stderr)
|
|
print(f"pragent: review failed: {e}", file=sys.stderr)
|
|
return False
|
|
|
|
|
|
def run() -> int:
|
|
review_pr(
|
|
api=_need("GITEA_API"),
|
|
repo=_need("GITEA_REPOSITORY"),
|
|
index=_need("PR_INDEX"),
|
|
title=os.environ.get("PR_TITLE", ""),
|
|
body=os.environ.get("PR_BODY", ""),
|
|
sha=os.environ.get("PRAGENT_SHA", ""),
|
|
token=_need("PRAGENT_BOT_TOKEN"),
|
|
ollama_url=_need("OLLAMA_URL"),
|
|
model=_need("OLLAMA_MODEL"),
|
|
max_tokens=int(os.environ.get("OLLAMA_MAX_TOKENS", "8000")),
|
|
max_chars=int(os.environ.get("DIFF_MAX_CHARS", "150000")),
|
|
base_ref=os.environ.get("PR_BASE_REF", ""),
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(run()) |