refactor: split review pipeline responsibilities
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from . import pipeline
|
||||
from .pipeline import *
|
||||
from .analysis import parse_text_blocks, truncate_diff
|
||||
from .configuration import parse_repo_config
|
||||
from .output import inline_comment_body, summary_bullets
|
||||
|
||||
# Network helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _http(method: str, url: str, token: str, body: dict | None = None, accept: str = "application/json") -> tuple[int, bytes]:
|
||||
from gitea_client import request
|
||||
return request(method, url, token, body, accept)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional context URLs — static repo-provided background fetched once
|
||||
# per review and injected into the brief. The idea is the cheap reusable
|
||||
# knowledge (architecture summary, module map, conventions, glossary, past
|
||||
# incident write-ups, …) lives in a versioned file the maintainers control,
|
||||
# so the agent doesn't have to re-read the source tree to rediscover it on
|
||||
# every PR. Cached by URL for the lifetime of the process.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Hard caps — these guard against a single repo-config entry pulling down a
|
||||
# 2 MB doc and blowing the brief budget. Per-URL truncation keeps the worst
|
||||
# case bounded; total truncation caps the sum across URLs.
|
||||
_ADDITIONAL_CONTEXT_MAX_URLS = 8
|
||||
_ADDITIONAL_CONTEXT_MAX_PER_URL_CHARS = 4000
|
||||
_ADDITIONAL_CONTEXT_MAX_TOTAL_CHARS = 16_000
|
||||
_ADDITIONAL_CONTEXT_TIMEOUT_S = 5
|
||||
# Module-level cache, keyed by URL. The webhook server is a single Python
|
||||
# process per pod and reviews happen sequentially, so this stays bounded.
|
||||
_ADDITIONAL_CONTEXT_CACHE: dict[str, str] = {}
|
||||
|
||||
|
||||
def _parse_additional_context_env(value: str) -> list[str]:
|
||||
"""Comma-split an env var into a deduped, ordered URL list."""
|
||||
if not value:
|
||||
return []
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for piece in value.split(","):
|
||||
u = piece.strip()
|
||||
if u and u not in seen:
|
||||
seen.add(u)
|
||||
out.append(u)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_additional_context_urls(config: dict | None) -> list[str]:
|
||||
"""Merge the env var `PRAGENT_ADDITIONAL_CONTEXT_URL` with the per-repo
|
||||
config field `additional_context_urls`. Env var wins on ordering — it
|
||||
appears first so a one-off override can shadow a stale config entry."""
|
||||
env = _parse_additional_context_env(os.environ.get("PRAGENT_ADDITIONAL_CONTEXT_URL", ""))
|
||||
cfg_raw = (config or {}).get("additional_context_urls") or []
|
||||
cfg: list[str] = []
|
||||
if isinstance(cfg_raw, list):
|
||||
for x in cfg_raw:
|
||||
if isinstance(x, str):
|
||||
u = x.strip()
|
||||
if u and u not in set(env):
|
||||
cfg.append(u)
|
||||
merged = env + cfg
|
||||
return merged[:_ADDITIONAL_CONTEXT_MAX_URLS]
|
||||
|
||||
|
||||
def _fetch_one_additional_context(url: str) -> str | None:
|
||||
"""Fetch a single URL. Returns the body (UTF-8, truncated) or None on
|
||||
any failure — never raises; additional-context is best-effort.
|
||||
|
||||
Reject non-http(s) schemes defensively so a misconfigured `file://` or
|
||||
`javascript:` URL cannot escape the pod. Cap per-URL size before parsing
|
||||
to avoid a 50 MB response landing in memory.
|
||||
"""
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return None
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "pragent/1.0 (+context)"})
|
||||
with urllib.request.urlopen(req, timeout=_ADDITIONAL_CONTEXT_TIMEOUT_S) as r:
|
||||
raw = r.read(_ADDITIONAL_CONTEXT_MAX_PER_URL_CHARS + 1)
|
||||
if len(raw) > _ADDITIONAL_CONTEXT_MAX_PER_URL_CHARS:
|
||||
raw = raw[:_ADDITIONAL_CONTEXT_MAX_PER_URL_CHARS]
|
||||
truncated = True
|
||||
else:
|
||||
truncated = False
|
||||
body = raw.decode("utf-8", errors="replace")
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError, ValueError):
|
||||
return None
|
||||
if truncated:
|
||||
body += "\n…[truncated]"
|
||||
return body
|
||||
|
||||
|
||||
def fetch_additional_context(urls: list[str]) -> str:
|
||||
"""Fetch a list of URLs, join into one string for the brief. Cached.
|
||||
|
||||
Empty when no URLs are given. Best-effort: a URL that errors is logged
|
||||
to stderr and skipped — never aborts the review. Each fetched body is
|
||||
truncated to `_ADDITIONAL_CONTEXT_MAX_PER_URL_CHARS` and the joined
|
||||
output to `_ADDITIONAL_CONTEXT_MAX_TOTAL_CHARS`. Already-cached URLs
|
||||
are not refetched.
|
||||
"""
|
||||
if not urls:
|
||||
return ""
|
||||
blocks: list[str] = []
|
||||
total = 0
|
||||
for url in urls:
|
||||
if url in _ADDITIONAL_CONTEXT_CACHE:
|
||||
body = _ADDITIONAL_CONTEXT_CACHE[url]
|
||||
else:
|
||||
body = _fetch_one_additional_context(url) or ""
|
||||
_ADDITIONAL_CONTEXT_CACHE[url] = body
|
||||
if not body:
|
||||
continue
|
||||
block = f"### {url}\n\n{body}"
|
||||
if total + len(block) > _ADDITIONAL_CONTEXT_MAX_TOTAL_CHARS:
|
||||
remaining = _ADDITIONAL_CONTEXT_MAX_TOTAL_CHARS - total
|
||||
if remaining <= 80:
|
||||
break
|
||||
block = block[:remaining] + "\n…[truncated]"
|
||||
blocks.append(block)
|
||||
total = _ADDITIONAL_CONTEXT_MAX_TOTAL_CHARS
|
||||
break
|
||||
blocks.append(block)
|
||||
total += len(block)
|
||||
return "\n\n".join(blocks)
|
||||
|
||||
|
||||
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 = pipeline.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 = pipeline.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 = pipeline.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 = pipeline.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:
|
||||
from model_client import complete
|
||||
return complete(ollama_url, model, system, user, max_tokens)
|
||||
|
||||
|
||||
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 = pipeline.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 = pipeline.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 = pipeline.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 _emit_langfuse(
|
||||
*,
|
||||
repo: str,
|
||||
index: str,
|
||||
sha: str,
|
||||
title: str,
|
||||
model: str,
|
||||
usage: dict | None,
|
||||
findings: list[dict],
|
||||
summary: str,
|
||||
engine: str,
|
||||
config: dict | None = None,
|
||||
dropped_count: float | None = None,
|
||||
) -> None:
|
||||
"""Ship this review's usage to Langfuse, if one is configured.
|
||||
|
||||
Called on both exit paths that spent tokens — the normal post and the
|
||||
salvage path — because an unparseable run costs the same as a clean one and
|
||||
is exactly the kind of thing worth trending.
|
||||
|
||||
Local import + blanket except: `langfuse_trace` is stdlib-only but optional,
|
||||
and telemetry is never allowed to fail a review (see the fail-open contract
|
||||
in `review_pr`). The trace's `environment` is `claude` or `ollama`, so the
|
||||
two spend stories stay separated in every Langfuse view.
|
||||
"""
|
||||
try:
|
||||
import langfuse_trace
|
||||
|
||||
# Same comparison model the review body prices against, so the number
|
||||
# in Langfuse and the number in the PR agree. Free/unknown models
|
||||
# (MiniMax, glm, self-hosted qwen) are priced against it; a paid model
|
||||
# is priced as itself.
|
||||
price_target, _err = _resolve_price_target(config)
|
||||
|
||||
langfuse_trace.emit_review_trace(
|
||||
repo=repo, index=index, sha=sha, title=title, model=model,
|
||||
usage=usage, findings=findings, summary=summary or "",
|
||||
engine=engine, lenses=(usage or {}).get("lenses"),
|
||||
price_target=price_target, dropped_count=dropped_count,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"pragent: langfuse emit skipped: {e}", file=sys.stderr)
|
||||
@@ -10,5 +10,5 @@ from __future__ import annotations
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
_implementation = importlib.import_module("review._legacy")
|
||||
_implementation = importlib.import_module("review.pipeline")
|
||||
sys.modules[__name__] = _implementation
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from . import pipeline
|
||||
from .pipeline import *
|
||||
from .pipeline import _CONFIDENCE_BADGE
|
||||
from .configuration import effective_config
|
||||
from .output import _string_list, findings_table
|
||||
|
||||
# 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 fmt_tokens(n) -> str:
|
||||
"""1234567 -> '1,234,567 (1.2M)'; 0 -> '0'; <1000 -> comma-only; None/negative -> '?'.
|
||||
|
||||
Always returns the full comma-separated number; the short suffix is a
|
||||
parenthetical for fast scanning. Caps at B; the cost model never exceeds M.
|
||||
"""
|
||||
if n is None:
|
||||
return "?"
|
||||
if not isinstance(n, (int, float)) or n < 0:
|
||||
return "?"
|
||||
n = int(n)
|
||||
if n < 1000:
|
||||
return f"{n:,}"
|
||||
if n < 1_000_000:
|
||||
return f"{n:,} ({n / 1000:.1f}K)"
|
||||
if n < 1_000_000_000:
|
||||
return f"{n:,} ({n / 1_000_000:.1f}M)"
|
||||
return f"{n:,} ({n / 1_000_000_000:.1f}B)"
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
from model_client import parse_text_blocks as _parse_text_blocks
|
||||
return _parse_text_blocks(content)
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
"""Read an int from the environment, falling back on anything unparseable.
|
||||
|
||||
A typo in a tuning knob must not take down a review that is already
|
||||
mid-flight — the operator gets a stderr line and the default instead.
|
||||
"""
|
||||
raw = os.environ.get(name, "")
|
||||
if not str(raw).strip():
|
||||
return default
|
||||
try:
|
||||
return int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
print(
|
||||
f"pragent: ignoring {name}={raw!r} (not an integer); using {default}",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
return default
|
||||
|
||||
|
||||
# 1-5 merge-verdict score (higher = safer). Buckets:
|
||||
# 5 = clean (or low/info/trivial only — nothing worth blocking on)
|
||||
# 4 = medium present
|
||||
# 3 = high present (operator should at least look)
|
||||
# 1 = critical present (block the merge by default)
|
||||
# Cross-lens agreement on any finding takes one more off, floored at 1.
|
||||
_CONFIDENCE_BADGE = {5: "🟢", 4: "🟢", 3: "🟡", 2: "🟠", 1: "🔴"}
|
||||
|
||||
|
||||
def merge_confidence(findings: list[dict], *, multi_lens_observed: bool = False) -> int:
|
||||
"""1-5 merge verdict: higher = safer.
|
||||
|
||||
Tier drops driven by the most severe finding present:
|
||||
- critical → 1
|
||||
- high → 3
|
||||
- medium → 4
|
||||
- else → 5 (low / trivial / info / unknown → no drop)
|
||||
|
||||
An extra -1 when cross-lens agreement was observed on any finding
|
||||
(``multi_lens_observed``). The flag is passed in explicitly because the
|
||||
raw ``_multi_lens`` marker is stripped from findings by the time they
|
||||
reach this function — first by ``opencode_review.run_lenses_review``
|
||||
(the ``_``-prefix scrub) and again by ``_normalize_finding`` (the
|
||||
7-key schema rebuild). The caller (``review_pr``) must capture the
|
||||
signal before those strips fire. Final score is clamped to [1, 5] so
|
||||
a critical + multi_lens combo doesn't go negative.
|
||||
"""
|
||||
if not findings:
|
||||
return 5
|
||||
max_rank = max(SEVERITY_RANK.get(f.get("severity", "low"), 0) for f in findings)
|
||||
if max_rank >= SEVERITY_RANK["critical"]:
|
||||
score = 1
|
||||
elif max_rank >= SEVERITY_RANK["high"]:
|
||||
score = 3
|
||||
elif max_rank >= SEVERITY_RANK["medium"]:
|
||||
score = 4
|
||||
else:
|
||||
score = 5
|
||||
if multi_lens_observed:
|
||||
score -= 1
|
||||
return max(1, min(5, score))
|
||||
|
||||
|
||||
def format_review_body(
|
||||
findings: str,
|
||||
model: str,
|
||||
sha: str,
|
||||
summary: str = "",
|
||||
usage_section: str = "",
|
||||
*,
|
||||
summary_changes: list[str] | None = None,
|
||||
risks: list[str] | None = None,
|
||||
findings_for_table: list[dict] | None = None,
|
||||
inline_count: int = 0,
|
||||
confidence: int = 5,
|
||||
walkthrough: list[str] | None = None,
|
||||
risk_verdict: str = "",
|
||||
test_coverage: str = "",
|
||||
static_message: str = "",
|
||||
|
||||
) -> str:
|
||||
"""Format the posted review summary body.
|
||||
|
||||
Layout (per the operator's format guide):
|
||||
|
||||
* Header line (``🤖 AI Review …``) including the merge-confidence badge.
|
||||
* Optional static banner (``> {static_message}``) — repo-wide call-out
|
||||
from `.pr-review.json:static_message`, placed under the header so
|
||||
every reviewer sees it on every review without scrolling.
|
||||
|
||||
* **Summary of Changes** — 2–4 bullets of what the PR introduces
|
||||
(`summary_changes`); falls back to the opencode prose `summary` if
|
||||
the agent didn't emit the list.
|
||||
* **Risk Verdict** — one-line "<level> risk: <reason>" verdict
|
||||
(`risk_verdict`); omitted when empty.
|
||||
* **Walkthrough** — up to 6 file- or change-grouped bullets
|
||||
(`walkthrough`); the file part is wrapped in backticks so paths
|
||||
render as code in Gitea. Omitted when empty.
|
||||
* **Test Coverage** — short `test_coverage` string ("Tests added" /
|
||||
etc.); omitted when empty.
|
||||
* **Key Risks & Concerns** — bullets of potential bugs/edge cases
|
||||
found across the diff (`risks`).
|
||||
* **Findings Overview** — a Markdown table (severity / location /
|
||||
one-line problem) covering ALL findings, anchored or not.
|
||||
* Unanchored bullets — findings with no post-change line to anchor
|
||||
(the inline ones are posted separately as Gitea review comments).
|
||||
* AI Usage & Run Details — wrapped in a ``<details>`` collapsible so
|
||||
the body stays scannable; cost lines stay inside it.
|
||||
* Hidden SHA marker — for the dedupe pass.
|
||||
|
||||
`confidence` is a 1-5 merge verdict rendered as `<N>/5 <badge>` in the
|
||||
header. Clamped to [1, 5] so a stray value (e.g. 0 from a missing
|
||||
finding list) doesn't print a broken badge.
|
||||
|
||||
Empty `summary_changes` + empty `risks` + empty `summary` collapse into
|
||||
a single "Summary of Changes: _no summary provided._" line so the body
|
||||
never looks half-rendered.
|
||||
"""
|
||||
score = max(1, min(5, confidence))
|
||||
badge = _CONFIDENCE_BADGE.get(score, "🟢")
|
||||
confidence_str = f"{score}/5 {badge}"
|
||||
header = REVIEW_HEADER.format(
|
||||
model=model,
|
||||
sha=sha[:8] if sha else "unknown",
|
||||
confidence=confidence_str,
|
||||
)
|
||||
parts: list[str] = [header]
|
||||
|
||||
# Optional free-text banner. Rendered as a Markdown blockquote immediately
|
||||
# after the header — front-of-mind for any maintainer scanning the review.
|
||||
if static_message and static_message.strip():
|
||||
parts.append(f"> {static_message.strip()}")
|
||||
|
||||
# --- Summary of Changes ---
|
||||
sc = list(summary_changes or [])
|
||||
if not sc and summary:
|
||||
sc = _string_list(summary)
|
||||
if sc:
|
||||
sc = sc[:4]
|
||||
items = "\n".join(f"- {item}" for item in sc)
|
||||
parts.append(f"### Summary of Changes\n\n{items}")
|
||||
else:
|
||||
parts.append("### Summary of Changes\n\n_No summary provided._")
|
||||
|
||||
# --- Risk Verdict ---
|
||||
if risk_verdict:
|
||||
parts.append(f"### Risk Verdict\n\n{risk_verdict}")
|
||||
|
||||
# --- Walkthrough ---
|
||||
wt = list(walkthrough or [])
|
||||
if wt:
|
||||
wt = wt[:6]
|
||||
rendered = []
|
||||
for item in wt:
|
||||
# Items typically look like "a.py — adds X" (em-dash separator).
|
||||
# Wrap the file path in backticks so it renders as code in the
|
||||
# Gitea markdown body; leave the description as plain prose. When
|
||||
# no separator is present, render the whole line as plain prose
|
||||
# (the agent's "plain prose" fallback for change-grouped bullets).
|
||||
if " — " in item:
|
||||
path, _, rest = item.partition(" — ")
|
||||
rendered.append(f"- `{path}` — {rest}")
|
||||
else:
|
||||
rendered.append(f"- {item}")
|
||||
parts.append(f"### Walkthrough\n\n" + "\n".join(rendered))
|
||||
|
||||
# --- Test Coverage ---
|
||||
if test_coverage:
|
||||
parts.append(f"### Test Coverage\n\n{test_coverage}")
|
||||
|
||||
# --- Key Risks & Concerns ---
|
||||
rs = list(risks or [])
|
||||
if rs:
|
||||
items = "\n".join(f"- {item}" for item in rs)
|
||||
parts.append(f"### Key Risks & Concerns\n\n{items}")
|
||||
else:
|
||||
parts.append("### Key Risks & Concerns\n\n_None identified._")
|
||||
|
||||
# --- Findings Overview (table) ---
|
||||
table = findings_table(findings_for_table or [])
|
||||
if table:
|
||||
n_inline = inline_count
|
||||
n_total = len(findings_for_table or [])
|
||||
if n_inline:
|
||||
heading = f"### Findings Overview\n\n_{n_inline} inline comment(s); {n_total} total._"
|
||||
else:
|
||||
heading = f"### Findings Overview\n\n_{n_total} finding(s)._"
|
||||
parts.append(f"{heading}\n\n{table}")
|
||||
|
||||
# --- Unanchored bullets ---
|
||||
fb = (findings or "").strip()
|
||||
if fb:
|
||||
parts.append(fb)
|
||||
|
||||
# --- Collapsible usage ---
|
||||
if usage_section:
|
||||
parts.append(usage_section.strip())
|
||||
|
||||
# --- Hidden marker ---
|
||||
marker = SHA_MARKER.format(sha=sha) if sha else ""
|
||||
|
||||
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 _resolve_price_target(config: dict | None) -> tuple[str, str | None]:
|
||||
"""Pick which provider to compute the equivalent cost against.
|
||||
|
||||
Order: `.pr-review.json:cost_target` > `PRAGENT_PRICE_TARGET` env >
|
||||
`DEFAULT_PRICE_TARGET` (claude-sonnet-5). Returns `(price_key, error)`.
|
||||
|
||||
If any of the user-set keys is unknown, falls back to the default AND
|
||||
reports the error so the operator sees their typo (a config-level typo
|
||||
silently picking the default would defeat the purpose of letting repos
|
||||
opt into a different comparison model).
|
||||
"""
|
||||
from cost_model import PRICES # local import keeps ollama path dep-free
|
||||
candidates: list[tuple[str, str]] = []
|
||||
if isinstance(config, dict) and config.get("cost_target"):
|
||||
candidates.append(("repo config", str(config["cost_target"]).strip()))
|
||||
env = os.environ.get("PRAGENT_PRICE_TARGET", "").strip()
|
||||
if env:
|
||||
candidates.append(("PRAGENT_PRICE_TARGET env", env))
|
||||
candidates.append(("default", DEFAULT_PRICE_TARGET))
|
||||
|
||||
chosen = DEFAULT_PRICE_TARGET
|
||||
for source, key in candidates:
|
||||
if key in PRICES:
|
||||
chosen = key
|
||||
break
|
||||
else:
|
||||
# No candidate was valid. Use default + report.
|
||||
return chosen, (
|
||||
f"unknown price target (checked {', '.join(f'{s}={k!r}' for s, k in candidates)}); "
|
||||
f"valid: {', '.join(sorted(PRICES))}"
|
||||
)
|
||||
|
||||
# Even when we picked a valid key, if the *user* set one and it was
|
||||
# unknown, surface that. (We only get here if a later candidate resolved,
|
||||
# so the invalid one was upstream.)
|
||||
invalid = [(s, k) for s, k in candidates if k not in PRICES and s != "default"]
|
||||
if invalid:
|
||||
return chosen, (
|
||||
f"unknown price target (set {', '.join(f'{s}={k!r}' for s, k in invalid)}); "
|
||||
f"valid: {', '.join(sorted(PRICES))}; falling back to `{chosen}`"
|
||||
)
|
||||
return chosen, None
|
||||
|
||||
|
||||
def _resolve_display_model(base_model: str, config: dict | None) -> str:
|
||||
"""Resolve the *display* model for one review.
|
||||
|
||||
Precedence (highest first):
|
||||
1. `OPENCODE_MODEL` env var — operator override, used as-is (already a
|
||||
provider-prefixed opencode ref like `headroom/MiniMax-M2.7`).
|
||||
2. `.pr-review.json:model` — per-repo override. Already validated
|
||||
against `cost_model.PRICES` by `parse_repo_config`, so a bare key
|
||||
like `claude-sonnet-5` or `qwen3.8-27b` is safe. Re-prefixed with
|
||||
the model's `provider` field from `cost_model.Price` (default
|
||||
`headroom`) so the opencode subprocess routes correctly — e.g.
|
||||
`qwen3.8-27b` → `vllm-qwen38/qwen3.8-27b` (vLLM on RTX 3090 at
|
||||
192.168.1.79:18020), `claude-sonnet-5` → `headroom/claude-sonnet-5`
|
||||
(Anthropic pricing proxy).
|
||||
3. Default — `f"headroom/{base_model}"` where `base_model` is the bare
|
||||
`OLLAMA_MODEL` (e.g. `"MiniMax-M2.7" → "headroom/MiniMax-M2.7"`).
|
||||
|
||||
The same value flows to every consumer (opencode subprocess, REVIEW_HEADER,
|
||||
cost-line parenthetical) so reviewers never see a mix of `glm-5.2:cloud`
|
||||
and the routed model in one body.
|
||||
"""
|
||||
env = os.environ.get("OPENCODE_MODEL")
|
||||
if env:
|
||||
return env
|
||||
cfg_model = (config or {}).get("model")
|
||||
if isinstance(cfg_model, str) and cfg_model.strip():
|
||||
# Look up the provider from PRICES so the opencode subprocess routes
|
||||
# through the right provider block (vllm-qwen38 vs headroom). Lazy
|
||||
# import — the ollama path doesn't touch cost_model.
|
||||
from cost_model import PRICES
|
||||
provider = PRICES.get(cfg_model.strip())
|
||||
if provider is not None:
|
||||
return f"{provider.provider}/{cfg_model.strip()}"
|
||||
# parse_repo_config already drops unknowns, but stay defensive: fall
|
||||
# back to headroom so the review still runs rather than crash.
|
||||
return f"headroom/{cfg_model.strip()}"
|
||||
return f"headroom/{base_model}"
|
||||
|
||||
|
||||
def equivalent_cost(usage: dict, price_key: str) -> float:
|
||||
"""USD the measured usage would have billed on `price_key`'s provider.
|
||||
|
||||
`usage` is the dict from `parse_opencode_events` (input/output/reasoning/
|
||||
cache_read/cache_write). Builds a `cost_model.Usage` and runs `cost()`. The
|
||||
pilot's actual provider (headroom/glm-5.2:cloud) reports $0 — this is what
|
||||
the same tokens would cost on a paid model, so maintainers can budget.
|
||||
"""
|
||||
from cost_model import Usage, cost, PRICES # local import: ollama path dep-free
|
||||
if price_key not in PRICES:
|
||||
return 0.0
|
||||
u = Usage(
|
||||
uncached_input=(usage.get("input", 0) - usage.get("cache_read", 0)),
|
||||
cached_input=usage.get("cache_read", 0),
|
||||
cache_writes=usage.get("cache_write", 0),
|
||||
output=usage.get("output", 0),
|
||||
)
|
||||
return cost(u, PRICES[price_key])
|
||||
|
||||
|
||||
def build_user_prompt(
|
||||
title: str,
|
||||
body: str,
|
||||
diff: str,
|
||||
config: dict | None = None,
|
||||
prior_reviews: list[str] | None = None,
|
||||
additional_context: str = "",
|
||||
) -> str:
|
||||
"""Assemble the user prompt: repo config + additional context + prior reviews + PR meta + diff."""
|
||||
parts: list[str] = []
|
||||
|
||||
eff = effective_config(config) if config else {}
|
||||
if eff:
|
||||
cfg_lines = []
|
||||
if eff.get("focus"):
|
||||
cfg_lines.append("Focus areas: " + ", ".join(eff["focus"]))
|
||||
if eff.get("exclude_paths"):
|
||||
cfg_lines.append("Ignore paths: " + ", ".join(eff["exclude_paths"]))
|
||||
if eff.get("languages"):
|
||||
cfg_lines.append("Languages: " + ", ".join(eff["languages"]))
|
||||
if eff.get("style"):
|
||||
cfg_lines.append(f"Review style: {eff['style']} "
|
||||
f"(max {eff['max_findings']} findings, threshold "
|
||||
f"{eff['severity_threshold']}+)")
|
||||
if eff.get("patterns", {}).get("allow"):
|
||||
cfg_lines.append("Allow paths (only these are reviewed): "
|
||||
+ ", ".join(eff["patterns"]["allow"]))
|
||||
if eff.get("patterns", {}).get("deny"):
|
||||
cfg_lines.append("Deny paths: " + ", ".join(eff["patterns"]["deny"]))
|
||||
if eff.get("exclude_tests"):
|
||||
cfg_lines.append("Skip test files entirely.")
|
||||
if eff.get("require_tests"):
|
||||
cfg_lines.append("Flag behavioral changes that don't add a test "
|
||||
"alongside (added as a `low` finding).")
|
||||
if eff.get("instructions"):
|
||||
cfg_lines.append("Instructions:\n" + str(eff["instructions"]).strip())
|
||||
if cfg_lines:
|
||||
parts.append("## Repo review config (.pr-review.json)\n" + "\n".join(cfg_lines))
|
||||
|
||||
if additional_context:
|
||||
# Repo-provided static background (architecture summary, module map,
|
||||
# conventions, glossary, …). Cached for the review; the agent reads
|
||||
# this ONCE per review and the prompt-cached prefix absorbs it on
|
||||
# later steps — much cheaper than re-discovering the same facts from
|
||||
# the source tree on every PR.
|
||||
parts.append(
|
||||
"## Repo-provided context (.pr-review.json:additional_context_urls "
|
||||
"+ PRAGENT_ADDITIONAL_CONTEXT_URL — cached per review)\n" + additional_context
|
||||
)
|
||||
|
||||
if prior_reviews:
|
||||
joined = "\n\n---\n\n".join(prior_reviews)
|
||||
if len(joined) > 4000:
|
||||
joined = joined[:4000] + "\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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -0,0 +1,459 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from . import pipeline
|
||||
from .pipeline import *
|
||||
|
||||
# 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
|
||||
CONFIG_MAX_PATTERNS_ITEMS = 16 # allow + deny separately, total 32 entries
|
||||
CONFIG_MAX_FINDINGS = 30
|
||||
CONFIG_MAX_STATIC_MESSAGE_CHARS = 400 # free-text banner, mirror of instructions
|
||||
|
||||
STYLES = frozenset(STYLE_DEFAULTS)
|
||||
SEVERITY_VALUES = frozenset(SEVERITIES)
|
||||
|
||||
|
||||
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;
|
||||
`patterns.allow` / `patterns.deny` each capped at CONFIG_MAX_PATTERNS_ITEMS
|
||||
of CONFIG_MAX_ITEM_CHARS.
|
||||
|
||||
Recognised keys (all optional):
|
||||
focus, exclude_paths, languages, instructions — text steer
|
||||
static_message ≤ CONFIG_MAX_STATIC_MESSAGE_CHARS — banner under header
|
||||
style strict|balanced|lenient — default: balanced
|
||||
severity_threshold low|medium|high|critical — default: per style
|
||||
max_findings 1..CONFIG_MAX_FINDINGS — default: per style
|
||||
exclude_tests bool — default: False
|
||||
require_tests bool — default: False
|
||||
patterns {allow:[…], deny:[…]} — post-filter globs
|
||||
model <key of cost_model.PRICES> — per-repo override
|
||||
cost_target <key of cost_model.PRICES> — see equivalent_cost
|
||||
additional_context_urls list[str] (≤ 8) — see fetch_additional_context
|
||||
"""
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
|
||||
def _str_list(v):
|
||||
if isinstance(v, list) and all(isinstance(x, str) for x in v):
|
||||
return [x[:CONFIG_MAX_ITEM_CHARS] for x in v[:CONFIG_MAX_LIST_ITEMS]]
|
||||
return None
|
||||
|
||||
out: dict = {}
|
||||
for k in ("focus", "exclude_paths", "languages"):
|
||||
s = _str_list(data.get(k))
|
||||
if s is not None:
|
||||
out[k] = s
|
||||
|
||||
instr = data.get("instructions")
|
||||
if isinstance(instr, str) and instr.strip():
|
||||
out["instructions"] = instr.strip()[:CONFIG_MAX_INSTRUCTIONS_CHARS]
|
||||
|
||||
sm = data.get("static_message")
|
||||
if isinstance(sm, str) and sm.strip():
|
||||
out["static_message"] = sm.strip()[:CONFIG_MAX_STATIC_MESSAGE_CHARS]
|
||||
|
||||
style = data.get("style")
|
||||
if isinstance(style, str) and style.strip().lower() in STYLES:
|
||||
out["style"] = style.strip().lower()
|
||||
|
||||
thresh = data.get("severity_threshold")
|
||||
if isinstance(thresh, str) and thresh.strip().lower() in SEVERITY_VALUES:
|
||||
out["severity_threshold"] = thresh.strip().lower()
|
||||
|
||||
mf = data.get("max_findings")
|
||||
if isinstance(mf, int) and not isinstance(mf, bool) and 1 <= mf <= CONFIG_MAX_FINDINGS:
|
||||
out["max_findings"] = mf
|
||||
elif isinstance(mf, str) and mf.strip().isdigit():
|
||||
n = int(mf.strip())
|
||||
if 1 <= n <= CONFIG_MAX_FINDINGS:
|
||||
out["max_findings"] = n
|
||||
|
||||
for bk in ("exclude_tests", "require_tests"):
|
||||
if isinstance(data.get(bk), bool):
|
||||
out[bk] = data[bk]
|
||||
|
||||
pat = data.get("patterns")
|
||||
if isinstance(pat, dict):
|
||||
allow = _str_list(pat.get("allow"))
|
||||
deny = _str_list(pat.get("deny"))
|
||||
patterns = {}
|
||||
if allow is not None:
|
||||
patterns["allow"] = allow[:CONFIG_MAX_PATTERNS_ITEMS]
|
||||
if deny is not None:
|
||||
patterns["deny"] = deny[:CONFIG_MAX_PATTERNS_ITEMS]
|
||||
if patterns:
|
||||
out["patterns"] = patterns
|
||||
|
||||
ct = data.get("cost_target")
|
||||
if isinstance(ct, str) and ct.strip():
|
||||
out["cost_target"] = ct.strip()
|
||||
|
||||
# Per-repo model override. Validated against cost_model.PRICES so the value
|
||||
# is usable both as the opencode subprocess ref and as the REVIEW_HEADER
|
||||
# label (see _resolve_display_model precedence). Unknown values are dropped
|
||||
# with a stderr pointer to the valid set — silently ignoring would mask
|
||||
# typos from repo admins.
|
||||
raw_model = data.get("model")
|
||||
if raw_model is not None:
|
||||
if isinstance(raw_model, str) and raw_model.strip():
|
||||
from cost_model import PRICES # lazy: ollama path dep-free
|
||||
candidate = raw_model.strip()
|
||||
if candidate in PRICES:
|
||||
out["model"] = candidate
|
||||
else:
|
||||
print(
|
||||
f"pragent: .pr-review.json:model={candidate!r} not in "
|
||||
f"cost_model.PRICES (valid: {', '.join(sorted(PRICES))}); "
|
||||
f"dropping",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
|
||||
acu = data.get("additional_context_urls")
|
||||
if isinstance(acu, list):
|
||||
urls: list[str] = []
|
||||
for x in acu:
|
||||
if isinstance(x, str):
|
||||
u = x.strip()
|
||||
if u:
|
||||
urls.append(u)
|
||||
if urls:
|
||||
# Cap is also enforced later by _resolve_additional_context_urls;
|
||||
# this just stops a 10k-entry file from making the config huge.
|
||||
out["additional_context_urls"] = urls[:8]
|
||||
|
||||
# Multi-lens reviewers roster. Absent / empty list = the 5-lens default
|
||||
# in pilot/opencode_review.py (security, docs, code-quality, tests, perf).
|
||||
# This is the cheap trigger: once the config declares `reviewers[]`, the
|
||||
# orchestrator spawns one opencode subprocess per lens in parallel. Set
|
||||
# to `[]` to opt out (single-primary fallback). Capped at 8.
|
||||
rev = _parse_reviewers_array(data.get("reviewers"))
|
||||
if rev is not None:
|
||||
out["reviewers"] = rev
|
||||
|
||||
# Triage (cheap pre-filter that picks a subset of lenses). Off by default
|
||||
# to keep the parse deterministic; the orchestrator's own default is
|
||||
# to enable it when `reviewers[]` is present.
|
||||
tr = _parse_triage_object(data.get("triage"))
|
||||
if tr is not None:
|
||||
out["triage"] = tr
|
||||
|
||||
# Repo-level kill-switch: `enabled: false` lets a maintainer pause the bot
|
||||
# for this repo without removing the file (handy during a flaky provider
|
||||
# outage). Always written so callers can do `cfg.get("enabled") is False`
|
||||
# without a separate default — the file itself is committed, so we treat
|
||||
# absent / wrong-type as an explicit off rather than as "config missing".
|
||||
en = data.get("enabled")
|
||||
out["enabled"] = en if isinstance(en, bool) else False
|
||||
|
||||
# Compare-against roster: list of `cost_model.PRICES` keys the render layer
|
||||
# uses to print equivalent-cost lines (one per key) for maintainer
|
||||
# budgeting. Unknown keys are dropped with a stderr line so a typo is loud.
|
||||
# Lazy import: `cost_model` has no dep on `ai_review`, and the ollama
|
||||
# fallback path never hits this branch — keep import-time cost low there.
|
||||
from cost_model import PRICES as _PRICES
|
||||
ca = data.get("compare_against")
|
||||
if isinstance(ca, list):
|
||||
cleaned: list[str] = []
|
||||
for x in ca:
|
||||
if isinstance(x, str) and x.strip() in _PRICES:
|
||||
cleaned.append(x.strip())
|
||||
elif isinstance(x, str):
|
||||
print(
|
||||
f"pragent: ignoring compare_against entry {x!r} "
|
||||
f"(not in cost_model.PRICES); valid: {', '.join(sorted(_PRICES))}",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
if cleaned:
|
||||
out["compare_against"] = cleaned[:12]
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _parse_reviewers_array(raw) -> list[dict] | None:
|
||||
"""Sanitize `.pr-review.json:reviewers[]` to a list of dicts.
|
||||
|
||||
Hard caps: 8 entries (default-reviewers.xml-bound), 200 chars per string
|
||||
field. Untyped / non-list → None (caller keeps the default). Fields we
|
||||
don't know about are dropped (no schema drift allowed).
|
||||
"""
|
||||
if not isinstance(raw, list):
|
||||
return None
|
||||
cap = 8
|
||||
out: list[dict] = []
|
||||
for entry in raw[:cap]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
spec: dict = {}
|
||||
rid = entry.get("id")
|
||||
if isinstance(rid, str) and rid.strip():
|
||||
cand = rid.strip()[:CONFIG_MAX_ITEM_CHARS]
|
||||
# Same id shape required by opencode_review.parse_reviewers_config:
|
||||
# kebab-case so it maps 1:1 to .opencode/agents/<id>.md
|
||||
import re as _re
|
||||
if _re.match(r"^[a-z0-9][a-z0-9-]{0,31}$", cand):
|
||||
spec["id"] = cand
|
||||
if not spec.get("id"):
|
||||
continue
|
||||
for sk in ("agent_file", "model"):
|
||||
sv = entry.get(sk)
|
||||
if isinstance(sv, str) and sv.strip():
|
||||
spec[sk] = sv.strip()[:CONFIG_MAX_ITEM_CHARS]
|
||||
sf = entry.get("severity_floor")
|
||||
if isinstance(sf, str) and sf.strip().lower() in SEVERITY_VALUES:
|
||||
spec["severity_floor"] = sf.strip().lower()
|
||||
mf = entry.get("max_findings")
|
||||
if isinstance(mf, int) and not isinstance(mf, bool) and 1 <= mf <= CONFIG_MAX_FINDINGS:
|
||||
spec["max_findings"] = mf
|
||||
act = entry.get("activation")
|
||||
if isinstance(act, str) and act.strip().lower() in ("auto", "always", "off"):
|
||||
spec["activation"] = act.strip().lower()
|
||||
skip = entry.get("skip_if_all_changed_paths")
|
||||
if isinstance(skip, str) and skip.strip():
|
||||
spec["skip_if_all_changed_paths"] = skip.strip()[:CONFIG_MAX_ITEM_CHARS]
|
||||
globs = entry.get("hotpath_globs")
|
||||
if isinstance(globs, list):
|
||||
cleaned = [g for g in globs if isinstance(g, str) and g.strip()]
|
||||
if cleaned:
|
||||
spec["hotpath_globs"] = [
|
||||
g.strip()[:CONFIG_MAX_ITEM_CHARS]
|
||||
for g in cleaned[:CONFIG_MAX_LIST_ITEMS]
|
||||
]
|
||||
out.append(spec)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_triage_object(raw) -> dict | None:
|
||||
"""Sanitize `.pr-review.json:triage` to a dict.
|
||||
|
||||
Returns `None` when absent. When the value is malformed (not an object),
|
||||
returns `{"enabled": False}` so a typo disables triage rather than
|
||||
silently making the orchestrator error.
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
return {"enabled": False}
|
||||
out: dict = {}
|
||||
if isinstance(raw.get("enabled"), bool):
|
||||
out["enabled"] = raw["enabled"]
|
||||
if isinstance(raw.get("model"), str) and raw["model"].strip():
|
||||
out["model"] = raw["model"].strip()[:CONFIG_MAX_ITEM_CHARS]
|
||||
ml = raw.get("max_lenses")
|
||||
if isinstance(ml, int) and not isinstance(ml, bool) and 1 <= ml <= 8:
|
||||
out["max_lenses"] = ml
|
||||
return out
|
||||
|
||||
|
||||
def effective_config(config: dict | None) -> dict:
|
||||
"""Apply STYLE_DEFAULTS for any field the config didn't pin.
|
||||
|
||||
Returns a NEW dict combining the user's `.pr-review.json` (if any) with the
|
||||
derived `max_findings` / `severity_threshold`. Style itself is preserved
|
||||
so downstream code can branch on it.
|
||||
"""
|
||||
style = (config or {}).get("style", "balanced")
|
||||
max_findings, severity_threshold = STYLE_DEFAULTS.get(style, STYLE_DEFAULTS["balanced"])
|
||||
out = dict(config or {})
|
||||
out.setdefault("style", style)
|
||||
out.setdefault("max_findings", max_findings)
|
||||
out.setdefault("severity_threshold", severity_threshold)
|
||||
return out
|
||||
|
||||
|
||||
_TEST_PATH_RE = re.compile(
|
||||
r"(?:^|/)("
|
||||
r"[^/]*[Tt]est\.[A-Za-z]+" # FooTest.java / foo_test.py
|
||||
r"|[^/]*\.[Tt]est\.[A-Za-z]+" # foo.Test.java
|
||||
r"|[^/]*_test\.py" # foo_test.py
|
||||
r"|test_[^/]*\.py" # test_foo.py
|
||||
r"|__tests__/[^/]+" # __tests__/foo.js
|
||||
r"|[^/]*\.spec\.[A-Za-z]+" # foo.spec.ts
|
||||
r")$"
|
||||
)
|
||||
|
||||
|
||||
def is_test_path(path: str) -> bool:
|
||||
"""Heuristic: is `path` a test file by name/path convention?
|
||||
|
||||
Conservative — false positives cost real findings; false negatives just
|
||||
produce one extra line in the summary. Patterns: `FooTest.java`,
|
||||
`foo_test.py`, `test_foo.py`, `__tests__/foo.js`, `foo.spec.ts`, anything
|
||||
ending in `.Test.java`.
|
||||
"""
|
||||
if not path:
|
||||
return False
|
||||
return bool(_TEST_PATH_RE.search(path))
|
||||
|
||||
|
||||
def _glob_to_regex(glob: str) -> re.Pattern:
|
||||
"""Translate a shell-style glob to a compiled regex.
|
||||
|
||||
Supports `*` (any chars except `/`), `**` (any chars including `/`),
|
||||
`?` (single non-`/` char). Other characters are escaped. Used by
|
||||
`apply_repo_config` to test `patterns.allow` / `patterns.deny` globs.
|
||||
"""
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(glob):
|
||||
c = glob[i]
|
||||
if c == "*":
|
||||
if i + 1 < len(glob) and glob[i + 1] == "*":
|
||||
out.append(".*")
|
||||
i += 2
|
||||
# swallow a following `/` so `**/x` and `x/**/y` behave
|
||||
if i < len(glob) and glob[i] == "/":
|
||||
i += 1
|
||||
continue
|
||||
out.append("[^/]*")
|
||||
elif c == "?":
|
||||
out.append("[^/]")
|
||||
else:
|
||||
out.append(re.escape(c))
|
||||
i += 1
|
||||
return re.compile("^" + "".join(out) + "$")
|
||||
|
||||
|
||||
def apply_repo_config(
|
||||
findings: list[dict],
|
||||
config: dict | None,
|
||||
changed_paths: list[str] | None = None,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""Filter + cap findings per `.pr-review.json` rules. Returns (kept, dropped).
|
||||
|
||||
Filters applied (in order):
|
||||
1. `exclude_tests` + test-path heuristic → drop test files
|
||||
2. `exclude_paths` glob match → drop matched paths
|
||||
3. `patterns.deny` glob match → drop matched paths
|
||||
4. `patterns.allow` (if non-empty) → keep ONLY matched paths
|
||||
5. `severity_threshold` → drop below threshold
|
||||
6. `max_findings` → keep first N (highest-severity-first)
|
||||
7. `require_tests` → append a low-severity finding
|
||||
if changed paths include non-test files but no test files changed
|
||||
alongside them (caller passes `changed_paths` from the brief).
|
||||
"""
|
||||
eff = effective_config(config)
|
||||
keep: list[dict] = []
|
||||
drop: list[dict] = []
|
||||
deny_globs = [_glob_to_regex(g) for g in (eff.get("patterns", {}) or {}).get("deny", [])]
|
||||
allow_globs = [_glob_to_regex(g) for g in (eff.get("patterns", {}) or {}).get("allow", [])]
|
||||
deny_path_globs = [_glob_to_regex(g) for g in eff.get("exclude_paths", [])]
|
||||
threshold_rank = SEVERITY_RANK[eff["severity_threshold"]]
|
||||
|
||||
for f in findings:
|
||||
path = f.get("path", "")
|
||||
if eff.get("exclude_tests") and is_test_path(path):
|
||||
drop.append(f); continue
|
||||
if any(rx.search(path) for rx in deny_path_globs):
|
||||
drop.append(f); continue
|
||||
if any(rx.search(path) for rx in deny_globs):
|
||||
drop.append(f); continue
|
||||
if allow_globs and not any(rx.search(path) for rx in allow_globs):
|
||||
drop.append(f); continue
|
||||
sev_rank = SEVERITY_RANK.get(f.get("severity", "low"), 0)
|
||||
if sev_rank < threshold_rank:
|
||||
drop.append(f); continue
|
||||
keep.append(f)
|
||||
|
||||
cap = eff["max_findings"]
|
||||
if len(keep) > cap:
|
||||
dropped = keep[cap:]
|
||||
keep = keep[:cap]
|
||||
drop.extend(dropped)
|
||||
|
||||
if eff.get("require_tests") and changed_paths is not None:
|
||||
non_test = [p for p in changed_paths if not is_test_path(p)]
|
||||
any_test = any(is_test_path(p) for p in changed_paths)
|
||||
if non_test and not any_test:
|
||||
keep.append({
|
||||
"severity": "low",
|
||||
"path": non_test[0],
|
||||
"line": 1,
|
||||
"problem": "no test file changed alongside this behavioral change (require_tests=true)",
|
||||
"fix": "add a unit test exercising the changed branch",
|
||||
"suggestion": "",
|
||||
"reference": "",
|
||||
"_config_synthetic": True,
|
||||
})
|
||||
|
||||
return keep, drop
|
||||
|
||||
|
||||
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 pipeline._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 = pipeline._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]
|
||||
|
||||
|
||||
def compact_prior_reviews(prior_bodies: list[str]) -> list[str]:
|
||||
"""Squeeze prior review bodies down to just the finding bullets.
|
||||
|
||||
Each prior review's prose ("this PR adds eval() — risky") is noise when the
|
||||
model already has the diff; the only thing it needs to *not repeat* is what
|
||||
was already flagged. We extract lines matching `-\\s*\\*\\*[SEV]\\*\\*`
|
||||
plus their directly-attached location reference (so `[CRITICAL]` stays
|
||||
anchored to `path:line`), drop the rest, and return one bullet-list per
|
||||
prior review. A prior review that had no parseable findings becomes an
|
||||
empty string and is dropped.
|
||||
|
||||
Local import keeps the ollama path dep-free (extract_finding_bullets lives
|
||||
in pilot/diff_compress.py).
|
||||
"""
|
||||
from diff_compress import extract_finding_bullets
|
||||
out = []
|
||||
for body in prior_bodies or []:
|
||||
bullets = extract_finding_bullets(body)
|
||||
if bullets:
|
||||
out.append("\n".join(bullets))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -0,0 +1,764 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from . import pipeline
|
||||
from .pipeline import *
|
||||
from .pipeline import _CONFIDENCE_BADGE, REVIEW_HEADER, SHA_MARKER
|
||||
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# How many raw findings the last `parse_review_output` / `parse_findings` call
|
||||
# rejected for an unusable path/line. A side channel rather than a return value
|
||||
# because both parsers already return fixed-width tuples that several callers
|
||||
# and their tests unpack positionally; widening them to carry a telemetry
|
||||
# number would be a breaking change for a fail-open signal.
|
||||
_LAST_PARSE_DROPPED: dict[str, int] = {"n": 0}
|
||||
|
||||
|
||||
def last_parse_dropped() -> int:
|
||||
"""Findings the last parse discarded. Read it immediately after parsing."""
|
||||
return int(_LAST_PARSE_DROPPED.get("n") or 0)
|
||||
|
||||
|
||||
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:
|
||||
r"""Return the substring of the last JSON object/array in text, or None.
|
||||
|
||||
The pragent agent emits ```json fences around its final block, but real
|
||||
outputs drift:
|
||||
* the fence contains nested objects (regex ``\{.*?\}`` only matches the
|
||||
first ``}``, truncating the JSON — the parser then sees
|
||||
``json.JSONDecodeError``);
|
||||
* the fence is missing or unterminated, but a balanced JSON object sits
|
||||
in the prose tail;
|
||||
* the agent emits a bare array (findings only, no summary wrapper).
|
||||
|
||||
Strategy:
|
||||
1. Find each fenced block, take the last. Inside it, walk a balanced
|
||||
``{...}``/``[...]`` scanner (not a regex) so nested structures survive.
|
||||
2. Fall back to a balanced scanner over the whole text, picking the LAST
|
||||
balanced object/array (the agent writes its conclusion last).
|
||||
"""
|
||||
s = text or ""
|
||||
if not s:
|
||||
return None
|
||||
# 1. Fenced blocks: take the last ```json ... ``` or ``` ... ``` region.
|
||||
fences = list(re.finditer(r"```(?:json)?\n", s))
|
||||
for m in reversed(fences):
|
||||
start = m.end()
|
||||
# Find the matching closing fence.
|
||||
end = s.find("```", start)
|
||||
if end < 0:
|
||||
# Unterminated fence — try to salvage the balanced object inside.
|
||||
end = len(s)
|
||||
inner = s[start:end].strip()
|
||||
obj = _balanced_json_substring(inner)
|
||||
if obj is not None:
|
||||
return obj
|
||||
# 2. No (parseable) fence — scan the whole text for the LAST balanced
|
||||
# object/array. The agent's conclusion is at the tail.
|
||||
return _last_balanced_json(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.
|
||||
|
||||
Also accepts a bare JSON array as the outer value: ``[{...}, {...}]`` —
|
||||
some agents skip the ``{"summary":..., "findings":[...]}`` wrapper.
|
||||
"""
|
||||
_LAST_PARSE_DROPPED["n"] = 0
|
||||
data = _parse_json_tolerant(text)
|
||||
if isinstance(data, dict):
|
||||
findings = data.get("findings")
|
||||
elif isinstance(data, list):
|
||||
findings = data
|
||||
else:
|
||||
return []
|
||||
if not isinstance(findings, list):
|
||||
return []
|
||||
out = []
|
||||
for f in findings:
|
||||
n = _normalize_finding(f)
|
||||
if n is not None:
|
||||
out.append(n)
|
||||
_LAST_PARSE_DROPPED["n"] = len(findings) - len(out)
|
||||
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], list[str], list[str], list[str], str, str]:
|
||||
"""Parse the opengine's stdout into a 7-tuple:
|
||||
(summary, findings, summary_changes, risks,
|
||||
walkthrough, risk_verdict, test_coverage)
|
||||
|
||||
Accepts `{"summary": "...", "summary_changes": [...], "risks": [...],
|
||||
"walkthrough": [...], "risk_verdict": "...", "test_coverage": "...",
|
||||
"findings": [...]}` (the opencode pragent agent), the legacy 4-field
|
||||
shape, or a bare `[...]` of finding dicts. The three new fields
|
||||
(`walkthrough`, `risk_verdict`, `test_coverage`) default to empty
|
||||
list / empty strings when absent — older outputs and the bare-array
|
||||
shape stay backward compatible.
|
||||
|
||||
Uses the LAST fenced block (the pragent agent emits JSON as the final
|
||||
block), with a tolerant fallback that scans for the last balanced
|
||||
object/array in the prose tail. Never raises.
|
||||
"""
|
||||
_LAST_PARSE_DROPPED["n"] = 0
|
||||
blob = _last_json_block(text)
|
||||
if blob is None:
|
||||
return "", [], [], [], [], "", ""
|
||||
try:
|
||||
data = json.loads(blob)
|
||||
except json.JSONDecodeError:
|
||||
return "", [], [], [], [], "", ""
|
||||
summary = ""
|
||||
summary_changes: list[str] = []
|
||||
risks: list[str] = []
|
||||
walkthrough: list[str] = []
|
||||
risk_verdict = ""
|
||||
test_coverage = ""
|
||||
findings_raw = None
|
||||
if isinstance(data, dict):
|
||||
summary = str(data.get("summary", "") or "").strip()
|
||||
summary_changes = _string_list(data.get("summary_changes"))
|
||||
risks = _string_list(data.get("risks"))
|
||||
walkthrough = _string_list(data.get("walkthrough"))
|
||||
risk_verdict = str(data.get("risk_verdict", "") or "").strip()
|
||||
test_coverage = str(data.get("test_coverage", "") or "").strip()
|
||||
findings_raw = data.get("findings")
|
||||
elif isinstance(data, list):
|
||||
# Bare array: each item is a finding; no summary/sections.
|
||||
findings_raw = data
|
||||
else:
|
||||
return "", [], [], [], [], "", ""
|
||||
out = []
|
||||
if isinstance(findings_raw, list):
|
||||
for f in findings_raw:
|
||||
n = _normalize_finding(f)
|
||||
if n is not None:
|
||||
out.append(n)
|
||||
# A model that emits findings at unusable locations is indistinguishable
|
||||
# from one that found nothing, because both end up with an empty `out`.
|
||||
# Stash the delta so the caller can score it (see `eval_scores`).
|
||||
_LAST_PARSE_DROPPED["n"] = len(findings_raw) - len(out)
|
||||
else:
|
||||
_LAST_PARSE_DROPPED["n"] = 0
|
||||
return summary, out, summary_changes, risks, walkthrough, risk_verdict, test_coverage
|
||||
|
||||
|
||||
def _string_list(value) -> list[str]:
|
||||
"""Coerce a JSON value into a list of non-empty strings.
|
||||
|
||||
Accepts a list of strings, a single string (split on lines/bullets), or
|
||||
anything else (returns []). Used for `summary_changes` and `risks`,
|
||||
which some agents emit as one big string instead of a list.
|
||||
"""
|
||||
if isinstance(value, list):
|
||||
return [str(v).strip() for v in value if str(v).strip()]
|
||||
if isinstance(value, str):
|
||||
s = value.strip()
|
||||
if not s:
|
||||
return []
|
||||
# Split on newlines OR on lines that start with "- " / "* " (markdown
|
||||
# bullets). Strip the bullet markers.
|
||||
out: list[str] = []
|
||||
for line in s.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line[:2] in ("- ", "* "):
|
||||
line = line[2:].strip()
|
||||
if line:
|
||||
out.append(line)
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def _parse_json_tolerant(text: str) -> dict | list | None:
|
||||
"""Parse a JSON object/array from text: try the last fenced block, then a
|
||||
direct parse, then the first balanced object. Returns None on any failure.
|
||||
Accepts both ``{...}`` (the pragent schema) and bare ``[...]`` arrays
|
||||
(agents that skip the wrapper)."""
|
||||
if not text:
|
||||
return None
|
||||
blob = _last_json_block(text)
|
||||
if blob is not None:
|
||||
try:
|
||||
d = json.loads(blob)
|
||||
if isinstance(d, (dict, list)):
|
||||
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, list)):
|
||||
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, list)):
|
||||
return d
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# Last resort: the JSON lives at the tail of the prose with no fence.
|
||||
# Walk the whole text for the last balanced object/array.
|
||||
last = _last_balanced_json(text)
|
||||
if last is not None:
|
||||
try:
|
||||
d = json.loads(last)
|
||||
if isinstance(d, (dict, list)):
|
||||
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
|
||||
end = _scan_balanced(s, start, "{", "}")
|
||||
if end is None:
|
||||
return None
|
||||
return s[start:end + 1]
|
||||
|
||||
|
||||
def _last_balanced_json(s: str) -> str | None:
|
||||
"""Return the substring of the LAST balanced ``{...}`` or ``[...]`` in s.
|
||||
|
||||
Used when the agent emits no fence: the JSON lives in the prose tail.
|
||||
Picks whichever closer (object or array) appears latest in the text.
|
||||
"""
|
||||
if not s:
|
||||
return None
|
||||
last_obj = _find_last_close(s, "{", "}")
|
||||
last_arr = _find_last_close(s, "[", "]")
|
||||
candidates = []
|
||||
if last_obj is not None:
|
||||
candidates.append(last_obj)
|
||||
if last_arr is not None:
|
||||
candidates.append(last_arr)
|
||||
if not candidates:
|
||||
return None
|
||||
end, opener, start = max(candidates, key=lambda t: t[0])
|
||||
return s[start:end + 1]
|
||||
|
||||
|
||||
def _balanced_json_substring(s: str) -> str | None:
|
||||
"""Return the first balanced ``{...}`` or ``[...]`` substring in ``s``.
|
||||
|
||||
Skips past leading whitespace/non-JSON and returns the full balanced
|
||||
extent (handles nested objects/arrays and string literals with braces).
|
||||
"""
|
||||
if not s:
|
||||
return None
|
||||
# Try object first; the pragent schema is an object on the outer level.
|
||||
for i, c in enumerate(s):
|
||||
if c == "{":
|
||||
end = _scan_balanced(s, i, "{", "}")
|
||||
if end is not None:
|
||||
return s[i:end + 1]
|
||||
break
|
||||
if c == "[":
|
||||
end = _scan_balanced(s, i, "[", "]")
|
||||
if end is not None:
|
||||
return s[i:end + 1]
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def _scan_balanced(s: str, start: int, opener: str, closer: str) -> int | None:
|
||||
"""Return the index of the matching ``closer`` for ``s[start] == opener``.
|
||||
|
||||
Tracks string literals (with ``\\`` escapes) so braces inside strings don't
|
||||
fool the depth counter. Returns None if no balance is reached.
|
||||
"""
|
||||
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 == opener:
|
||||
depth += 1
|
||||
elif c == closer:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def _find_last_close(s: str, opener: str, closer: str) -> tuple[int, str, int] | None:
|
||||
"""Walk ``s`` backwards from the last ``closer`` to find its matching opener.
|
||||
|
||||
Returns ``(close_idx, opener_char, open_idx)`` for the rightmost balanced
|
||||
structure, or None if no pair exists.
|
||||
"""
|
||||
# Find the last `closer` candidate.
|
||||
last = s.rfind(closer)
|
||||
while last >= 0:
|
||||
# Walk left, tracking depth from the perspective of the opener.
|
||||
depth = 1
|
||||
in_str = False
|
||||
esc = False
|
||||
for j in range(last - 1, -1, -1):
|
||||
c = s[j]
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif c == "\\":
|
||||
esc = True
|
||||
elif c == '"':
|
||||
in_str = False
|
||||
continue
|
||||
if c == '"':
|
||||
# Approximation: we don't track quotes perfectly walking
|
||||
# backwards, but strings in agent output are short and rare.
|
||||
in_str = not in_str
|
||||
elif c == closer:
|
||||
depth += 1
|
||||
elif c == opener:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return (last, opener, j)
|
||||
last = s.rfind(closer, 0, last)
|
||||
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, "")
|
||||
|
||||
|
||||
_SEVERITY_EMOJI = {
|
||||
"critical": "🔴",
|
||||
"high": "🔴",
|
||||
"medium": "🟡",
|
||||
"low": "🔵",
|
||||
"trivial": "⚪",
|
||||
"info": "⚪",
|
||||
"nit": "⚪",
|
||||
}
|
||||
|
||||
# Severities whose own name is rendered verbatim (uppercased) in the badge.
|
||||
# Anything outside this set falls back to "INFO" so the badge label stays
|
||||
# a clean short token regardless of what the model emits.
|
||||
_BADGED_SEVERITY_LABELS = frozenset({
|
||||
"critical", "high", "medium", "low", "trivial", "info", "nit",
|
||||
})
|
||||
|
||||
|
||||
def _severity_badge(severity: str) -> str:
|
||||
"""Render the severity as emoji + uppercase label (e.g. ``🔴 [HIGH]``)."""
|
||||
sev = (severity or "").lower()
|
||||
emoji = _SEVERITY_EMOJI.get(sev, "⚪")
|
||||
label = sev.upper() if sev in _BADGED_SEVERITY_LABELS else "INFO"
|
||||
return f"{emoji} [{label}]"
|
||||
|
||||
|
||||
def _format_reference(ref: str) -> str:
|
||||
"""Render a reference URL as a clean Markdown hyperlink.
|
||||
|
||||
``"https://example.com/x"`` → ``"[example.com/x](https://example.com/x)"``.
|
||||
Accepts the bare URL form so older findings still render readably; drops
|
||||
anything that doesn't look like a URL rather than embedding raw text in
|
||||
parens (the spec says: never print raw URLs).
|
||||
"""
|
||||
ref = (ref or "").strip()
|
||||
if not ref:
|
||||
return ""
|
||||
if not (ref.startswith("http://") or ref.startswith("https://")):
|
||||
# Non-URL text (e.g. a CVE id, a doc title). Render as plain text —
|
||||
# `[CVE-2024-1](CVE-2024-1)` would render as a broken *relative* link
|
||||
# in Gitea, which is worse than no link at all.
|
||||
return ref
|
||||
# Strip the scheme + www. for the visible label so the link text is short.
|
||||
visible = ref
|
||||
for prefix in ("https://", "http://"):
|
||||
if visible.startswith(prefix):
|
||||
visible = visible[len(prefix):]
|
||||
break
|
||||
if visible.startswith("www."):
|
||||
visible = visible[4:]
|
||||
# Drop trailing slash + truncate any path noise past 60 chars.
|
||||
visible = visible.rstrip("/")
|
||||
if len(visible) > 60:
|
||||
visible = visible[:57] + "…"
|
||||
return f"[{visible}]({ref})"
|
||||
|
||||
|
||||
def inline_comment_body(f: dict) -> str:
|
||||
"""Render one finding as a positional review-comment body.
|
||||
|
||||
Shape:
|
||||
* Severity badge with emoji (🔴 HIGH / 🟡 MEDIUM / 🔵 LOW / ⚪ INFO).
|
||||
* 1–2 short paragraphs: ``problem`` + optional ``fix``.
|
||||
* ``suggestion`` block (Gitea/Forgejo apply-on-click) when the model
|
||||
produced replacement code. Language-tagged fences are reserved for
|
||||
cross-file patterns the suggestion block can't carry.
|
||||
* Reference as a Markdown hyperlink (``[label](url)``) — never a raw URL.
|
||||
* Per-comment attributed output tokens (`🪙 ~N tok (P% · attributed)`)
|
||||
when the caller passed `compute_attribution` data. Hidden when the
|
||||
finding has no attributed tokens (e.g. legacy callers / ollama path
|
||||
without usage metering).
|
||||
"""
|
||||
badge = _severity_badge(f.get("severity", "medium"))
|
||||
body = f"{badge} {f.get('problem', '').strip()}"
|
||||
fix = (f.get("fix") or "").strip()
|
||||
if fix:
|
||||
body += f"\n\n**Fix:** {fix}"
|
||||
suggestion = (f.get("suggestion") or "").strip()
|
||||
if suggestion:
|
||||
# `suggestion` fence is the standard one-click-apply block in
|
||||
# Gitea/Forgejo/GitHub. The agent's replacement lines must already be
|
||||
# indented as in the target file.
|
||||
body += f"\n\n```suggestion\n{suggestion}\n```"
|
||||
ref_md = _format_reference(f.get("reference", ""))
|
||||
if ref_md:
|
||||
body += f"\n\n🔗 **Reference:** {ref_md}"
|
||||
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🪙 ~{pipeline.fmt_tokens(tok)} tok ({pct:.0f}% · attributed output)"
|
||||
return body
|
||||
|
||||
|
||||
def summary_bullets(findings: list[dict]) -> str:
|
||||
"""Render unanchored findings as PR-level bullets.
|
||||
|
||||
Used for findings that couldn't be anchored to a post-change line (no
|
||||
inline comment posted). Each bullet carries severity, location, problem,
|
||||
fix, and a Markdown-linked reference.
|
||||
"""
|
||||
lines = []
|
||||
for f in findings:
|
||||
loc = f"{f['path']}:{f['line']}" if f["line"] else f["path"]
|
||||
badge = _severity_badge(f.get("severity", "medium"))
|
||||
problem = f.get("problem", "").strip()
|
||||
body = f"- {badge} `{loc}` — {problem}"
|
||||
fix = (f.get("fix") or "").strip()
|
||||
if fix:
|
||||
body += f"\n - **Fix:** {fix}"
|
||||
ref_md = _format_reference(f.get("reference", ""))
|
||||
if ref_md:
|
||||
body += f"\n - 🔗 **Reference:** {ref_md}"
|
||||
lines.append(body)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def findings_table(findings: list[dict]) -> str:
|
||||
"""Render ALL findings as a Markdown table for the PR-level comment.
|
||||
|
||||
Columns: severity emoji, location (path:line), and a one-line summary.
|
||||
Findings with empty location collapse to just the severity + summary.
|
||||
"""
|
||||
if not findings:
|
||||
return ""
|
||||
header = "| Severity | Location | Finding |\n|---|---|---|"
|
||||
rows = []
|
||||
for f in findings:
|
||||
badge = _severity_badge(f.get("severity", "medium"))
|
||||
path = (f.get("path") or "").strip()
|
||||
line = f.get("line")
|
||||
loc = f"`{path}:{line}`" if line else (f"`{path}`" if path else "_(no location)_")
|
||||
problem = (f.get("problem") or "").strip()
|
||||
# Escape pipes inside the finding text so the table stays valid.
|
||||
problem_esc = problem.replace("|", "\\|").replace("\n", " ")
|
||||
rows.append(f"| {badge} | {loc} | {problem_esc} |")
|
||||
return "\n".join([header, *rows])
|
||||
|
||||
|
||||
def _render_collapsible_usage(usage: dict | None, model: str, config: dict | None) -> str:
|
||||
"""Render the telemetry as a collapsible ``<details>`` block.
|
||||
|
||||
Empty string when `usage` is None. The equivalent-cost table is the
|
||||
operator's budgeting signal — the pilot runs on a free tier, so the
|
||||
`actual` line is $0.00; the table shows what the same measured tokens
|
||||
would bill on mainstream paid APIs (configurable via `compare_against`,
|
||||
defaulting to ``DEFAULT_COMPARE_AGAINST``). The row matching `cost_target`
|
||||
is bolded so the price target stands out. The whole table is omitted when
|
||||
every row would be $0 (no work done). The `actual` parenthetical clause
|
||||
reflects the *actually-routed* model (`model` arg, resolved by caller from
|
||||
`OPENCODE_MODEL` env or `headroom/{OLLAMA_MODEL}`) — cost == 0 → "free
|
||||
tier", nonzero → "billed".
|
||||
|
||||
"""
|
||||
if not usage:
|
||||
return ""
|
||||
dur = usage.get("duration_s")
|
||||
dur_s = f"{dur}s" if dur is not None else "?"
|
||||
actual = usage.get("cost") or 0.0
|
||||
actual_s = f"${actual:.4f}" if actual else "$0.00"
|
||||
actual_note = f" ({model} — {'free tier' if not actual else 'billed'})"
|
||||
cost_target, price_err = pipeline._resolve_price_target(config)
|
||||
if price_err:
|
||||
# Surface config typos loudly but do not pollute the posted summary
|
||||
# body — typos at the table-row level would render as English
|
||||
# mid-table and look like a model error.
|
||||
print(f"pragent: {price_err}", file=sys.stderr, flush=True)
|
||||
# Lazy: cost_model has no dep on ai_review, and the ollama path
|
||||
# never reaches this branch.
|
||||
from cost_model import PRICES as _PRICES
|
||||
cfg = config or {}
|
||||
compare: list[str] = list(cfg.get("compare_against") or DEFAULT_COMPARE_AGAINST)
|
||||
# Always include the resolved cost_target (env + config), even when the
|
||||
# operator pinned a different `compare_against` roster — the price target
|
||||
# row is the one maintainers eyeball against. Skip silently if the key
|
||||
# isn't a known Price (e.g. a typo that slipped past stderr earlier).
|
||||
if cost_target in _PRICES and cost_target not in compare:
|
||||
compare.append(cost_target)
|
||||
eq_rows: list[str] = []
|
||||
for key in compare:
|
||||
if key not in _PRICES:
|
||||
continue
|
||||
c = pipeline.equivalent_cost(usage, key)
|
||||
if c <= 0:
|
||||
continue
|
||||
label = _PRICES[key].name
|
||||
cost_str = f"${c:.4f}" if c < 0.01 else f"${c:.2f}"
|
||||
bold = "**" if key == cost_target else ""
|
||||
eq_rows.append(f"| {bold}{label}{bold} | {cost_str} |")
|
||||
|
||||
in_tok = usage.get("input", 0)
|
||||
out_tok = usage.get("output", 0)
|
||||
reason_tok = usage.get("reasoning", 0)
|
||||
cache_r = usage.get("cache_read", 0)
|
||||
cache_w = usage.get("cache_write", 0)
|
||||
total = usage.get("total", 0)
|
||||
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 output is *attributed* (one model pass "
|
||||
"produces all findings; output split by each finding's body weight)."
|
||||
)
|
||||
lines = [
|
||||
"<details>",
|
||||
"<summary>🔋 AI Usage & Run Details</summary>",
|
||||
"",
|
||||
f"- **Model / Engine**: `{model}` · opencode · {usage.get('steps', 0)} steps · {dur_s}",
|
||||
f"- **Total Tokens**: {pipeline.fmt_tokens(in_tok)} in / {pipeline.fmt_tokens(out_tok)} out "
|
||||
f"({pipeline.fmt_tokens(reason_tok)} reasoning, cache {pipeline.fmt_tokens(cache_r)} read / "
|
||||
f"{pipeline.fmt_tokens(cache_w)} write, {pipeline.fmt_tokens(total)} total)",
|
||||
f"- **Actual**: {actual_s}{actual_note}",
|
||||
f"- **Scope**: {scope}",
|
||||
]
|
||||
if eq_rows:
|
||||
lines.append("")
|
||||
lines.append("- **Equivalent cost on paid providers** (this run's tokens):")
|
||||
lines.append("")
|
||||
lines.append("| Provider | Cost |")
|
||||
lines.append("|---|---:|")
|
||||
lines.extend(eq_rows)
|
||||
# Multi-lens fan-out: surface the lens roster + summed steps so the user
|
||||
# can see which lenses contributed (and that triage didn't drop them all).
|
||||
lenses = usage.get("lenses")
|
||||
if lenses:
|
||||
ls = usage.get("lens_steps", usage.get("steps", 0))
|
||||
lines.append(
|
||||
f"- **Lenses**: {', '.join(f'`{x}`' for x in lenses)} "
|
||||
f"({len(lenses)} parallel subprocesses, {ls} summed steps)"
|
||||
)
|
||||
lines += ["", "</details>"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -0,0 +1,437 @@
|
||||
#!/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}` · Merge confidence: {confidence}"
|
||||
# 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}) -->")
|
||||
|
||||
SEVERITIES = ("critical", "high", "medium", "low", "trivial", "info")
|
||||
# Severity rank — higher = more severe. Used by `apply_repo_config` to drop
|
||||
# findings below `severity_threshold`. critical=4, high=3, medium=2, low=1,
|
||||
# trivial=0, info=-1.
|
||||
SEVERITY_RANK = {"info": -1, "trivial": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
|
||||
REPO_CONFIG_FILE = ".pr-review.json"
|
||||
|
||||
# Style → (default max_findings, default severity_threshold). Strict is
|
||||
# terse/high-signal; lenient shows everything; balanced is the default for
|
||||
# unconfigured repos. Repo `.pr-review.json` overrides per-field.
|
||||
STYLE_DEFAULTS: dict[str, tuple[int, str]] = {
|
||||
"strict": (5, "high"),
|
||||
"balanced": (12, "medium"),
|
||||
"lenient": (15, "low"),
|
||||
}
|
||||
|
||||
# Default provider to compare against in the usage section. The pilot runs on
|
||||
# headroom/glm-5.2:cloud at $0/MTok, so the actual line shows $0.00 — but the
|
||||
# equivalent provider line lets a maintainer see what they would have paid on
|
||||
# Claude/GPT for the same measured tokens. Override with PRAGENT_PRICE_TARGET
|
||||
# (env) or `.pr-review.json:cost_target` (per repo).
|
||||
DEFAULT_PRICE_TARGET = "claude-sonnet-5"
|
||||
|
||||
# Default roster of paid providers shown in the equivalent-cost table when
|
||||
# `.pr-review.json` does not pin `compare_against`. The pilot is free-tier only,
|
||||
# so this list is the operator's budgeting signal — it answers "what would this
|
||||
# have cost on a mainstream paid API?". Override per-repo via
|
||||
# `.pr-review.json:compare_against` (capped at 12 entries; unknown keys are
|
||||
# dropped with a stderr line at parse time).
|
||||
DEFAULT_COMPARE_AGAINST = ("claude-sonnet-5", "gpt-5", "gemini-2.5-pro", "grok-4.5")
|
||||
|
||||
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|trivial|info",
|
||||
"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>"
|
||||
}
|
||||
],
|
||||
"walkthrough": ["2-6 short bullets, file- or change-grouped, plain prose"],
|
||||
"risk_verdict": "Low|Medium|High|Critical risk: <one-line concrete reason>",
|
||||
"test_coverage": "Tests added" | "Tests changed" | "No tests for behavioral change" | "No test files in repo"
|
||||
}
|
||||
|
||||
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).
|
||||
- `walkthrough`: 2-6 short bullets, file- or change-grouped, plain prose.
|
||||
Default to `[]` when the diff is trivial. Backward compatible: parsers
|
||||
default to `[]` if absent.
|
||||
- `risk_verdict`: exactly one line. Lead with "Low|Medium|High|Critical risk:"
|
||||
followed by a concrete reason. Default to `""` when not applicable.
|
||||
Backward compatible: parsers default to `""` if absent.
|
||||
- `test_coverage`: short string. One of "Tests added" / "Tests changed" /
|
||||
"No tests for behavioral change" / "No test files in repo". Default to `""`
|
||||
when not applicable. Backward compatible: parsers default to `""` if absent.
|
||||
- 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."""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared render constant retained here for compatibility with the extracted
|
||||
# modules and existing callers.
|
||||
_CONFIDENCE_BADGE = {5: "🟢", 4: "🟢", 3: "🟡", 2: "🟠", 1: "🔴"}
|
||||
|
||||
# Internal modules provide pure transforms and adapters; this file retains
|
||||
# the orchestration entry point and backwards-compatible symbols.
|
||||
from . import adapters as _adapters
|
||||
from . import analysis as _analysis
|
||||
from . import configuration as _configuration
|
||||
from . import output as _output
|
||||
|
||||
for _module in (_analysis, _output, _configuration, _adapters):
|
||||
globals().update({
|
||||
_name: _value
|
||||
for _name, _value in vars(_module).items()
|
||||
if not _name.startswith("__")
|
||||
})
|
||||
|
||||
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,
|
||||
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".
|
||||
|
||||
The opencode engine's measured token/cost usage is always rendered as a
|
||||
`## 🔋 AI usage` section on the review body and an attributed `🪙 ~N tok`
|
||||
line on each inline comment when usage data is available (i.e. when the
|
||||
opencode subprocess returned a `usage` dict). No-op on the ollama fallback
|
||||
(no usage available — `usage` is None).
|
||||
|
||||
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:
|
||||
# Pre-compute a *fallback* display name for the early-exit paths
|
||||
# (already-reviewed dedupe skip, no-diff-content). We re-resolve
|
||||
# properly after `.pr-review.json` is loaded further down — that
|
||||
# version honours `OPENCODE_MODEL` env > `.pr-review.json:model` >
|
||||
# this fallback.
|
||||
display_model = f"headroom/{model}"
|
||||
|
||||
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
|
||||
|
||||
raw_diff, _truncated, _orig = fetch_pr_diff(api, repo, index, token, max_chars)
|
||||
if not raw_diff.strip():
|
||||
post_review(api, repo, index, token, format_review_body("No diff content to review.", display_model, sha))
|
||||
return True
|
||||
|
||||
config = fetch_repo_config(api, repo, token, ref=base_ref)
|
||||
prior = compact_prior_reviews(prior_review_bodies(reviews, sha))
|
||||
|
||||
# Re-resolve display_model now that .pr-review.json is available —
|
||||
# per-repo override (`.pr-review.json:model`) takes precedence over
|
||||
# the bare OLLAMA_MODEL fallback, with OPENCODE_MODEL env still
|
||||
# winning above both (see `_resolve_display_model`).
|
||||
display_model = _resolve_display_model(model, config)
|
||||
|
||||
# Trim the diff to +/- hunks plus a narrow context window. The agent
|
||||
# resends the brief prefix every step, so a 25k-char diff becomes
|
||||
# 25k × 30-step × cached-after-step-1 = hundreds of thousands of input
|
||||
# tokens. Default context=1: enough for the reviewer to see what an
|
||||
# added line is replacing; the full file is on disk in the workdir
|
||||
# anyway, so anything more is reading the diff twice. Tunable via
|
||||
# PRAGENT_DIFF_CONTEXT (0 = +/- only; -1 = disable compression).
|
||||
from diff_compress import compress_diff
|
||||
ctx = _int_env("PRAGENT_DIFF_CONTEXT", 1)
|
||||
if ctx < 0:
|
||||
diff = raw_diff
|
||||
compression_note = ""
|
||||
else:
|
||||
diff, orig_chars, kept_chars = compress_diff(raw_diff, context=ctx)
|
||||
if kept_chars < orig_chars:
|
||||
compression_note = (
|
||||
f"\n\n> _diff compressed: {orig_chars:,} → {kept_chars:,} chars "
|
||||
f"(context={ctx}; PRAGENT_DIFF_CONTEXT to tune)_"
|
||||
)
|
||||
else:
|
||||
compression_note = ""
|
||||
|
||||
engine = os.environ.get("PRAGENT_ENGINE", "opencode").strip().lower()
|
||||
review_summary = ""
|
||||
# Static repo-provided context (architecture summary, module map, …)
|
||||
# fetched once from `additional_context_urls` (env + .pr-review.json).
|
||||
# Cheap, cached, capped — see fetch_additional_context.
|
||||
additional_context = fetch_additional_context(_resolve_additional_context_urls(config))
|
||||
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
|
||||
# Reuse the display_model resolved above for the subprocess — same
|
||||
# provider-prefixed ref goes to the engine and into the review body.
|
||||
oc_model = display_model
|
||||
# Multi-lens fan-out: when the repo declared `reviewers[]` (or the
|
||||
# operator pinned PRAGENT_REVIEWERS=1), spawn one opencode subprocess
|
||||
# per lens in parallel and synthesize. Falls through to the legacy
|
||||
# single-primary path when neither is set.
|
||||
use_lenses = bool((config or {}).get("reviewers")) or bool(
|
||||
os.environ.get("PRAGENT_REVIEWERS")
|
||||
)
|
||||
if use_lenses and hasattr(opencode_review, "run_lenses_review"):
|
||||
stdout, usage = opencode_review.run_lenses_review(
|
||||
api=api, repo=repo, index=index, sha=sha, token=token,
|
||||
title=title, body=body, diff=diff, config=config,
|
||||
prior_reviews=prior, model=oc_model,
|
||||
compression_note=compression_note,
|
||||
additional_context=additional_context,
|
||||
)
|
||||
else:
|
||||
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,
|
||||
compression_note=compression_note,
|
||||
additional_context=additional_context,
|
||||
)
|
||||
review_summary, findings, summary_changes, risks, _walkthrough, _risk_verdict, _test_coverage = parse_review_output(stdout)
|
||||
parse_dropped = last_parse_dropped()
|
||||
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 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 = _render_collapsible_usage(usage, display_model, config=config) if usage else ""
|
||||
post_review(api, repo, index, token, format_review_body(
|
||||
salvaged or "AI review produced no parseable output.",
|
||||
display_model, sha, usage_section=usage_section,
|
||||
static_message=(config or {}).get("static_message", "")))
|
||||
_emit_langfuse(
|
||||
repo=repo, index=index, sha=sha, title=title,
|
||||
model=display_model, usage=usage, findings=[],
|
||||
summary=salvaged, engine=engine, config=config,
|
||||
dropped_count=parse_dropped,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
user_prompt = build_user_prompt(title, body + compression_note, diff, config, prior, additional_context)
|
||||
raw_findings = call_model(ollama_url, model, SYSTEM_PROMPT, user_prompt, max_tokens)
|
||||
findings = parse_findings(raw_findings)
|
||||
parse_dropped = last_parse_dropped()
|
||||
usage = None
|
||||
|
||||
# Filter / cap findings per `.pr-review.json` (style, threshold, max,
|
||||
# patterns, exclude_tests). Without this every config knob would be a
|
||||
# no-op — the agent has no view into the config beyond instructions.
|
||||
# The synthetic require_tests finding (if any) is appended here.
|
||||
try:
|
||||
changed_paths = sorted({
|
||||
f.get("path", "")
|
||||
for f in findings
|
||||
if f.get("path")
|
||||
})
|
||||
except Exception:
|
||||
changed_paths = []
|
||||
# Capture cross-lens agreement BEFORE apply_repo_config — by the time
|
||||
# findings land in `review_pr` the `_multi_lens` marker has already
|
||||
# been scrubbed (once by `opencode_review.run_lenses_review`'s
|
||||
# `_`-prefix strip, again by `_normalize_finding`'s 7-key rebuild),
|
||||
# so `merge_confidence` cannot read it off the dict. We scan here as
|
||||
# the convergence point for both engine paths; in practice the kwarg
|
||||
# currently always passes False, but the structural plumbing is
|
||||
# correct for any future code path that preserves the flag.
|
||||
multi_lens = any(f.get("_multi_lens") for f in findings)
|
||||
kept, _dropped = apply_repo_config(findings, config, changed_paths=changed_paths)
|
||||
findings = kept
|
||||
if _dropped:
|
||||
print(
|
||||
f"pragent: {repo}#{index} sha={sha[:8]} filtered "
|
||||
f"{len(_dropped)} finding(s) per .pr-review.json "
|
||||
f"(style={(config or {}).get('style', 'balanced')}, "
|
||||
f"threshold={(config or {}).get('severity_threshold', '?')}, "
|
||||
f"max={len(findings)})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Compute attribution so inline comments + the table can show per-comment
|
||||
# estimates. Only meaningful when we have measured usage.
|
||||
if usage and usage.get("output"):
|
||||
compute_attribution(findings, usage["output"])
|
||||
usage_section = _render_collapsible_usage(usage, display_model, config=config) if usage else ""
|
||||
|
||||
# Anchor against the RAW diff, never the compressed one. Compression
|
||||
# drops context lines, so a finding on a line that survived in the file
|
||||
# but not in the prompt would be demoted to a bullet for no reason.
|
||||
# (compress_diff renumbers its hunks, so both are line-accurate; the
|
||||
# raw diff is simply the complete set.)
|
||||
anchors = parse_diff_anchors(raw_diff)
|
||||
anchored, unanchored = split_findings(findings, anchors)
|
||||
|
||||
# Summary body: unanchored bullets fall through to a "Unanchored notes"
|
||||
# section; the structured Findings Overview table covers both anchored
|
||||
# + unanchored so reviewers see the full set even if inline comments
|
||||
# are collapsed.
|
||||
bullets = summary_bullets(unanchored)
|
||||
summary_parts = []
|
||||
if bullets:
|
||||
summary_parts.append("### Unanchored Notes\n\n" + bullets)
|
||||
# 1-5 merge verdict for the header badge. Computed AFTER filtering +
|
||||
# anchoring so the verdict reflects what the operator sees (a critical
|
||||
# finding that fails to anchor is still a critical finding). The
|
||||
# default 5 keeps any failure path (e.g. empty findings) green.
|
||||
# Cross-lens agreement is passed in via kwarg (see multi_lens scan
|
||||
# above) because the `_multi_lens` flag is stripped before findings
|
||||
# reach this call.
|
||||
confidence = merge_confidence(findings, multi_lens_observed=multi_lens)
|
||||
summary_body = format_review_body(
|
||||
"\n\n".join(summary_parts), display_model, sha,
|
||||
summary=review_summary,
|
||||
usage_section=usage_section,
|
||||
summary_changes=summary_changes,
|
||||
risks=risks,
|
||||
findings_for_table=findings,
|
||||
inline_count=len(anchored),
|
||||
confidence=confidence,
|
||||
static_message=(config or {}).get("static_message", ""),
|
||||
)
|
||||
|
||||
post_inline_review(api, repo, index, token, summary_body, anchored)
|
||||
_emit_langfuse(
|
||||
repo=repo, index=index, sha=sha, title=title,
|
||||
model=display_model, usage=usage, findings=findings,
|
||||
summary=review_summary, engine=engine, config=config,
|
||||
dropped_count=parse_dropped,
|
||||
)
|
||||
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}", display_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_env("OLLAMA_MAX_TOKENS", 8000),
|
||||
max_chars=_int_env("DIFF_MAX_CHARS", 150000),
|
||||
base_ref=os.environ.get("PR_BASE_REF", ""),
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run())
|
||||
Reference in New Issue
Block a user