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:
@@ -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) == []
|
||||
|
||||
Reference in New Issue
Block a user