fix(review): correct diff-compression line numbers, prior-review dedupe, triage skip
Four defects, all found reviewing PR #9 (two of them by pragent-bot's own review of that PR, which the anchoring bug then misplaced): * compress_diff dropped context lines but copied the original `@@` hunk header verbatim, so the header no longer described the lines beneath it. parse_diff_anchors then walked stale headers and produced anchor sets shifted by the number of elided lines, misplacing inline comments or demoting them to bullets. Each surviving run of lines is now re-emitted as its own hunk with a recomputed `@@ -a,b +c,d @@`, so the output stays a valid unified diff whose numbers describe the real post-change file. The pseudo-marker `@@ … N context line(s) omitted … @@` is gone; it parsed as a hunk header and reset the anchor counter to 0. Anchoring additionally runs on the raw diff now, so the prompt window can never shrink the anchorable set. * compress_diff's `_FILE_HEADER` regex matched diff *body* lines: a removed YAML `---` separator or an added `++` line was read as a file header, truncating the hunk and dropping its `@@` header with it. Body detection is now prefix-based, with a full-shape hunk-header regex. * extract_finding_bullets could not match the bullets pragent itself posts: summary_bullets renders an emoji severity badge between the `-` and the `[SEV]` tag, which the regex rejected, so compact_prior_reviews always returned [] and every re-review repeated its previous findings. * triage returning `{"lenses":[]}` — documented in .opencode/agents/triage.md as "no lens has surface, skip the fan-out" — ran every lens instead, since _intersect_with_triage mapped an empty selection to "all" and the call site had a second `or reviewers` fallback. `[]` and None are now distinct outcomes: `[]` skips, None fails open. A roster naming only unknown lens ids now fails open rather than silencing the review. The skip path returns a well-formed empty-findings response instead of "", which had landed in ai_review's unparseable-output branch and posted "AI review produced no parseable output" — a malfunction message for a normal verdict. Also: non-URL references (a CVE id, a doc title) rendered as `[CVE-2024-1234](CVE-2024-1234)`, a broken relative link in Gitea — now plain text. PRAGENT_DIFF_CONTEXT and friends parse through _int_env, so a typo logs and falls back instead of killing a review mid-flight. Removed format_usage_section, dead since the collapsible usage block replaced it and carrying a duplicate copy of the price-target logic. Tests: 290 -> 301. New coverage for hunk-header fidelity before/after compression, header-shaped content lines, the bullet round-trip against the real renderer, and triage's three outcomes (previously untested). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
This commit is contained in:
+32
-79
@@ -156,6 +156,25 @@ def parse_text_blocks(content: list) -> str:
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def format_review_body(
|
||||
findings: str,
|
||||
model: str,
|
||||
@@ -337,79 +356,6 @@ def equivalent_cost(usage: dict, price_key: str) -> float:
|
||||
return cost(u, PRICES[price_key])
|
||||
|
||||
|
||||
def format_usage_section(
|
||||
usage: dict | None,
|
||||
findings: list[dict],
|
||||
model: str,
|
||||
config: dict | None = None,
|
||||
) -> str:
|
||||
"""Render the `## 🔋 AI usage` block for the review body.
|
||||
|
||||
Only called when the PR carries the `AI-USAGE` label (and the opencode
|
||||
engine produced a usage dict). Reports the MEASURED total
|
||||
(input/output/reasoning/cache/cost/steps/duration) plus an ATTRIBUTED
|
||||
per-finding table — one model pass generates all findings, so per-comment
|
||||
counts are an estimate (output split by body weight), clearly labelled.
|
||||
|
||||
The cost lines show TWO numbers because the pilot runs on headroom at
|
||||
$0/MTok: the `actual` line is what was billed (always $0.00 today), and
|
||||
the `est. cost on <provider>` line shows what the same measured tokens
|
||||
would have cost on a paid model — the number a maintainer actually cares
|
||||
about when budgeting. `config["cost_target"]` / `PRAGENT_PRICE_TARGET`
|
||||
/ `DEFAULT_PRICE_TARGET` (claude-sonnet-5) picks the comparison provider.
|
||||
|
||||
Returns "" if `usage` is None.
|
||||
"""
|
||||
if not usage:
|
||||
return ""
|
||||
dur = usage.get("duration_s")
|
||||
dur_s = f"{dur}s" if dur is not None else "?"
|
||||
actual = usage.get("cost") or 0.0
|
||||
actual_s = f"${actual:.4f}" if actual else "$0.00"
|
||||
actual_note = (
|
||||
"(headroom glm-5.2:cloud — free tier)"
|
||||
if not actual else "(billed by provider)"
|
||||
)
|
||||
price_key, price_err = _resolve_price_target(config)
|
||||
from cost_model import PRICES # local import keeps ollama path dep-free
|
||||
eq = equivalent_cost(usage, price_key)
|
||||
eq_s = f"${eq:.4f}" if eq else "$0.00"
|
||||
eq_label = PRICES[price_key].name
|
||||
lines = [
|
||||
"## 🔋 AI usage",
|
||||
"",
|
||||
f"- model: `{model}` · engine: opencode · agent steps: {usage.get('steps', 0)} · duration: {dur_s}",
|
||||
(
|
||||
f"- tokens: {usage.get('input', 0)} in · {usage.get('output', 0)} out · "
|
||||
f"{usage.get('reasoning', 0)} reasoning · cache "
|
||||
f"{usage.get('cache_read', 0)} read / {usage.get('cache_write', 0)} write "
|
||||
f"→ {usage.get('total', 0)} total"
|
||||
),
|
||||
f"- est. cost on **{eq_label}**: {eq_s}" + (
|
||||
f" _(price target: `{price_key}`; "
|
||||
f"{price_err})_"
|
||||
if price_err else ""
|
||||
),
|
||||
f"- actual: {actual_s} {actual_note}",
|
||||
"- scope: whole-repo checkout at head sha (agent can read any file + run linters, not just the diff) — input tokens include files read beyond the diff",
|
||||
"- per-comment tokens are *attributed* (one model pass produces all findings; output split by each finding's body weight)",
|
||||
]
|
||||
# Per-finding attribution table.
|
||||
rows = [f for f in findings if f.get("_tok_attrib") is not None]
|
||||
if rows:
|
||||
lines.append("")
|
||||
lines.append("| severity | location | ≈out tok | % |")
|
||||
lines.append("|---|---|---:|---:|")
|
||||
for f in rows:
|
||||
loc = f"{f['path']}:{f['line']}" if f.get("line") else f.get("path", "?")
|
||||
pct = f.get("_tok_pct", 0.0) * 100
|
||||
lines.append(
|
||||
f"| {f.get('severity', '').upper()} | `{loc}` | "
|
||||
f"{f.get('_tok_attrib', 0)} | {pct:.0f}% |"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_user_prompt(
|
||||
title: str,
|
||||
body: str,
|
||||
@@ -987,8 +933,10 @@ def _format_reference(ref: str) -> str:
|
||||
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 label.
|
||||
return f"[{ref}]({ref})"
|
||||
# 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://"):
|
||||
@@ -1884,7 +1832,7 @@ def review_pr(
|
||||
# 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(os.environ.get("PRAGENT_DIFF_CONTEXT", "1"))
|
||||
ctx = _int_env("PRAGENT_DIFF_CONTEXT", 1)
|
||||
if ctx < 0:
|
||||
diff = raw_diff
|
||||
compression_note = ""
|
||||
@@ -1990,7 +1938,12 @@ def review_pr(
|
||||
compute_attribution(findings, usage["output"])
|
||||
usage_section = _render_collapsible_usage(usage, model, config=config) if report_usage else ""
|
||||
|
||||
anchors = parse_diff_anchors(diff)
|
||||
# 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"
|
||||
@@ -2038,8 +1991,8 @@ def run() -> int:
|
||||
token=_need("PRAGENT_BOT_TOKEN"),
|
||||
ollama_url=_need("OLLAMA_URL"),
|
||||
model=_need("OLLAMA_MODEL"),
|
||||
max_tokens=int(os.environ.get("OLLAMA_MAX_TOKENS", "8000")),
|
||||
max_chars=int(os.environ.get("DIFF_MAX_CHARS", "150000")),
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user