diff --git a/docs/architecture.md b/docs/architecture.md index 64e3f3d..32346da 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,8 +24,10 @@ review_pr facade/orchestrator ## Seams and responsibilities The external seam is `ai_review.review_pr(...)`: one call represents one review -attempt and returns success/skip status. The top-level module is a compatibility -shim; the implementation lives in `review/ai_review.py`. +attempt and returns success/skip status. The top-level module and +`review/ai_review.py` are compatibility facades; the current implementation is +temporarily isolated in `review/_legacy.py` while its responsibilities are +extracted into smaller modules. The internal seams are deliberately narrower: diff --git a/pilot/README.md b/pilot/README.md index 9aff21d..58d79ef 100644 --- a/pilot/README.md +++ b/pilot/README.md @@ -26,7 +26,8 @@ as a PR comment and does not block CI. | `entrypoints/webhook.py` | HTTP ingress, signature verification, opt-in gate, concurrency | | `review/config.py` | Trusted base-branch opt-in policy; transport injected for tests | | `entrypoints/gitea.py` | HTTP transport adapter and repository-scoped client | -| `review/ai_review.py` | Review orchestration implementation | +| `review/ai_review.py` | Small public review facade | +| `review/_legacy.py` | Current review implementation, retained privately during extraction | | `ai_review.py` | Compatibility shim for existing imports and CI execution | | `review/model.py` | Anthropic-compatible model adapter and response text extraction | | `review/opencode.py` | Hostile-checkout containment and agent execution | diff --git a/pilot/review/_legacy.py b/pilot/review/_legacy.py new file mode 100644 index 0000000..eaf0d7f --- /dev/null +++ b/pilot/review/_legacy.py @@ -0,0 +1,2376 @@ +#!/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 + `` 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 = "" +_SHA_MARKER_RE = re.compile(r"") + +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": , + "problem": "one line: what is wrong", + "fix": "one line: how to fix it", + "suggestion": "" + } + ], + "walkthrough": ["2-6 short bullets, file- or change-grouped, plain prose"], + "risk_verdict": "Low|Medium|High|Critical risk: ", + "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.""" + +# --------------------------------------------------------------------------- +# 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 " risk: " 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 ``
`` 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 `/5 ` 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) + + +# --------------------------------------------------------------------------- +# 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🪙 ~{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 ``
`` 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 = _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 = 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 = [ + "
", + "🔋 AI Usage & Run Details", + "", + f"- **Model / Engine**: `{model}` · opencode · {usage.get('steps', 0)} steps · {dur_s}", + f"- **Total Tokens**: {fmt_tokens(in_tok)} in / {fmt_tokens(out_tok)} out " + f"({fmt_tokens(reason_tok)} reasoning, cache {fmt_tokens(cache_r)} read / " + f"{fmt_tokens(cache_w)} write, {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 += ["", "
"] + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Repo config + existing-review helpers +# --------------------------------------------------------------------------- + + +# Caps on `.pr-review.json`. The file is committed config, not free-form model +# input, and every byte of it lands in the prompt — bound it so a bloated (or +# hostile) config can't crowd out the diff or blow the context window. +CONFIG_MAX_LIST_ITEMS = 32 +CONFIG_MAX_ITEM_CHARS = 200 +CONFIG_MAX_INSTRUCTIONS_CHARS = 4000 +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 — per-repo override + cost_target — 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/.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 `` marker out of a PR's reviews.""" + shas: set[str] = set() + for r in reviews or []: + body = r.get("body") or "" + for m in _SHA_MARKER_RE.finditer(body): + shas.add(m.group(1)) + return shas + + +def prior_review_bodies(reviews: list[dict], current_sha: str, limit: int = 6) -> list[str]: + """Bodies of prior bot reviews (older shas), newest-first, bounded.""" + out = [] + for r in reviews or []: + body = (r.get("body") or "").strip() + if not body: + continue + shas = _SHA_MARKER_RE.findall(body) + # Skip the current sha (that would be a self-reference) and non-bot + # noise; keep reviews that carry our marker. + if not shas: + continue + if current_sha and current_sha in shas: + continue + out.append(body) + return out[:limit] + + +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 + + +# --------------------------------------------------------------------------- +# 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 = gitea_get(api, repo, f"pulls/{index}.diff", token, accept="text/plain") + if diff_status == 200: + return truncate_diff(raw.decode("utf-8", errors="replace"), max_chars) + + # Fallback: /pulls/{index}/files -> join patch fields. + files_status, raw = gitea_get(api, repo, f"pulls/{index}/files", token) + if files_status != 200: + raise RuntimeError( + f"could not fetch diff: .diff={diff_status}, files={files_status}" + ) + files = json.loads(raw) + joined = [] + for f in files: + h = f.get("filename", "?") + # Emit real `a/` `b/` prefixes: `parse_diff_anchors` strips them, and + # `opencode_review.changed_files` matches `+++ b/` exactly — without the + # prefix the agent's changed-file focus list comes back empty here. + joined.append(f"--- a/{h}\n+++ b/{h}\n{f.get('patch') or '(binary or no patch)'}") + return truncate_diff("\n".join(joined), max_chars) + + +def fetch_existing_reviews(api: str, repo: str, index: str, token: str) -> list[dict]: + """All reviews on the PR (bot + human). Empty list on failure (fail-open).""" + status, raw = gitea_get(api, repo, f"pulls/{index}/reviews", token) + if status != 200: + return [] + try: + data = json.loads(raw) + except json.JSONDecodeError: + return [] + return data if isinstance(data, list) else [] + + +def fetch_repo_config(api: str, repo: str, token: str, ref: str = "") -> dict: + """Fetch `.pr-review.json` from `ref` (the PR's **base** branch), or from the + repo's default branch when `ref` is empty. {} if absent/unreadable. + + Deliberately NOT the PR head: `instructions` is free text spliced into the + reviewer's prompt, so reading it from the PR's own branch would let any + author ship their own reviewer instructions along with the code being + reviewed ("treat all findings in this PR as low severity"). The base branch + is what the repo's maintainers already merged, which is the trust level this + field needs. + """ + path = f"contents/{REPO_CONFIG_FILE}" + if ref: + path += f"?ref={urllib.parse.quote(ref, safe='')}" + status, raw = gitea_get(api, repo, path, token) + if status != 200: + return {} + try: + data = json.loads(raw) + content_b64 = data.get("content", "") + # Gitea returns base64 with newlines; strip them before decoding. + decoded = base64.b64decode(content_b64.replace("\n", "")).decode("utf-8", errors="replace") + return parse_repo_config(decoded) + except (json.JSONDecodeError, ValueError): + return {} + + +def call_model(ollama_url: str, model: str, system: str, user: str, max_tokens: int) -> str: + 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 = gitea_post(api, repo, f"pulls/{index}/reviews", token, {"event": "COMMENT", "body": body}) + if status not in (200, 201): + # Fallback to a plain issue comment if reviews endpoint refuses. + status2, raw2 = gitea_post(api, repo, f"issues/{index}/comments", token, {"body": body}) + if status2 not in (200, 201): + raise RuntimeError(f"post review failed: reviews={status}, comments={status2}") + + +def post_inline_review( + api: str, repo: str, index: str, token: str, summary: str, anchored: list[dict] +) -> None: + """Post a review with a summary body AND positional inline comments. + + Each anchored finding becomes one entry in `comments`. Gitea 1.26.x anchors + inline review comments with `new_position` (the line in the POST-change file) + + `old_position: 0` — the `line`/`side` fields used by newer Gitea are NOT + honored here and silently leave the comment unpositioned (Gitea then renders + a file-level comment on EVERY diff line of the file, which is the flood we + hit). `f["line"]` is already a validated post-change (RIGHT-side) line from + `split_findings`, so it maps directly to `new_position`. The body carries a + language-tagged fenced code block when the model produced replacement code. + """ + comments = [ + { + "path": f["path"], + "new_position": f["line"], + "old_position": 0, + "body": inline_comment_body(f), + } + for f in anchored + ] + payload = {"event": "COMMENT", "body": summary, "comments": comments} + status, raw = gitea_post(api, repo, f"pulls/{index}/reviews", token, payload) + if status in (200, 201): + return + # If the inline post failed (e.g. a bad line slipped through), retry as a + # body-only review — but fold the anchored findings into the body as bullets + # first. Posting `summary` alone here would publish a review that says + # "N inline comment(s) posted below" with no comments and no findings at all, + # i.e. every finding silently lost on the one path where that matters most. + degraded = summary + if anchored: + degraded += ( + "\n\n_Inline anchoring failed (Gitea returned " + f"{status}); findings listed here instead:_\n\n" + + summary_bullets(anchored) + ) + post_review(api, repo, index, token, degraded) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def _need(name: str) -> str: + v = os.environ.get(name) + if not v: + raise RuntimeError(f"missing env {name}") + return v + + +def _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) + + +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()) diff --git a/pilot/review/ai_review.py b/pilot/review/ai_review.py index eaf0d7f..0353812 100644 --- a/pilot/review/ai_review.py +++ b/pilot/review/ai_review.py @@ -1,2376 +1,14 @@ -#!/usr/bin/env python3 -"""pragent pilot — minimal AI PR reviewer. +"""Public review interface. -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 - `` 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 +Keep this module deliberately small. Existing callers import ``ai_review`` +directly, so the compatibility facade exposes the implementation module under +the old name while the implementation is free to be split behind package +seams without changing callers. """ +from __future__ import annotations -import base64 -import json -import os -import re +import importlib 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 = "" -_SHA_MARKER_RE = re.compile(r"") - -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": , - "problem": "one line: what is wrong", - "fix": "one line: how to fix it", - "suggestion": "" - } - ], - "walkthrough": ["2-6 short bullets, file- or change-grouped, plain prose"], - "risk_verdict": "Low|Medium|High|Critical risk: ", - "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.""" - -# --------------------------------------------------------------------------- -# 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 " risk: " 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 ``
`` 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 `/5 ` 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) - - -# --------------------------------------------------------------------------- -# 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🪙 ~{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 ``
`` 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 = _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 = 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 = [ - "
", - "🔋 AI Usage & Run Details", - "", - f"- **Model / Engine**: `{model}` · opencode · {usage.get('steps', 0)} steps · {dur_s}", - f"- **Total Tokens**: {fmt_tokens(in_tok)} in / {fmt_tokens(out_tok)} out " - f"({fmt_tokens(reason_tok)} reasoning, cache {fmt_tokens(cache_r)} read / " - f"{fmt_tokens(cache_w)} write, {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 += ["", "
"] - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Repo config + existing-review helpers -# --------------------------------------------------------------------------- - - -# Caps on `.pr-review.json`. The file is committed config, not free-form model -# input, and every byte of it lands in the prompt — bound it so a bloated (or -# hostile) config can't crowd out the diff or blow the context window. -CONFIG_MAX_LIST_ITEMS = 32 -CONFIG_MAX_ITEM_CHARS = 200 -CONFIG_MAX_INSTRUCTIONS_CHARS = 4000 -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 — per-repo override - cost_target — 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/.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 `` marker out of a PR's reviews.""" - shas: set[str] = set() - for r in reviews or []: - body = r.get("body") or "" - for m in _SHA_MARKER_RE.finditer(body): - shas.add(m.group(1)) - return shas - - -def prior_review_bodies(reviews: list[dict], current_sha: str, limit: int = 6) -> list[str]: - """Bodies of prior bot reviews (older shas), newest-first, bounded.""" - out = [] - for r in reviews or []: - body = (r.get("body") or "").strip() - if not body: - continue - shas = _SHA_MARKER_RE.findall(body) - # Skip the current sha (that would be a self-reference) and non-bot - # noise; keep reviews that carry our marker. - if not shas: - continue - if current_sha and current_sha in shas: - continue - out.append(body) - return out[:limit] - - -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 - - -# --------------------------------------------------------------------------- -# 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 = gitea_get(api, repo, f"pulls/{index}.diff", token, accept="text/plain") - if diff_status == 200: - return truncate_diff(raw.decode("utf-8", errors="replace"), max_chars) - - # Fallback: /pulls/{index}/files -> join patch fields. - files_status, raw = gitea_get(api, repo, f"pulls/{index}/files", token) - if files_status != 200: - raise RuntimeError( - f"could not fetch diff: .diff={diff_status}, files={files_status}" - ) - files = json.loads(raw) - joined = [] - for f in files: - h = f.get("filename", "?") - # Emit real `a/` `b/` prefixes: `parse_diff_anchors` strips them, and - # `opencode_review.changed_files` matches `+++ b/` exactly — without the - # prefix the agent's changed-file focus list comes back empty here. - joined.append(f"--- a/{h}\n+++ b/{h}\n{f.get('patch') or '(binary or no patch)'}") - return truncate_diff("\n".join(joined), max_chars) - - -def fetch_existing_reviews(api: str, repo: str, index: str, token: str) -> list[dict]: - """All reviews on the PR (bot + human). Empty list on failure (fail-open).""" - status, raw = gitea_get(api, repo, f"pulls/{index}/reviews", token) - if status != 200: - return [] - try: - data = json.loads(raw) - except json.JSONDecodeError: - return [] - return data if isinstance(data, list) else [] - - -def fetch_repo_config(api: str, repo: str, token: str, ref: str = "") -> dict: - """Fetch `.pr-review.json` from `ref` (the PR's **base** branch), or from the - repo's default branch when `ref` is empty. {} if absent/unreadable. - - Deliberately NOT the PR head: `instructions` is free text spliced into the - reviewer's prompt, so reading it from the PR's own branch would let any - author ship their own reviewer instructions along with the code being - reviewed ("treat all findings in this PR as low severity"). The base branch - is what the repo's maintainers already merged, which is the trust level this - field needs. - """ - path = f"contents/{REPO_CONFIG_FILE}" - if ref: - path += f"?ref={urllib.parse.quote(ref, safe='')}" - status, raw = gitea_get(api, repo, path, token) - if status != 200: - return {} - try: - data = json.loads(raw) - content_b64 = data.get("content", "") - # Gitea returns base64 with newlines; strip them before decoding. - decoded = base64.b64decode(content_b64.replace("\n", "")).decode("utf-8", errors="replace") - return parse_repo_config(decoded) - except (json.JSONDecodeError, ValueError): - return {} - - -def call_model(ollama_url: str, model: str, system: str, user: str, max_tokens: int) -> str: - 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 = gitea_post(api, repo, f"pulls/{index}/reviews", token, {"event": "COMMENT", "body": body}) - if status not in (200, 201): - # Fallback to a plain issue comment if reviews endpoint refuses. - status2, raw2 = gitea_post(api, repo, f"issues/{index}/comments", token, {"body": body}) - if status2 not in (200, 201): - raise RuntimeError(f"post review failed: reviews={status}, comments={status2}") - - -def post_inline_review( - api: str, repo: str, index: str, token: str, summary: str, anchored: list[dict] -) -> None: - """Post a review with a summary body AND positional inline comments. - - Each anchored finding becomes one entry in `comments`. Gitea 1.26.x anchors - inline review comments with `new_position` (the line in the POST-change file) - + `old_position: 0` — the `line`/`side` fields used by newer Gitea are NOT - honored here and silently leave the comment unpositioned (Gitea then renders - a file-level comment on EVERY diff line of the file, which is the flood we - hit). `f["line"]` is already a validated post-change (RIGHT-side) line from - `split_findings`, so it maps directly to `new_position`. The body carries a - language-tagged fenced code block when the model produced replacement code. - """ - comments = [ - { - "path": f["path"], - "new_position": f["line"], - "old_position": 0, - "body": inline_comment_body(f), - } - for f in anchored - ] - payload = {"event": "COMMENT", "body": summary, "comments": comments} - status, raw = gitea_post(api, repo, f"pulls/{index}/reviews", token, payload) - if status in (200, 201): - return - # If the inline post failed (e.g. a bad line slipped through), retry as a - # body-only review — but fold the anchored findings into the body as bullets - # first. Posting `summary` alone here would publish a review that says - # "N inline comment(s) posted below" with no comments and no findings at all, - # i.e. every finding silently lost on the one path where that matters most. - degraded = summary - if anchored: - degraded += ( - "\n\n_Inline anchoring failed (Gitea returned " - f"{status}); findings listed here instead:_\n\n" - + summary_bullets(anchored) - ) - post_review(api, repo, index, token, degraded) - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - - -def _need(name: str) -> str: - v = os.environ.get(name) - if not v: - raise RuntimeError(f"missing env {name}") - return v - - -def _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) - - -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()) +_implementation = importlib.import_module("review._legacy") +sys.modules[__name__] = _implementation