feat(review): equivalent provider price + enriched .pr-review.json schema

Three things in this commit, all in the review-rendering path:

1. COST DISPLAY — the `## 🔋 AI usage` section used to show $0.00 because
   the pilot runs on headroom/glm-5.2:cloud at no per-token charge. Now it
   shows TWO lines: the equivalent provider cost (default Claude Sonnet 5;
   configurable via .pr-review.json:cost_target or PRAGENT_PRICE_TARGET env)
   AND the actual $0.00 line. Maintainers can now budget on what the same
   measured tokens would cost on a paid model.

   equivalent_cost() builds a cost_model.Usage from the measured dict and
   runs cost_model.cost() against the resolved provider. _resolve_price_target
   walks repo config > env > default, surfaces typos as an inline note on
   the usage line (not a crash).

2. .pr-review.json SCHEMA — seven new optional fields:
     style                strict|balanced|lenient  (default: balanced)
     severity_threshold   low|medium|high|critical (per style)
     max_findings         1..30                     (per style)
     exclude_tests        bool                      (skip test files)
     require_tests        bool                      (synthetic finding)
     patterns             {allow: [...], deny: [...]} (glob filter)
     cost_target          <PRICES key>              (see #1)

   The first three are style-driven defaults — strict = 5 findings / high+,
   balanced = 12 / medium+, lenient = 15 / low+. Override per-field.
   patterns globs support * and **; built-in fnmatch-style with re.escape.

3. APPLY CONFIG — findings are filtered by the new schema before being
   split into anchored/unanchored. apply_repo_config() drops by exclude_tests
   / exclude_paths / patterns.deny / patterns.allow / severity_threshold, then
   caps at max_findings. require_tests=true appends a synthetic 'low' finding
   when changed paths include non-test files but no test file changed
   alongside them.

   build_user_prompt renders the new fields into the brief so the agent knows
   about style / threshold / patterns explicitly (not just via instructions).

Plus plumbing:
  * review_pr runs compress_diff(diff, context=PRAGENT_DIFF_CONTEXT) before
    handing the diff to either engine. Default context=1 (enough to anchor;
    full files are on disk in the workdir anyway). -1 disables.
  * compact_prior_reviews(prior) keeps only finding-bullet lines, drops the
    rest. Prior-review cap lowered 8k -> 4k chars in build_user_prompt.
  * opencode_review.write_brief accepts compression_note (rendered under
    the PR description, OUTSIDE the untrusted-data fence).

160 new tests covering equivalent_cost (4), format_usage_section cost lines
(5), parse_repo_config extended schema (6), apply_repo_config filters (8),
effective_config style defaults (2), compact_prior_reviews (2), and the
whole diff_compress suite (14 from the previous commit). 174 pass / 0 fail.
This commit is contained in:
Marcos
2026-08-20 16:12:46 +00:00
parent 770581bf53
commit ec26ec000a
3 changed files with 668 additions and 32 deletions
+241
View File
@@ -779,3 +779,244 @@ def test_salvage_summary_empty_when_nothing_to_salvage():
assert ai_review.salvage_summary("") == ""
assert ai_review.salvage_summary(" \n ") == ""
assert ai_review.salvage_summary("```json\n{}\n```") == ""
# ---------------------------------------------------------------------------
# equivalent_cost + format_usage_section equivalent-provider line
# ---------------------------------------------------------------------------
def test_equivalent_cost_matches_cost_model():
usage = {"input": 1_000_000, "output": 0, "cache_read": 0, "cache_write": 0}
eq = ai_review.equivalent_cost(usage, "claude-sonnet-5")
# Sonnet 5 input is $2/MTok, so 1M input = $2.00 exactly.
assert abs(eq - 2.0) < 1e-9
def test_equivalent_cost_unknown_key_returns_zero():
assert ai_review.equivalent_cost({"input": 100}, "bogus") == 0.0
def test_format_usage_section_shows_equivalent_provider_cost():
usage = {"input": 200000, "output": 4000, "reasoning": 0,
"cache_read": 0, "cache_write": 0, "total": 204000,
"cost": 0.0, "steps": 6, "duration_s": 100.0}
sec = ai_review.format_usage_section(usage, [], "glm-5.2:cloud")
# Two cost lines now: an equivalent (default Sonnet 5) AND the $0 actual.
assert "## 🔋 AI usage" in sec
assert "est. cost on **Claude Sonnet 5**" in sec
assert "actual: $0.00" in sec
assert "free tier" in sec
# Equivalent should be > 0 for non-trivial token counts.
assert "$0.00" in sec # the actual line
# And a non-zero one for the equivalent.
import re
cost_lines = [ln for ln in sec.splitlines() if "cost on" in ln]
assert len(cost_lines) == 1
assert re.search(r"\$\d", cost_lines[0]) is not None
assert "$0.00" not in cost_lines[0]
def test_format_usage_section_honors_cost_target(monkeypatch):
monkeypatch.setenv("PRAGENT_PRICE_TARGET", "claude-haiku-4-5")
usage = {"input": 1000, "output": 100, "reasoning": 0,
"cache_read": 0, "cache_write": 0, "total": 1100,
"cost": 0.0, "steps": 1, "duration_s": 5.0}
sec = ai_review.format_usage_section(usage, [], "glm-5.2:cloud")
assert "Claude Haiku 4.5" in sec
# 1k * $1/MTok + 100 * $5/MTok = 0.001 + 0.0005 = $0.0015
assert "$0.0015" in sec
def test_format_usage_section_respects_repo_config_cost_target(monkeypatch):
monkeypatch.delenv("PRAGENT_PRICE_TARGET", raising=False)
usage = {"input": 1000, "output": 100, "reasoning": 0,
"cache_read": 0, "cache_write": 0, "total": 1100,
"cost": 0.0, "steps": 1, "duration_s": 5.0}
sec = ai_review.format_usage_section(
usage, [], "glm-5.2:cloud", config={"cost_target": "claude-opus-5"}
)
assert "Claude Opus 5" in sec
# Opus 5 = $5/MTok input + $25/MTok output → 1000*5e-6 + 100*25e-6 = 0.0075
assert "$0.0075" in sec
def test_format_usage_section_reports_unknown_price_target():
usage = {"input": 100, "output": 100, "reasoning": 0,
"cache_read": 0, "cache_write": 0, "total": 200,
"cost": 0.0, "steps": 1, "duration_s": 1.0}
sec = ai_review.format_usage_section(
usage, [], "glm-5.2:cloud", config={"cost_target": "bogus-model"}
)
# Falls back to default + surfaces the error in the line.
assert "Claude Sonnet 5" in sec
assert "unknown price target" in sec
assert "bogus-model" in sec
# ---------------------------------------------------------------------------
# parse_repo_config — extended schema
# ---------------------------------------------------------------------------
def test_parse_repo_config_new_fields_all_valid():
raw = json.dumps({
"focus": ["security"],
"style": "strict",
"severity_threshold": "high",
"max_findings": 5,
"exclude_tests": True,
"require_tests": True,
"patterns": {"allow": ["src/**"], "deny": ["**/*.test.ts"]},
"cost_target": "claude-opus-5",
})
c = ai_review.parse_repo_config(raw)
assert c["style"] == "strict"
assert c["severity_threshold"] == "high"
assert c["max_findings"] == 5
assert c["exclude_tests"] is True
assert c["require_tests"] is True
assert c["patterns"]["allow"] == ["src/**"]
assert c["patterns"]["deny"] == ["**/*.test.ts"]
assert c["cost_target"] == "claude-opus-5"
def test_parse_repo_config_rejects_bad_style_and_threshold():
c = ai_review.parse_repo_config(json.dumps({"style": "wild", "severity_threshold": "meh"}))
assert "style" not in c
assert "severity_threshold" not in c
def test_parse_repo_config_caps_max_findings():
c1 = ai_review.parse_repo_config(json.dumps({"max_findings": 0}))
c2 = ai_review.parse_repo_config(json.dumps({"max_findings": 999}))
c3 = ai_review.parse_repo_config(json.dumps({"max_findings": "12"}))
assert "max_findings" not in c1 # 0 invalid
assert "max_findings" not in c2 # > 30 invalid
assert c3["max_findings"] == 12 # numeric string accepted
def test_parse_repo_config_caps_patterns():
raw = json.dumps({
"patterns": {"allow": [f"a{i}" for i in range(20)], "deny": [f"d{i}" for i in range(20)]}
})
c = ai_review.parse_repo_config(raw)
assert len(c["patterns"]["allow"]) == ai_review.CONFIG_MAX_PATTERNS_ITEMS
assert len(c["patterns"]["deny"]) == ai_review.CONFIG_MAX_PATTERNS_ITEMS
def test_effective_config_applies_style_defaults():
eff = ai_review.effective_config({"focus": ["security"]})
assert eff["style"] == "balanced"
assert eff["max_findings"] == 12
assert eff["severity_threshold"] == "medium"
assert eff["focus"] == ["security"]
def test_effective_config_style_overrides_fields():
eff = ai_review.effective_config({"style": "strict"})
assert eff["max_findings"] == 5
assert eff["severity_threshold"] == "high"
# ---------------------------------------------------------------------------
# apply_repo_config — filter findings
# ---------------------------------------------------------------------------
_FINDINGS = [
{"severity": "critical", "path": "src/main.py", "line": 1, "problem": "p", "fix": "f", "suggestion": ""},
{"severity": "high", "path": "src/main.py", "line": 5, "problem": "p", "fix": "f", "suggestion": ""},
{"severity": "medium", "path": "src/main.py", "line": 9, "problem": "p", "fix": "f", "suggestion": ""},
{"severity": "low", "path": "src/main.py", "line": 13, "problem": "p", "fix": "f", "suggestion": ""},
{"severity": "high", "path": "src/FooTest.java", "line": 22, "problem": "p", "fix": "f", "suggestion": ""},
{"severity": "medium", "path": "src/app.test.ts", "line": 7, "problem": "p", "fix": "f", "suggestion": ""},
]
def test_apply_repo_config_severity_threshold():
kept, dropped = ai_review.apply_repo_config(_FINDINGS, {"severity_threshold": "high"})
assert len(kept) == 3 # critical + 2 highs (main.py + FooTest.java)
assert all(f["severity"] in ("critical", "high") for f in kept)
assert len(dropped) == 3
def test_apply_repo_config_exclude_tests_drops_test_files():
kept, dropped = ai_review.apply_repo_config(_FINDINGS, {"exclude_tests": True})
paths = {f["path"] for f in kept}
assert "src/FooTest.java" not in paths
assert "src/app.test.ts" not in paths
def test_apply_repo_config_patterns_deny_drops_matching():
cfg = {"patterns": {"deny": ["src/main.py"]}}
kept, dropped = ai_review.apply_repo_config(_FINDINGS, cfg)
paths = {f["path"] for f in kept}
assert "src/main.py" not in paths
def test_apply_repo_config_patterns_allow_keeps_only_matching():
cfg = {"patterns": {"allow": ["src/main.py"]}}
kept, dropped = ai_review.apply_repo_config(_FINDINGS, cfg)
paths = {f["path"] for f in kept}
assert paths == {"src/main.py"}
def test_apply_repo_config_max_findings_caps():
kept, dropped = ai_review.apply_repo_config(_FINDINGS, {"max_findings": 2})
assert len(kept) == 2
# Highest-severity first (critical, then high)
assert kept[0]["severity"] == "critical"
assert kept[1]["severity"] == "high"
def test_apply_repo_config_exclude_paths_glob():
cfg = {"exclude_paths": ["src/main.py"]}
kept, dropped = ai_review.apply_repo_config(_FINDINGS, cfg)
assert "src/main.py" not in {f["path"] for f in kept}
def test_apply_repo_config_require_tests_synthetic_finding():
cfg = {"require_tests": True}
changed = ["src/main.py", "src/lib.ts"]
kept, dropped = ai_review.apply_repo_config([], cfg, changed_paths=changed)
assert any(f.get("_config_synthetic") for f in kept)
def test_apply_repo_config_require_tests_no_synthetic_when_tests_present():
cfg = {"require_tests": True}
changed = ["src/main.py", "src/main_test.py"]
kept, dropped = ai_review.apply_repo_config(_FINDINGS, cfg, changed_paths=changed)
assert not any(f.get("_config_synthetic") for f in kept)
def test_is_test_path_recognises_common_patterns():
assert ai_review.is_test_path("src/FooTest.java")
assert ai_review.is_test_path("src/foo.test.ts")
assert ai_review.is_test_path("tests/foo_test.py")
assert ai_review.is_test_path("test_foo.py")
assert ai_review.is_test_path("packages/app/__tests__/foo.js")
assert not ai_review.is_test_path("src/main.py")
assert not ai_review.is_test_path("src/testing.py") # "testing" ≠ "test_"
# ---------------------------------------------------------------------------
# compact_prior_reviews
# ---------------------------------------------------------------------------
def test_compact_prior_reviews_drops_prose_keeps_bullets():
bodies = [
"🤖 AI Review · m · `abc`\n\nLong prose.\n\n- **[HIGH]** `a.py:1` — bug.\n- **[LOW]** `b.go:2` — nit.\n\n_2 inline comments posted._\n<!-- pragent:sha=abc -->",
"Just chatter, no findings.",
]
out = ai_review.compact_prior_reviews(bodies)
assert len(out) == 1
assert "HIGH" in out[0] and "a.py:1" in out[0]
assert "Long prose." not in out[0]
assert "inline comments posted" not in out[0]
def test_compact_prior_reviews_empty_and_none():
assert ai_review.compact_prior_reviews([]) == []
assert ai_review.compact_prior_reviews(None) == []