7a510a926d
Group review, feedback, evaluation, observability, and entrypoint code into packages. Keep thin top-level compatibility shims for existing scripts and imports, and mirror the structure in the tests.
2152 lines
80 KiB
Python
2152 lines
80 KiB
Python
"""Unit tests for pragent pilot pure helpers. No network."""
|
|
import base64
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
# Allow running without install: add repo root to path.
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
ROOT = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
|
|
sys.path.insert(0, os.path.join(ROOT, "pilot"))
|
|
|
|
import ai_review # noqa: E402
|
|
from ai_review import ( # noqa: E402
|
|
_CONFIDENCE_BADGE,
|
|
_SEVERITY_EMOJI,
|
|
_balanced_json_substring,
|
|
_extract_first_json_object,
|
|
_last_balanced_json,
|
|
_normalize_finding,
|
|
_render_collapsible_usage,
|
|
_severity_badge,
|
|
build_user_prompt,
|
|
compute_attribution,
|
|
findings_table,
|
|
fmt_tokens,
|
|
format_review_body,
|
|
inline_comment_body,
|
|
merge_confidence,
|
|
parse_diff_anchors,
|
|
parse_findings,
|
|
parse_repo_config,
|
|
parse_review_output,
|
|
parse_text_blocks,
|
|
prior_review_bodies,
|
|
reviewed_shas,
|
|
REVIEW_HEADER,
|
|
SEVERITIES,
|
|
SEVERITY_RANK,
|
|
split_findings,
|
|
summary_bullets,
|
|
truncate_diff,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# truncate_diff
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_truncate_diff_short():
|
|
text, truncated, n = truncate_diff("abc", 100)
|
|
assert text == "abc"
|
|
assert truncated is False
|
|
assert n == 3
|
|
|
|
|
|
def test_truncate_diff_exact_boundary():
|
|
text, truncated, n = truncate_diff("x" * 100, 100)
|
|
assert truncated is False
|
|
assert n == 100
|
|
assert text == "x" * 100
|
|
|
|
|
|
def test_truncate_diff_over_cap():
|
|
text, truncated, n = truncate_diff("x" * 250, 100)
|
|
assert truncated is True
|
|
assert n == 250
|
|
assert text.startswith("x" * 100)
|
|
assert "[diff truncated at 100 characters]" in text
|
|
|
|
|
|
def test_truncate_diff_none():
|
|
text, truncated, n = truncate_diff(None, 100) # type: ignore[arg-type]
|
|
assert text == ""
|
|
assert truncated is False
|
|
assert n == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_text_blocks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_text_blocks_text_only():
|
|
content = [{"type": "text", "text": "hello"}, {"type": "text", "text": "world"}]
|
|
assert parse_text_blocks(content) == "hello\nworld"
|
|
|
|
|
|
def test_parse_text_blocks_drops_thinking():
|
|
content = [
|
|
{"type": "thinking", "thinking": "reasoning here"},
|
|
{"type": "text", "text": "- [high] a.go:3 — bug. fix."},
|
|
]
|
|
assert parse_text_blocks(content) == "- [high] a.go:3 — bug. fix."
|
|
|
|
|
|
def test_parse_text_blocks_empty_and_malformed():
|
|
assert parse_text_blocks([]) == ""
|
|
assert parse_text_blocks(None) == "" # type: ignore[arg-type]
|
|
assert parse_text_blocks([{"type": "text"}, "garbage", 5]) == ""
|
|
|
|
|
|
def test_parse_text_blocks_real_glm_shape():
|
|
# Captured from glm-5.2:cloud via headroom 8789.
|
|
content = [
|
|
{"type": "thinking", "thinking": "Analyze the request..."},
|
|
{"type": "text", "text": "- [critical] auth.py:12 — token compared with `==`. Use hmac.compare_digest."},
|
|
]
|
|
assert "compare_digest" in parse_text_blocks(content)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# format_review_body
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_format_review_body_findings():
|
|
body = format_review_body("- [high] x:1 — bug. fix.", "glm-5.2:cloud", "abcdef1234567890")
|
|
assert "pragent pilot" in body
|
|
assert "glm-5.2:cloud" in body
|
|
assert "`abcdef12`" in body # 8-char sha
|
|
# New layout: always emits Summary of Changes + Key Risks. Findings table
|
|
# only shows when findings_for_table is passed (callers pass the actual
|
|
# list of finding dicts; plain-string findings arg renders as bullets).
|
|
assert "### Summary of Changes" in body
|
|
assert "### Key Risks & Concerns" in body
|
|
|
|
|
|
def test_format_review_body_empty_findings():
|
|
body = format_review_body("", "glm-5.2:cloud", "abcdef1234567890")
|
|
# No summary → "no summary provided" sentinel; Findings table absent
|
|
# because no findings were passed.
|
|
assert "_No summary provided._" in body
|
|
assert "_None identified._" in body
|
|
assert "### Findings Overview" not in body
|
|
|
|
|
|
def test_format_review_body_whitespace_findings():
|
|
body = format_review_body(" \n ", "glm-5.2:cloud", "abcdef1234567890")
|
|
assert "_No summary provided._" in body
|
|
|
|
|
|
def test_format_review_body_no_sha():
|
|
body = format_review_body("- [low] y:2 — nit", "glm-5.2:cloud", "")
|
|
assert "`unknown`" in body
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_user_prompt
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_build_user_prompt_includes_title_and_diff():
|
|
p = build_user_prompt("Fix login", "Closes #1", "diff --git a/x b/x")
|
|
assert "Fix login" in p
|
|
assert "Closes #1" in p
|
|
assert "diff --git a/x b/x" in p
|
|
|
|
|
|
def test_build_user_prompt_truncates_long_body():
|
|
long_body = "B" * 6000
|
|
p = build_user_prompt("t", long_body, "d")
|
|
assert "[PR body truncated]" in p
|
|
assert p.count("B") < 6000
|
|
|
|
|
|
def test_build_user_prompt_no_body():
|
|
p = build_user_prompt("t", "", "d")
|
|
assert "Description:" not in p
|
|
|
|
|
|
def test_build_user_prompt_with_config_and_prior():
|
|
cfg = {"focus": ["security"], "instructions": "Use Result<T,E>."}
|
|
prior = ["🤖 **AI Review** …\n- [high] x:1 — old."]
|
|
p = build_user_prompt("t", "b", "diff --git a/x b/x", config=cfg, prior_reviews=prior)
|
|
assert "## Repo review config" in p
|
|
assert "security" in p
|
|
assert "Result<T,E>" in p
|
|
assert "## PREVIOUS REVIEWS" in p
|
|
assert "old." in p
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_diff_anchors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_DIFF = """\
|
|
diff --git a/src/a.py b/src/a.py
|
|
index 1..2 100644
|
|
--- a/src/a.py
|
|
+++ b/src/a.py
|
|
@@ -1,4 +1,5 @@
|
|
context
|
|
-removed
|
|
+added
|
|
context2
|
|
@@ -10,3 +10,4 @@
|
|
keep
|
|
+new
|
|
last
|
|
diff --git a/binary.bin b/binary.bin
|
|
new file mode 100644
|
|
index 0..1
|
|
Binary files differ
|
|
"""
|
|
|
|
|
|
def test_parse_diff_anchors_context_and_added():
|
|
a = parse_diff_anchors(_DIFF)
|
|
# context(1), +added(2), context2(3) | keep(10), +new(11), last(12)
|
|
assert a["src/a.py"] == {1, 2, 3, 10, 11, 12}
|
|
# removed line (-removed, old line 2) has no new-line anchor
|
|
assert 2 in a["src/a.py"] # 2 here is the +added line, not the removed one
|
|
|
|
|
|
def test_parse_diff_anchors_binary_file_present_no_lines():
|
|
a = parse_diff_anchors(_DIFF)
|
|
assert "binary.bin" in a
|
|
assert a["binary.bin"] == set()
|
|
|
|
|
|
def test_parse_diff_anchors_empty():
|
|
assert parse_diff_anchors("") == {}
|
|
assert parse_diff_anchors(None) == {} # type: ignore[arg-type]
|
|
|
|
|
|
def test_parse_diff_anchors_new_file():
|
|
diff = "diff --git a/new.ts b/new.ts\nnew file mode 100644\n--- /dev/null\n+++ b/new.ts\n@@ -0,0 +1,3 @@\n+a\n+b\n+c\n"
|
|
a = parse_diff_anchors(diff)
|
|
assert a["new.ts"] == {1, 2, 3}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_findings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_findings_clean_json():
|
|
txt = '{"findings":[{"severity":"high","path":"a.py","line":3,"problem":"x","fix":"y","suggestion":"z"}]}'
|
|
fs = parse_findings(txt)
|
|
assert len(fs) == 1
|
|
assert fs[0]["severity"] == "high"
|
|
assert fs[0]["path"] == "a.py"
|
|
assert fs[0]["line"] == 3
|
|
|
|
|
|
def test_parse_findings_fenced_json():
|
|
txt = '```json\n{"findings":[{"severity":"low","path":"b.go","line":1,"problem":"p","fix":"","suggestion":""}]}\n```'
|
|
fs = parse_findings(txt)
|
|
assert len(fs) == 1
|
|
assert fs[0]["path"] == "b.go"
|
|
|
|
|
|
def test_parse_findings_json_in_prose():
|
|
txt = 'Here is my review: {"findings":[{"severity":"critical","path":"c","line":9,"problem":"q"}]} thanks!'
|
|
fs = parse_findings(txt)
|
|
assert len(fs) == 1
|
|
assert fs[0]["severity"] == "critical"
|
|
|
|
|
|
def test_parse_findings_empty():
|
|
assert parse_findings('{"findings":[]}') == []
|
|
assert parse_findings("") == []
|
|
assert parse_findings("not json at all") == []
|
|
|
|
|
|
def test_parse_findings_drops_bad_entries():
|
|
# missing path, bad line, unknown severity (normalised)
|
|
txt = '{"findings":[{"line":1},{"path":"x","line":-1},{"path":"x","line":2,"severity":"bogus","problem":"p"}]}'
|
|
fs = parse_findings(txt)
|
|
assert len(fs) == 1
|
|
assert fs[0]["severity"] == "medium"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# split_findings + inline_comment_body + summary_bullets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_split_findings_by_anchor():
|
|
anchors = {"a.py": {1, 3, 4}}
|
|
fs = [
|
|
{"severity": "high", "path": "a.py", "line": 3, "problem": "p", "fix": "f", "suggestion": ""},
|
|
{"severity": "low", "path": "a.py", "line": 99, "problem": "off", "fix": "", "suggestion": ""},
|
|
{"severity": "medium", "path": "other.go", "line": 1, "problem": "x", "fix": "", "suggestion": ""},
|
|
]
|
|
anchored, unanchored = split_findings(fs, anchors)
|
|
assert [f["line"] for f in anchored] == [3]
|
|
assert len(unanchored) == 2
|
|
|
|
|
|
def test_inline_comment_body_with_suggestion():
|
|
f = {"severity": "high", "path": "a", "line": 1, "problem": "bad", "fix": "swap", "suggestion": "good()"}
|
|
body = inline_comment_body(f)
|
|
# Severity emoji + bracketed label.
|
|
assert "🔴 [HIGH]" in body
|
|
assert "bad" in body
|
|
# Standard ```suggestion fence (Gitea/Forgejo apply-on-click).
|
|
assert "```suggestion\ngood()\n```" in body
|
|
assert "good()" in body
|
|
|
|
|
|
def test_inline_comment_body_suggestion_not_lang_tagged():
|
|
# Per the format spec, the suggestion fence is ALWAYS ```suggestion —
|
|
# never a language-tagged fence (those are reserved for cross-file
|
|
# pattern illustrations, which we don't emit here).
|
|
f = {"severity": "high", "path": "src/Foo.java", "line": 1,
|
|
"problem": "bad", "fix": "swap", "suggestion": "good();"}
|
|
body = inline_comment_body(f)
|
|
assert "```suggestion\ngood();\n```" in body
|
|
assert "```java" not in body
|
|
|
|
|
|
def test_inline_comment_body_no_suggestion():
|
|
f = {"severity": "low", "path": "a", "line": 1, "problem": "p", "fix": "f", "suggestion": ""}
|
|
body = inline_comment_body(f)
|
|
assert "```" not in body
|
|
assert "**Fix:** f" in body
|
|
|
|
|
|
def test_inline_comment_body_severity_emoji_mapping():
|
|
cases = [
|
|
("critical", "🔴 [CRITICAL]"),
|
|
("high", "🔴 [HIGH]"),
|
|
("medium", "🟡 [MEDIUM]"),
|
|
("low", "🔵 [LOW]"),
|
|
("info", "⚪ [INFO]"),
|
|
("nit", "⚪ [NIT]"), # legacy alias — renders with its own name
|
|
("bogus", "⚪ [INFO]"), # unknown severity falls back to INFO
|
|
]
|
|
for sev, badge in cases:
|
|
f = {"severity": sev, "path": "a", "line": 1, "problem": "p", "fix": "",
|
|
"suggestion": "", "reference": ""}
|
|
assert badge in inline_comment_body(f), f"{sev} → {badge}"
|
|
|
|
|
|
def test_inline_comment_body_with_token_attribution():
|
|
# Operator wants per-comment attribution back: every inline comment shows
|
|
# the attributed output tokens + share of total. Hidden only when no
|
|
# attribution data was computed (legacy callers / ollama path without
|
|
# usage metering).
|
|
f = {"severity": "high", "path": "a", "line": 1, "problem": "p",
|
|
"fix": "f", "suggestion": "", "reference": "",
|
|
"_tok_attrib": 1234, "_tok_pct": 0.30}
|
|
body = inline_comment_body(f)
|
|
assert "🪙 ~1,234 (1.2K) tok" in body
|
|
assert "30%" in body
|
|
assert "attributed output" in body
|
|
|
|
|
|
def test_summary_bullets_format():
|
|
fs = [{"severity": "high", "path": "a.py", "line": 7, "problem": "p", "fix": "f", "suggestion": ""}]
|
|
b = summary_bullets(fs)
|
|
assert "🔴 [HIGH]" in b
|
|
assert "`a.py:7`" in b
|
|
assert "**Fix:** f" in b
|
|
|
|
|
|
def test_summary_bullets_with_reference_link():
|
|
fs = [{"severity": "medium", "path": "x", "line": 1, "problem": "p",
|
|
"fix": "", "suggestion": "", "reference": "https://owasp.org/x"}]
|
|
b = summary_bullets(fs)
|
|
assert "🔗 **Reference:** [owasp.org/x](https://owasp.org/x)" in b
|
|
assert "https://owasp.org/x" in b # URL preserved
|
|
|
|
|
|
def test_findings_table_renders_table():
|
|
fs = [
|
|
{"severity": "high", "path": "a.py", "line": 1, "problem": "bug", "fix": "", "suggestion": "", "reference": ""},
|
|
{"severity": "low", "path": "b.go", "line": 9, "problem": "nit", "fix": "", "suggestion": "", "reference": ""},
|
|
]
|
|
t = findings_table(fs)
|
|
assert t.startswith("| Severity | Location | Finding |")
|
|
assert "|---|---|---|" in t
|
|
assert "🔴 [HIGH]" in t
|
|
assert "🔵 [LOW]" in t
|
|
assert "`a.py:1`" in t
|
|
assert "`b.go:9`" in t
|
|
|
|
|
|
def test_findings_table_escapes_pipes():
|
|
fs = [{"severity": "high", "path": "a", "line": 1,
|
|
"problem": "uses | inside", "fix": "", "suggestion": "", "reference": ""}]
|
|
t = findings_table(fs)
|
|
assert "uses \\| inside" in t
|
|
|
|
|
|
def test_findings_table_empty():
|
|
assert findings_table([]) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# repo config parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_repo_config_full():
|
|
raw = '{"focus":["security","perf"],"exclude_paths":["vendor/**"],"languages":["go"],"instructions":"be strict"}'
|
|
c = parse_repo_config(raw)
|
|
assert c["focus"] == ["security", "perf"]
|
|
assert c["exclude_paths"] == ["vendor/**"]
|
|
assert c["instructions"] == "be strict"
|
|
|
|
|
|
def test_parse_repo_config_partial_and_bad():
|
|
assert parse_repo_config('{"focus":"not-a-list"}') == {"enabled": False}
|
|
assert parse_repo_config('{"focus":["ok"]}') == {"focus": ["ok"], "enabled": False}
|
|
assert parse_repo_config("") == {}
|
|
assert parse_repo_config("not json") == {}
|
|
assert parse_repo_config('{"instructions":" "}') == {"enabled": False}
|
|
|
|
|
|
def test_parse_repo_config_reads_static_message():
|
|
cfg = parse_repo_config(json.dumps({"static_message": " NOTE: this repo is in maintenance mode "}))
|
|
assert cfg.get("static_message") == "NOTE: this repo is in maintenance mode"
|
|
assert cfg.get("enabled") is False
|
|
|
|
|
|
def test_parse_repo_config_static_message_caps_length():
|
|
long_text = "x" * 9999
|
|
cfg = parse_repo_config(json.dumps({"static_message": long_text}))
|
|
assert "static_message" in cfg
|
|
assert len(cfg["static_message"]) <= 400
|
|
|
|
|
|
def test_parse_repo_config_static_message_ignores_blank():
|
|
assert "static_message" not in parse_repo_config(json.dumps({"static_message": " "}))
|
|
assert "static_message" not in parse_repo_config(json.dumps({"static_message": ""}))
|
|
assert "static_message" not in parse_repo_config(json.dumps({"static_message": 42}))
|
|
|
|
|
|
def test_parse_repo_config_reads_model_override():
|
|
# Per-repo override is validated against cost_model.PRICES. Only keys
|
|
# the cost model knows about can override the review engine.
|
|
cfg = parse_repo_config(json.dumps({"model": "claude-sonnet-5"}))
|
|
assert cfg.get("model") == "claude-sonnet-5"
|
|
|
|
|
|
def test_parse_repo_config_rejects_unknown_model(capsys):
|
|
cfg = parse_repo_config(json.dumps({"model": "not-in-prices"}))
|
|
assert "model" not in cfg
|
|
# Repos that pin a typo should get a stderr hint pointing at the valid set.
|
|
err = capsys.readouterr().err
|
|
assert "model" in err.lower() or "prices" in err.lower() or "unknown" in err.lower()
|
|
|
|
|
|
def test_parse_repo_config_model_must_be_string():
|
|
assert "model" not in parse_repo_config(json.dumps({"model": 42}))
|
|
assert "model" not in parse_repo_config(json.dumps({"model": []}))
|
|
assert "model" not in parse_repo_config(json.dumps({"model": None}))
|
|
|
|
|
|
def test_resolve_display_model_precedence(monkeypatch):
|
|
# Order is OPENCODE_MODEL env > config['model'] (re-prefixed by provider) > headroom/{base}.
|
|
monkeypatch.delenv("OPENCODE_MODEL", raising=False)
|
|
# 1. No env, no config → headroom/<base>
|
|
assert ai_review._resolve_display_model("MiniMax-M2.7", None) == "headroom/MiniMax-M2.7"
|
|
assert ai_review._resolve_display_model("MiniMax-M2.7", {}) == "headroom/MiniMax-M2.7"
|
|
# 2. No env, config has a PRICES key → re-prefixed with that model's provider.
|
|
# headroom-hosted models default to provider="headroom".
|
|
assert (
|
|
ai_review._resolve_display_model("MiniMax-M2.7", {"model": "claude-sonnet-5"})
|
|
== "headroom/claude-sonnet-5"
|
|
)
|
|
# Self-hosted models carry provider="vllm-qwen38" → routes to the
|
|
# matching provider block in opencode.json (AI workstation on
|
|
# 192.168.1.79:18020).
|
|
assert (
|
|
ai_review._resolve_display_model("MiniMax-M2.7", {"model": "qwen3.8-27b"})
|
|
== "vllm-qwen38/qwen3.8-27b"
|
|
)
|
|
# 3. Env wins over config
|
|
monkeypatch.setenv("OPENCODE_MODEL", "headroom/MiniMax-M2.7")
|
|
assert (
|
|
ai_review._resolve_display_model("MiniMax-M2.7", {"model": "claude-sonnet-5"})
|
|
== "headroom/MiniMax-M2.7"
|
|
)
|
|
# 4. Env alone, no config
|
|
monkeypatch.delenv("OPENCODE_MODEL")
|
|
assert ai_review._resolve_display_model("x", {}) == "headroom/x"
|
|
|
|
|
|
def test_format_review_body_uses_override_for_cost_paren():
|
|
# End-to-end sanity: when the caller passes the resolved override as the
|
|
# `model` arg to format_review_body, both the header AND the cost line
|
|
# show the override — i.e. callers DO substitute the resolved display
|
|
# name into both the opencode subprocess ref and the review body.
|
|
body = format_review_body(
|
|
"- [high] x:1 — bug. fix.", "claude-sonnet-5", "abcdef1234567890",
|
|
)
|
|
assert "claude-sonnet-5" in body
|
|
assert "MiniMax-M2.7" not in body # the base didn't leak through
|
|
assert "🤖" in body # header rendered
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# dedupe / prior-context parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_reviewed_shas_extracts_marker():
|
|
reviews = [
|
|
{"body": "🤖 AI Review · glm · `abcdef12`\n\n<!-- pragent:sha=abcdef1234567890 -->"},
|
|
{"body": "human comment, no marker"},
|
|
{"body": "<!-- pragent:sha=0987654 -->"},
|
|
]
|
|
shas = reviewed_shas(reviews)
|
|
assert "abcdef1234567890" in shas
|
|
assert "0987654" in shas
|
|
|
|
|
|
def test_reviewed_shas_empty():
|
|
assert reviewed_shas([]) == set()
|
|
assert reviewed_shas([{"body": "no marker"}]) == set()
|
|
|
|
|
|
def test_prior_review_bodies_skips_current_sha():
|
|
reviews = [
|
|
{"body": "r1\n<!-- pragent:sha=1111111 -->"},
|
|
{"body": "r2\n<!-- pragent:sha=2222222 -->"},
|
|
{"body": "no marker here"},
|
|
]
|
|
prior = prior_review_bodies(reviews, current_sha="2222222")
|
|
assert len(prior) == 1
|
|
assert "r1" in prior[0]
|
|
|
|
|
|
def test_format_review_body_has_sha_marker():
|
|
body = format_review_body("- [high] x:1 — b", "glm-5.2:cloud", "abcdef1234567890")
|
|
assert "<!-- pragent:sha=abcdef1234567890 -->" in body
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_review_output (opencode engine: {summary, findings} + reference)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_review_output_summary_and_findings():
|
|
txt = (
|
|
"This PR adds an eval helper — risky. See findings.\n\n"
|
|
"```json\n"
|
|
'{"summary":"Adds eval() — security risk.","findings":['
|
|
'{"severity":"critical","path":"src/x.ts","line":4,"problem":"eval on user input",'
|
|
'"fix":"parse explicitly","suggestion":"const n = Number(s)","reference":"https://owasp.org/x"}'
|
|
"]}",
|
|
"\n```",
|
|
)
|
|
summary, fs, *_ = parse_review_output("".join(txt))
|
|
assert "eval()" in summary
|
|
assert len(fs) == 1
|
|
assert fs[0]["severity"] == "critical"
|
|
assert fs[0]["reference"] == "https://owasp.org/x"
|
|
assert fs[0]["suggestion"] == "const n = Number(s)"
|
|
|
|
|
|
def test_parse_review_output_bare_findings_no_summary():
|
|
txt = '```json\n{"findings":[{"severity":"low","path":"a","line":1,"problem":"p"}]}\n```'
|
|
summary, fs, *_ = parse_review_output(txt)
|
|
assert summary == ""
|
|
assert len(fs) == 1
|
|
assert fs[0]["reference"] == "" # default
|
|
|
|
|
|
def test_parse_review_output_empty_and_bogus():
|
|
assert parse_review_output("") == ("", [], [], [], [], "", "")
|
|
assert parse_review_output("no json here") == ("", [], [], [], [], "", "")
|
|
assert parse_review_output('{"findings":[]}') == ("", [], [], [], [], "", "")
|
|
|
|
|
|
def test_parse_review_output_uses_last_json_block():
|
|
# Agent emits a stray json-ish block first, then the real one last.
|
|
txt = (
|
|
"```json\n{\"findings\":[{\"path\":\"x\",\"line\":1,\"severity\":\"low\"}]}\n```\n"
|
|
"more prose\n"
|
|
"```json\n{\"summary\":\"real\",\"findings\":[{\"path\":\"y\",\"line\":2,\"severity\":\"high\"}]}\n```"
|
|
)
|
|
summary, fs, *_ = parse_review_output(txt)
|
|
assert summary == "real"
|
|
assert len(fs) == 1
|
|
assert fs[0]["path"] == "y"
|
|
|
|
|
|
def test_parse_findings_fenced_json_with_nested_object():
|
|
# Real-world regression: agent emits a fence whose inner JSON has nested
|
|
# objects. The old regex `\{.*?\}` matched only the first `}`, truncating
|
|
# the JSON. Now we balance braces inside the fence.
|
|
txt = (
|
|
"```json\n"
|
|
'{"summary":"x","findings":[{"severity":"high","path":"a.py","line":1,'
|
|
'"problem":"p","fix":"f","suggestion":"","reference":""}],"meta":{"engine":"opencode"}}\n'
|
|
"```"
|
|
)
|
|
fs = parse_findings(txt)
|
|
assert len(fs) == 1
|
|
assert fs[0]["path"] == "a.py"
|
|
|
|
|
|
def test_parse_findings_unfenced_at_tail():
|
|
# No fence at all. Agent wrote the JSON inline at the very end of its
|
|
# prose. The old first-balanced regex caught the FIRST `{`, not this one.
|
|
txt = (
|
|
"I considered the diff carefully. Two findings stand out:\n"
|
|
"First one is just text.\n"
|
|
'{"findings":[{"severity":"critical","path":"x","line":1,"problem":"p","fix":"f"}]}'
|
|
)
|
|
fs = parse_findings(txt)
|
|
assert len(fs) == 1
|
|
assert fs[0]["severity"] == "critical"
|
|
|
|
|
|
def test_parse_findings_bare_array():
|
|
# Some agents skip the `{"summary":..., "findings":[...]}` wrapper and
|
|
# emit just the array.
|
|
txt = (
|
|
"Here are my findings:\n"
|
|
"```json\n"
|
|
'[{"severity":"low","path":"a","line":1,"problem":"p","fix":"f","suggestion":"","reference":""}]\n'
|
|
"```"
|
|
)
|
|
fs = parse_findings(txt)
|
|
assert len(fs) == 1
|
|
assert fs[0]["path"] == "a"
|
|
|
|
|
|
def test_parse_review_output_unfenced_at_tail():
|
|
# The exact shape canalhandia produced: long prose, JSON at the very end,
|
|
# no fence. Old parser returned ([], salvage) — now we recover findings.
|
|
txt = (
|
|
"Let me refine the fix: should call a dedicated `setPermanent`.\n"
|
|
"Let me finalize. Let me also double-check the `find` thread-safety.\n"
|
|
'{"summary":"Adds void protection; one critical race.","findings":['
|
|
'{"severity":"high","path":"VoidProtection.java","line":162,'
|
|
'"problem":"drop duplication race","fix":"use ItemMeta","suggestion":"","reference":""}]}'
|
|
)
|
|
summary, fs, *_ = parse_review_output(txt)
|
|
assert "void protection" in summary.lower()
|
|
assert len(fs) == 1
|
|
assert fs[0]["path"] == "VoidProtection.java"
|
|
|
|
|
|
def test_parse_review_output_bare_array_at_tail():
|
|
txt = (
|
|
"All wrapped up.\n"
|
|
'[{"severity":"low","path":"a","line":1,"problem":"p","fix":"","suggestion":"","reference":""}]'
|
|
)
|
|
summary, fs, *_ = parse_review_output(txt)
|
|
assert summary == ""
|
|
assert len(fs) == 1
|
|
|
|
|
|
def test_parse_review_output_extracts_walkthrough_risk_tests():
|
|
# The 7-tuple shape carries three new top-level fields:
|
|
# walkthrough (list[str]), risk_verdict (str), test_coverage (str).
|
|
txt = (
|
|
"```json\n"
|
|
"{\n"
|
|
' "summary": "x",\n'
|
|
' "summary_changes": [],\n'
|
|
' "risks": [],\n'
|
|
' "walkthrough": ["a.py: adds X", "b.py: refactors Y"],\n'
|
|
' "risk_verdict": "Low risk.",\n'
|
|
' "test_coverage": "No tests for behavioral change in a.py.",\n'
|
|
' "findings": []\n'
|
|
"}\n"
|
|
"```"
|
|
)
|
|
summary, findings, _changes, _risks, walkthrough, risk_verdict, test_coverage = (
|
|
parse_review_output(txt)
|
|
)
|
|
assert summary == "x"
|
|
assert findings == []
|
|
assert walkthrough == ["a.py: adds X", "b.py: refactors Y"]
|
|
assert risk_verdict == "Low risk."
|
|
assert test_coverage == "No tests for behavioral change in a.py."
|
|
|
|
|
|
def test_parse_review_output_missing_fields_default_empty():
|
|
# Backward-compatible: the 4-tuple shape still parses fine; the new
|
|
# fields default to empty list / empty string.
|
|
out = parse_review_output('{"summary":"x","findings":[]}')
|
|
summary, findings, _changes, _risks, walkthrough, risk_verdict, test_coverage = out
|
|
assert summary == "x"
|
|
assert findings == []
|
|
assert walkthrough == []
|
|
assert risk_verdict == ""
|
|
assert test_coverage == ""
|
|
|
|
|
|
def test_scan_balanced_handles_braces_in_strings():
|
|
# The JSON scanner must not be fooled by `{` or `}` inside string literals.
|
|
s = '{"a":"contains { and }","b":1}'
|
|
obj = _extract_first_json_object(s)
|
|
assert obj == s
|
|
d = json.loads(obj)
|
|
assert d["a"] == "contains { and }"
|
|
|
|
|
|
def test_last_balanced_json_picks_latest():
|
|
s = '{"a":1} some text {"b":2,"nested":{"c":3}} trailing'
|
|
out = _last_balanced_json(s)
|
|
assert out is not None
|
|
d = json.loads(out)
|
|
assert d == {"b": 2, "nested": {"c": 3}}
|
|
|
|
|
|
def test_last_balanced_json_no_json():
|
|
assert _last_balanced_json("nothing here") is None
|
|
assert _last_balanced_json("") is None
|
|
|
|
|
|
def test_balanced_json_substring_skips_leading_prose():
|
|
s = 'preamble {"a":1} more prose {"b":2}'
|
|
out = _balanced_json_substring(s)
|
|
assert out == '{"a":1}'
|
|
|
|
|
|
def test_balanced_json_substring_handles_array():
|
|
s = '[{"a":1},{"b":2}]'
|
|
out = _balanced_json_substring(s)
|
|
assert out == s
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# reference rendering in inline_comment_body + summary_bullets + summary section
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_inline_comment_body_renders_reference():
|
|
f = {"severity": "high", "path": "a", "line": 1, "problem": "p", "fix": "f",
|
|
"suggestion": "", "reference": "https://cve.example/X"}
|
|
body = inline_comment_body(f)
|
|
# Per spec: Markdown hyperlink, not raw URL.
|
|
assert "🔗 **Reference:** [cve.example/X](https://cve.example/X)" in body
|
|
|
|
|
|
def test_reference_non_url_renders_as_plain_text():
|
|
# A CVE id or doc title is not a URL. `[CVE-2024-1](CVE-2024-1)` renders as
|
|
# a broken *relative* link in Gitea, so bare text is the correct fallback.
|
|
assert ai_review._format_reference("CVE-2024-1234") == "CVE-2024-1234"
|
|
assert ai_review._format_reference("see OWASP A03") == "see OWASP A03"
|
|
assert ai_review._format_reference("") == ""
|
|
f = {"severity": "high", "path": "a", "line": 1, "problem": "p", "fix": "",
|
|
"suggestion": "", "reference": "CVE-2024-1234"}
|
|
body = inline_comment_body(f)
|
|
assert "🔗 **Reference:** CVE-2024-1234" in body
|
|
assert "](CVE-" not in body
|
|
|
|
|
|
def test_int_env_falls_back_on_garbage(monkeypatch, capsys):
|
|
monkeypatch.setenv("PRAGENT_DIFF_CONTEXT", "two")
|
|
assert ai_review._int_env("PRAGENT_DIFF_CONTEXT", 1) == 1
|
|
assert "ignoring PRAGENT_DIFF_CONTEXT" in capsys.readouterr().err
|
|
monkeypatch.setenv("PRAGENT_DIFF_CONTEXT", " 3 ")
|
|
assert ai_review._int_env("PRAGENT_DIFF_CONTEXT", 1) == 3
|
|
monkeypatch.setenv("PRAGENT_DIFF_CONTEXT", "")
|
|
assert ai_review._int_env("PRAGENT_DIFF_CONTEXT", 1) == 1
|
|
monkeypatch.delenv("PRAGENT_DIFF_CONTEXT")
|
|
assert ai_review._int_env("PRAGENT_DIFF_CONTEXT", -1) == -1
|
|
|
|
|
|
def test_inline_comment_body_no_reference_no_ref_line():
|
|
f = {"severity": "low", "path": "a", "line": 1, "problem": "p", "fix": "",
|
|
"suggestion": "", "reference": ""}
|
|
assert "🔗" not in inline_comment_body(f)
|
|
assert "Reference:" not in inline_comment_body(f)
|
|
|
|
|
|
def test_inline_comment_body_reference_truncates_long_url():
|
|
f = {"severity": "high", "path": "a", "line": 1, "problem": "p", "fix": "",
|
|
"suggestion": "",
|
|
"reference": "https://very-long-domain.example.com/some/very/long/path/that/exceeds/the/sixty/char/limit/x"}
|
|
body = inline_comment_body(f)
|
|
# Visible label is truncated to ≤60 chars (ellipsis added).
|
|
assert "…" in body
|
|
# But the underlying URL is preserved verbatim inside the link target.
|
|
assert "very-long-domain.example.com" in body
|
|
|
|
|
|
def test_summary_bullets_renders_reference():
|
|
fs = [{"severity": "high", "path": "a.py", "line": 7, "problem": "p", "fix": "f",
|
|
"suggestion": "", "reference": "https://r.example"}]
|
|
b = summary_bullets(fs)
|
|
assert "https://r.example" in b
|
|
assert "`a.py:7`" in b
|
|
|
|
|
|
def test_format_review_body_with_summary_section():
|
|
body = format_review_body("- [high] x:1 — b", "glm-5.2:cloud", "abcdef1234567890",
|
|
summary="This PR adds a risky helper.")
|
|
assert "This PR adds a risky helper." in body
|
|
assert "- [high] x:1" in body
|
|
assert "<!-- pragent:sha=abcdef1234567890 -->" in body
|
|
# summary appears before the findings bullets
|
|
assert body.index("risky helper") < body.index("[high]")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AI-USAGE: compute_attribution + usage block + inline 🪙 line
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_compute_attribution_weighted_split():
|
|
# weights 1 (problem="a") and 3 (problem="aaa"), output 100 → 25 / 75
|
|
fs = [
|
|
{"severity": "high", "path": "x", "line": 1, "problem": "a", "fix": "", "suggestion": ""},
|
|
{"severity": "low", "path": "x", "line": 2, "problem": "aaa", "fix": "", "suggestion": ""},
|
|
]
|
|
compute_attribution(fs, 100)
|
|
assert fs[0]["_tok_attrib"] == 25
|
|
assert fs[1]["_tok_attrib"] == 75
|
|
assert abs(fs[0]["_tok_pct"] - 0.25) < 1e-9
|
|
assert abs(fs[1]["_tok_pct"] - 0.75) < 1e-9
|
|
|
|
|
|
def test_compute_attribution_zero_weights_splits_evenly():
|
|
fs = [
|
|
{"severity": "high", "path": "x", "line": 1, "problem": "", "fix": "", "suggestion": ""},
|
|
{"severity": "low", "path": "x", "line": 2, "problem": "", "fix": "", "suggestion": ""},
|
|
]
|
|
compute_attribution(fs, 80)
|
|
assert fs[0]["_tok_attrib"] == 40
|
|
assert fs[1]["_tok_attrib"] == 40
|
|
assert abs(fs[0]["_tok_pct"] - 0.5) < 1e-9
|
|
|
|
|
|
def test_compute_attribution_noop_on_empty_or_zero_budget():
|
|
fs = [{"severity": "high", "path": "x", "line": 1, "problem": "a", "fix": "", "suggestion": ""}]
|
|
compute_attribution([], 100)
|
|
compute_attribution(fs, 0)
|
|
assert "_tok_attrib" not in fs[0]
|
|
|
|
|
|
def test_inline_comment_body_with_attribution_line():
|
|
# Operator wants per-comment attribution back: every inline comment shows
|
|
# the attributed output tokens + share of total. Hidden only when no
|
|
# attribution data was computed (legacy callers / ollama path without
|
|
# usage metering).
|
|
f = {"severity": "high", "path": "a", "line": 1, "problem": "bad", "fix": "swap",
|
|
"suggestion": "", "_tok_attrib": 180, "_tok_pct": 0.29}
|
|
body = inline_comment_body(f)
|
|
assert "🪙 ~180 tok" in body
|
|
assert "29%" in body
|
|
assert "attributed output" in body
|
|
|
|
|
|
def test_inline_comment_body_no_attribution_no_coin_line():
|
|
f = {"severity": "high", "path": "a", "line": 1, "problem": "bad", "fix": "swap",
|
|
"suggestion": ""}
|
|
assert "🪙" not in inline_comment_body(f)
|
|
|
|
|
|
def test_render_collapsible_usage_renders_totals():
|
|
usage = {"input": 18420, "output": 612, "reasoning": 0, "cache_read": 15210,
|
|
"cache_write": 0, "total": 19032, "cost": 0.0, "steps": 7, "duration_s": 142.0}
|
|
sec = _render_collapsible_usage(usage, "glm-5.2:cloud", config=None)
|
|
assert "🔋 AI Usage & Run Details" in sec
|
|
assert "`glm-5.2:cloud`" in sec
|
|
assert "7 steps" in sec
|
|
assert "142.0s" in sec
|
|
assert "18,420 (18.4K) in / 612 out" in sec and "19,032 (19.0K) total" in sec
|
|
assert "$0.00" in sec
|
|
assert "Whole-repo checkout" in sec
|
|
assert "attributed" in sec
|
|
|
|
|
|
def test_render_collapsible_usage_none_returns_empty():
|
|
assert _render_collapsible_usage(None, "m", config=None) == ""
|
|
|
|
|
|
def test_render_collapsible_usage_cost_nonzero_drops_free_tier_note():
|
|
usage = {"input": 10, "output": 0, "reasoning": 0, "cache_read": 0,
|
|
"cache_write": 0, "total": 10, "cost": 0.0123, "steps": 1, "duration_s": 1.0}
|
|
sec = _render_collapsible_usage(usage, "m", config=None)
|
|
assert "$0.0123" in sec
|
|
# Was hardcoded "free tier" previously; now says "billed" since cost > 0.
|
|
assert "billed" in sec
|
|
assert "free tier" not in sec
|
|
|
|
|
|
def test_render_collapsible_usage_uses_passed_model_for_free_tier_clause():
|
|
# Regression: the cost parenthetical must reflect the actually-routed model,
|
|
# not a stale hardcoded `headroom glm-5.2:cloud` literal that predates the
|
|
# MiniMax / Anthropic switch.
|
|
usage = {"input": 10, "output": 0, "reasoning": 0, "cache_read": 0,
|
|
"cache_write": 0, "total": 10, "cost": 0.0, "steps": 1, "duration_s": 1.0}
|
|
sec = _render_collapsible_usage(usage, "MiniMax-M2.7", config=None)
|
|
# The parenthetical clause is "(<model> — free tier)" — a model name MUST
|
|
# sit immediately before "— free tier".
|
|
assert "(MiniMax-M2.7 — free tier)" in sec
|
|
# And the stale hardcoded model name must no longer appear anywhere.
|
|
assert "glm-5.2:cloud" not in sec
|
|
|
|
|
|
def test_render_collapsible_usage_full_provider_prefix_in_display():
|
|
# When the caller has resolved a provider-prefixed model ref (the opencode
|
|
# subprocess path), the parenthetical should mirror that verbatim.
|
|
usage = {"input": 10, "output": 0, "reasoning": 0, "cache_read": 0,
|
|
"cache_write": 0, "total": 10, "cost": 0.0, "steps": 1, "duration_s": 1.0}
|
|
sec = _render_collapsible_usage(usage, "headroom/MiniMax-M2.7", config=None)
|
|
assert "(headroom/MiniMax-M2.7 — free tier)" in sec
|
|
|
|
|
|
def test_render_collapsible_usage_nonzero_cost_says_billed():
|
|
usage = {"input": 10, "output": 0, "reasoning": 0, "cache_read": 0,
|
|
"cache_write": 0, "total": 10, "cost": 0.123, "steps": 1, "duration_s": 1.0}
|
|
sec = _render_collapsible_usage(usage, "MiniMax-M2.7", config=None)
|
|
assert "(MiniMax-M2.7 — billed)" in sec
|
|
assert "free tier" not in sec
|
|
|
|
|
|
def test_format_review_body_usage_section_below_findings():
|
|
# New layout: header → Summary of Changes → Key Risks → findings → usage.
|
|
usage_sec = "## 🔋 AI usage\n\n- model: `m`"
|
|
body = format_review_body("- [high] x:1 — b", "glm-5.2:cloud", "abcdef1234567890",
|
|
summary="This PR is risky.", usage_section=usage_sec)
|
|
assert body.index("risky.") < body.index("[high]")
|
|
assert body.index("[high]") < body.index("AI usage")
|
|
assert body.index("AI usage") < body.index("<!-- pragent:sha=")
|
|
assert "## 🔋 AI usage" in body
|
|
|
|
|
|
def test_format_review_body_no_usage_section_omitted():
|
|
body = format_review_body("- [high] x:1 — b", "glm-5.2:cloud", "abcdef1234567890")
|
|
assert "AI usage" not in body
|
|
|
|
|
|
def test_format_review_body_renders_static_message_banner():
|
|
body = format_review_body(
|
|
"", "glm-5.2:cloud", "abcdef1234567890",
|
|
static_message="NOTE: this repo is in maintenance mode.",
|
|
)
|
|
assert "> NOTE: this repo is in maintenance mode." in body
|
|
# Banner sits under the header and above the rest of the body.
|
|
assert body.index("NOTE") > body.index("🤖")
|
|
assert body.index("NOTE") < body.index("### Summary of Changes")
|
|
|
|
|
|
def test_format_review_body_omits_static_message_when_blank():
|
|
body = format_review_body("", "glm-5.2:cloud", "abcdef1234567890")
|
|
assert "> " not in body
|
|
|
|
|
|
def test_format_review_body_with_summary_changes_and_risks():
|
|
body = format_review_body(
|
|
"", "glm-5.2:cloud", "abcdef1234567890",
|
|
summary_changes=["Adds void-death item rescue.", "Adds chunk-loader pause/rename."],
|
|
risks=["Mob spawn leak in force-loaded chunks.", "Race on /chunkloader tempo -1."],
|
|
findings_for_table=[
|
|
{"severity": "high", "path": "a.py", "line": 1, "problem": "race", "fix": "",
|
|
"suggestion": "", "reference": ""},
|
|
],
|
|
inline_count=1,
|
|
)
|
|
assert "### Summary of Changes" in body
|
|
assert "Adds void-death item rescue." in body
|
|
assert "Adds chunk-loader pause/rename." in body
|
|
assert "### Key Risks & Concerns" in body
|
|
assert "Mob spawn leak in force-loaded chunks." in body
|
|
assert "### Findings Overview" in body
|
|
assert "1 inline comment(s); 1 total." in body
|
|
assert "🔴 [HIGH]" in body
|
|
assert "`a.py:1`" in body
|
|
|
|
|
|
def test_format_review_body_renders_walkthrough():
|
|
body = format_review_body(
|
|
"", "glm-5.2:cloud", "abc1234",
|
|
summary_changes=["adds X"],
|
|
risks=[],
|
|
walkthrough=["a.py — adds X", "b.py — refactors Y"],
|
|
risk_verdict="Low risk: clean.",
|
|
test_coverage="Tests added.",
|
|
findings_for_table=[],
|
|
)
|
|
assert "### Walkthrough" in body
|
|
assert "`a.py` — adds X" in body
|
|
assert "### Risk Verdict" in body
|
|
assert "Low risk: clean." in body
|
|
assert "### Test Coverage" in body
|
|
assert "Tests added." in body
|
|
|
|
|
|
def test_format_review_body_omits_empty_sections():
|
|
body = format_review_body(
|
|
"", "glm-5.2:cloud", "abc1234",
|
|
summary_changes=["adds X"],
|
|
walkthrough=[], risk_verdict="", test_coverage="",
|
|
)
|
|
assert "### Walkthrough" not in body
|
|
assert "### Risk Verdict" not in body
|
|
assert "### Test Coverage" not in body
|
|
|
|
|
|
def test_format_review_body_placeholder_when_empty():
|
|
body = format_review_body(
|
|
"", "glm-5.2:cloud", "abc1234",
|
|
walkthrough=[], risk_verdict="", test_coverage="",
|
|
)
|
|
assert body # non-empty
|
|
|
|
|
|
def test_render_collapsible_usage_contains_details():
|
|
usage = {
|
|
"model": "glm-5.2:cloud", "input": 1000, "output": 200, "reasoning": 0,
|
|
"cache_read": 0, "cache_write": 0, "total": 1200, "steps": 5, "duration_s": 12.0,
|
|
"cost": 0.0,
|
|
}
|
|
block = _render_collapsible_usage(usage, "glm-5.2:cloud", config=None)
|
|
assert "<details>" in block
|
|
assert "<summary>🔋 AI Usage & Run Details</summary>" in block
|
|
assert "</details>" in block
|
|
assert "glm-5.2:cloud" in block
|
|
assert "1,000 (1.0K) in / 200 out" in block
|
|
|
|
|
|
def test_render_collapsible_usage_empty_when_no_usage():
|
|
assert _render_collapsible_usage(None, "glm-5.2:cloud", config=None) == ""
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_diff_anchors — empty context lines
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_diff_anchors_counts_empty_context_line():
|
|
# A context line that is *blank* arrives as "" when trailing whitespace was
|
|
# stripped somewhere upstream. If it isn't counted, every later line in the
|
|
# hunk is off by one.
|
|
diff = (
|
|
"diff --git a/x.py b/x.py\n"
|
|
"--- a/x.py\n"
|
|
"+++ b/x.py\n"
|
|
"@@ -1,4 +1,5 @@\n"
|
|
" import os\n"
|
|
"\n" # blank context line, whitespace stripped
|
|
" def f():\n"
|
|
"+ return 1\n"
|
|
" # tail\n"
|
|
)
|
|
anchors = parse_diff_anchors(diff)
|
|
assert anchors["x.py"] == {1, 2, 3, 4, 5}
|
|
|
|
|
|
def test_parse_diff_anchors_space_prefixed_blank_line_still_counts():
|
|
diff = (
|
|
"+++ b/y.py\n"
|
|
"@@ -1,3 +1,4 @@\n"
|
|
" a\n"
|
|
" \n" # properly space-prefixed blank context line
|
|
"+b\n"
|
|
" c\n"
|
|
)
|
|
assert parse_diff_anchors(diff)["y.py"] == {1, 2, 3, 4}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_repo_config — caps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_repo_config_caps_instructions_length():
|
|
cfg = parse_repo_config(json.dumps({"instructions": "x" * 99999}))
|
|
assert len(cfg["instructions"]) == ai_review.CONFIG_MAX_INSTRUCTIONS_CHARS
|
|
|
|
|
|
def test_parse_repo_config_caps_list_length_and_items():
|
|
cfg = parse_repo_config(json.dumps({
|
|
"focus": ["a" * 9999] * 500,
|
|
"exclude_paths": ["vendor/**"],
|
|
}))
|
|
assert len(cfg["focus"]) == ai_review.CONFIG_MAX_LIST_ITEMS
|
|
assert all(len(x) == ai_review.CONFIG_MAX_ITEM_CHARS for x in cfg["focus"])
|
|
assert cfg["exclude_paths"] == ["vendor/**"]
|
|
|
|
|
|
def test_parse_repo_config_still_accepts_normal_config():
|
|
cfg = parse_repo_config(json.dumps({
|
|
"focus": ["security"], "languages": ["go"], "instructions": "No bare throw.",
|
|
}))
|
|
assert cfg == {
|
|
"focus": ["security"], "languages": ["go"], "instructions": "No bare throw.",
|
|
"enabled": False,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch_repo_config — reads the BASE ref, never the PR head
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stub_config_response(payload: dict):
|
|
blob = base64.b64encode(json.dumps(payload).encode()).decode()
|
|
return 200, json.dumps({"content": blob}).encode()
|
|
|
|
|
|
def test_fetch_repo_config_uses_given_base_ref(monkeypatch):
|
|
seen = {}
|
|
|
|
def fake_get(api, repo, path, token, accept="application/json"):
|
|
seen["path"] = path
|
|
return _stub_config_response({"focus": ["security"]})
|
|
|
|
monkeypatch.setattr(ai_review, "gitea_get", fake_get)
|
|
cfg = ai_review.fetch_repo_config("http://g", "o/r", "tok", ref="main")
|
|
assert cfg == {"focus": ["security"], "enabled": False}
|
|
assert seen["path"] == "contents/.pr-review.json?ref=main"
|
|
|
|
|
|
def test_fetch_repo_config_without_ref_omits_ref_param(monkeypatch):
|
|
seen = {}
|
|
|
|
def fake_get(api, repo, path, token, accept="application/json"):
|
|
seen["path"] = path
|
|
return _stub_config_response({})
|
|
|
|
monkeypatch.setattr(ai_review, "gitea_get", fake_get)
|
|
ai_review.fetch_repo_config("http://g", "o/r", "tok")
|
|
assert "?ref=" not in seen["path"]
|
|
|
|
|
|
def test_fetch_repo_config_quotes_ref_with_slashes(monkeypatch):
|
|
seen = {}
|
|
|
|
def fake_get(api, repo, path, token, accept="application/json"):
|
|
seen["path"] = path
|
|
return _stub_config_response({})
|
|
|
|
monkeypatch.setattr(ai_review, "gitea_get", fake_get)
|
|
ai_review.fetch_repo_config("http://g", "o/r", "tok", ref="release/v1 x")
|
|
assert "release%2Fv1%20x" in seen["path"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# post_inline_review — degraded fallback must not lose findings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_post_inline_review_fallback_keeps_anchored_findings(monkeypatch):
|
|
posted = []
|
|
|
|
def fake_post(api, repo, path, token, body):
|
|
posted.append((path, body))
|
|
# Reject the inline review, accept the plain one.
|
|
if "reviews" in path and body.get("comments"):
|
|
return 422, b"bad line"
|
|
return 201, b"{}"
|
|
|
|
monkeypatch.setattr(ai_review, "gitea_post", fake_post)
|
|
anchored = [{
|
|
"severity": "high", "path": "a.py", "line": 7,
|
|
"problem": "off-by-one", "fix": "use <=", "suggestion": "", "reference": "",
|
|
}]
|
|
ai_review.post_inline_review("http://g", "o/r", "1", "tok", "SUMMARY", anchored)
|
|
|
|
final_body = posted[-1][1]["body"]
|
|
assert "off-by-one" in final_body
|
|
assert "a.py:7" in final_body
|
|
assert "SUMMARY" in final_body
|
|
|
|
|
|
def test_post_inline_review_success_posts_no_fallback(monkeypatch):
|
|
posted = []
|
|
|
|
def fake_post(api, repo, path, token, body):
|
|
posted.append(path)
|
|
return 201, b"{}"
|
|
|
|
monkeypatch.setattr(ai_review, "gitea_post", fake_post)
|
|
ai_review.post_inline_review("http://g", "o/r", "1", "tok", "S", [])
|
|
assert len(posted) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch_pr_diff — files-endpoint fallback
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fetch_pr_diff_fallback_emits_git_style_prefixes(monkeypatch):
|
|
def fake_get(api, repo, path, token, accept="application/json"):
|
|
if path.endswith(".diff"):
|
|
return 404, b"nope"
|
|
return 200, json.dumps([
|
|
{"filename": "src/a.py", "patch": "@@ -1 +1,2 @@\n a\n+b"},
|
|
]).encode()
|
|
|
|
monkeypatch.setattr(ai_review, "gitea_get", fake_get)
|
|
diff, truncated, _ = ai_review.fetch_pr_diff("http://g", "o/r", "1", "tok", 10000)
|
|
assert "--- a/src/a.py" in diff
|
|
assert "+++ b/src/a.py" in diff
|
|
assert truncated is False
|
|
# And the synthesized diff must actually anchor.
|
|
assert parse_diff_anchors(diff)["src/a.py"] == {1, 2}
|
|
|
|
|
|
def test_fetch_pr_diff_error_reports_both_statuses(monkeypatch):
|
|
def fake_get(api, repo, path, token, accept="application/json"):
|
|
return (404, b"") if path.endswith(".diff") else (500, b"")
|
|
|
|
monkeypatch.setattr(ai_review, "gitea_get", fake_get)
|
|
try:
|
|
ai_review.fetch_pr_diff("http://g", "o/r", "1", "tok", 10000)
|
|
except RuntimeError as e:
|
|
assert ".diff=404" in str(e)
|
|
assert "files=500" in str(e)
|
|
else:
|
|
raise AssertionError("expected RuntimeError")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# salvage_summary — don't discard an expensive run over a missing JSON block
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_salvage_summary_keeps_the_prose():
|
|
text = "I reviewed the diff. The retry loop in worker.py never terminates."
|
|
out = ai_review.salvage_summary(text)
|
|
assert "never terminates" in out
|
|
assert "unverified" in out
|
|
|
|
|
|
def test_salvage_summary_drops_fenced_blocks():
|
|
text = 'Analysis here.\n\n```json\n{"findings": [ truncated...\n'
|
|
out = ai_review.salvage_summary(text)
|
|
assert "Analysis here." in out
|
|
# The half-written JSON blob is gone (the banner legitimately says
|
|
# "findings", so assert on the blob's own content instead).
|
|
assert "truncated..." not in out
|
|
assert "[" not in out.split("_\n\n", 1)[1]
|
|
|
|
|
|
def test_salvage_summary_drops_complete_fences_too():
|
|
text = "Before.\n```python\nprint(1)\n```\nAfter."
|
|
out = ai_review.salvage_summary(text)
|
|
assert "Before." in out and "After." in out
|
|
assert "print(1)" not in out
|
|
|
|
|
|
def test_salvage_summary_keeps_the_tail_when_long():
|
|
text = "x" * 9000 + " FINAL CONCLUSION"
|
|
out = ai_review.salvage_summary(text, max_chars=1000)
|
|
assert "FINAL CONCLUSION" in out # the conclusion is written last
|
|
assert len(out) < 1600
|
|
|
|
|
|
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 + usage-block 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_usage_block_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._render_collapsible_usage(usage, "glm-5.2:cloud", config=None)
|
|
# New layout: equivalent-cost table instead of a single "Est. cost on …"
|
|
# line. The default compare_against is sonnet-5, gpt-5, gemini-2.5-pro,
|
|
# grok-4.5; cost_target defaults to sonnet-5 (bolded).
|
|
assert "🔋 AI Usage & Run Details" in sec
|
|
assert "**Actual**: $0.00" in sec
|
|
# The "free tier" clause must mention the routed model verbatim, not the
|
|
# stale hardcoded `headroom glm-5.2:cloud` literal.
|
|
assert "free tier" in sec
|
|
assert "glm-5.2:cloud" in sec
|
|
# Equivalent should be > 0 for non-trivial token counts.
|
|
assert "$0.00" in sec # the actual line
|
|
# Multi-provider table header present, default roster rendered, default
|
|
# cost_target (Sonnet 5) is the bolded row.
|
|
assert "| Provider | Cost |" in sec
|
|
assert "**Claude Sonnet 5**" in sec
|
|
assert "GPT-5" in sec
|
|
assert "Gemini 2.5 Pro" in sec
|
|
assert "Grok 4.5" in sec
|
|
# 200k * $2/MTok + 4k * $10/MTok → $0.44
|
|
assert "$0.44" in sec
|
|
|
|
|
|
def test_usage_block_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._render_collapsible_usage(usage, "glm-5.2:cloud", config=None)
|
|
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_usage_block_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._render_collapsible_usage(
|
|
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_usage_block_reports_unknown_price_target(capsys):
|
|
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._render_collapsible_usage(
|
|
usage, "glm-5.2:cloud", config={"cost_target": "bogus-model"}
|
|
)
|
|
# Falls back to default. The error now goes to stderr (otherwise it would
|
|
# land mid-table and look like a model error in the posted summary).
|
|
assert "Claude Sonnet 5" in sec
|
|
assert "**Claude Sonnet 5**" in sec # bolded as the resolved cost_target
|
|
assert "bogus-model" not in sec
|
|
assert "unknown price target" not in sec
|
|
err = capsys.readouterr().err
|
|
assert "unknown price target" in err
|
|
assert "bogus-model" in err
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ADDITIONAL_CONTEXT_URL — env var + per-repo config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_repo_config_accepts_additional_context_urls():
|
|
raw = json.dumps({
|
|
"additional_context_urls": [
|
|
"https://nexus.example.com/raw/context.md",
|
|
" https://other.example/x.md ",
|
|
123, # ignored (non-string)
|
|
"", # ignored (empty after strip)
|
|
]
|
|
})
|
|
cfg = parse_repo_config(raw)
|
|
assert "additional_context_urls" in cfg
|
|
# Non-strings and empty are stripped; whitespace trimmed.
|
|
assert cfg["additional_context_urls"] == [
|
|
"https://nexus.example.com/raw/context.md",
|
|
"https://other.example/x.md",
|
|
]
|
|
|
|
|
|
def test_parse_repo_config_additional_context_urls_capped_at_8():
|
|
urls = [f"https://x.example/{i}.md" for i in range(20)]
|
|
cfg = parse_repo_config(json.dumps({"additional_context_urls": urls}))
|
|
assert len(cfg["additional_context_urls"]) == 8
|
|
|
|
|
|
def test_parse_repo_config_additional_context_urls_absent_when_missing():
|
|
assert "additional_context_urls" not in parse_repo_config("{}")
|
|
|
|
|
|
def test_resolve_additional_context_urls_env_wins_and_dedupes(monkeypatch):
|
|
monkeypatch.setenv(
|
|
"PRAGENT_ADDITIONAL_CONTEXT_URL",
|
|
"https://env.example/a.md, https://env.example/b.md",
|
|
)
|
|
cfg = {"additional_context_urls": [
|
|
"https://env.example/a.md", # dup with env -> dropped from cfg list
|
|
"https://cfg.example/d.md",
|
|
]}
|
|
urls = ai_review._resolve_additional_context_urls(cfg)
|
|
# Env comes first, in declared order; cfg entries that duplicate env are skipped.
|
|
assert urls == [
|
|
"https://env.example/a.md",
|
|
"https://env.example/b.md",
|
|
"https://cfg.example/d.md",
|
|
]
|
|
|
|
|
|
def test_resolve_additional_context_urls_no_env_no_config():
|
|
import os as _os
|
|
_os.environ.pop("PRAGENT_ADDITIONAL_CONTEXT_URL", None)
|
|
assert ai_review._resolve_additional_context_urls(None) == []
|
|
assert ai_review._resolve_additional_context_urls({}) == []
|
|
|
|
|
|
def test_resolve_additional_context_urls_total_cap_is_8(monkeypatch):
|
|
monkeypatch.setenv(
|
|
"PRAGENT_ADDITIONAL_CONTEXT_URL",
|
|
",".join(f"https://e.example/{i}" for i in range(20)),
|
|
)
|
|
urls = ai_review._resolve_additional_context_urls({
|
|
"additional_context_urls": [f"https://c.example/{i}" for i in range(20)]
|
|
})
|
|
assert len(urls) == 8
|
|
|
|
|
|
class _FakeResp:
|
|
"""Minimal stand-in for urllib's HTTP response: context manager + .read(N)."""
|
|
|
|
def __init__(self, body: bytes):
|
|
import io as _io
|
|
self._buf = _io.BytesIO(body)
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def read(self, n=-1):
|
|
return self._buf.read(n)
|
|
|
|
|
|
def _patch_urlopen(body_for_url):
|
|
"""Replace ai_review.urllib.request.urlopen with a fake that returns the
|
|
configured body for each URL. `body_for_url: dict[str, bytes]`. Records
|
|
every URL it sees in `calls` on the closure."""
|
|
calls: list[str] = []
|
|
|
|
def fake(req, *args, **kwargs):
|
|
url = req.full_url if hasattr(req, "full_url") else str(req)
|
|
calls.append(url)
|
|
return _FakeResp(body_for_url.get(url, b""))
|
|
|
|
import ai_review as _ar
|
|
orig = _ar.urllib.request.urlopen
|
|
_ar.urllib.request.urlopen = fake
|
|
|
|
def restore():
|
|
_ar.urllib.request.urlopen = orig
|
|
|
|
return calls, restore
|
|
|
|
|
|
def test_fetch_additional_context_joins_blocks():
|
|
ai_review._ADDITIONAL_CONTEXT_CACHE.clear()
|
|
calls, restore = _patch_urlopen({
|
|
"https://a/x.md": b"alpha body",
|
|
"https://b/y.md": b"beta body",
|
|
})
|
|
try:
|
|
out = ai_review.fetch_additional_context(["https://a/x.md", "https://b/y.md"])
|
|
finally:
|
|
restore()
|
|
|
|
assert "alpha body" in out and "beta body" in out
|
|
assert calls == ["https://a/x.md", "https://b/y.md"]
|
|
|
|
|
|
def test_fetch_additional_context_caches_by_url():
|
|
ai_review._ADDITIONAL_CONTEXT_CACHE.clear()
|
|
calls, restore = _patch_urlopen({"https://a/x.md": b"cached body"})
|
|
try:
|
|
ai_review.fetch_additional_context(["https://a/x.md"])
|
|
ai_review.fetch_additional_context(["https://a/x.md", "https://a/x.md"])
|
|
finally:
|
|
restore()
|
|
|
|
# Second call hits cache; only one network call despite 3 references.
|
|
assert calls == ["https://a/x.md"]
|
|
|
|
|
|
def test_fetch_additional_context_rejects_non_http_schemes():
|
|
ai_review._ADDITIONAL_CONTEXT_CACHE.clear()
|
|
out = ai_review.fetch_additional_context([
|
|
"file:///etc/passwd",
|
|
"javascript:alert(1)",
|
|
"ftp://x/y",
|
|
])
|
|
# All rejected at scheme check, no network calls.
|
|
assert out == ""
|
|
|
|
|
|
def test_fetch_additional_context_truncates_per_url():
|
|
ai_review._ADDITIONAL_CONTEXT_CACHE.clear()
|
|
cap = ai_review._ADDITIONAL_CONTEXT_MAX_PER_URL_CHARS
|
|
_, restore = _patch_urlopen({"https://a/big.md": b"X" * (cap + 500)})
|
|
try:
|
|
out = ai_review.fetch_additional_context(["https://a/big.md"])
|
|
finally:
|
|
restore()
|
|
|
|
assert "…[truncated]" in out
|
|
# The fetched body is bounded to `cap` chars (the marker + the URL
|
|
# header are appended on top by the joiner, so we count just X's).
|
|
assert out.count("X") == cap
|
|
|
|
|
|
def test_fetch_additional_context_caps_total_chars():
|
|
ai_review._ADDITIONAL_CONTEXT_CACHE.clear()
|
|
cap_total = ai_review._ADDITIONAL_CONTEXT_MAX_TOTAL_CHARS
|
|
# Each block: "### https://a/N.md\n\n" + 3990 X + "\nEND" ≈ 4017 chars.
|
|
big = (b"X" * 3990) + b"\nEND"
|
|
urls = [f"https://a/{i}.md" for i in range(8)]
|
|
_, restore = _patch_urlopen({u: big for u in urls})
|
|
try:
|
|
out = ai_review.fetch_additional_context(urls)
|
|
finally:
|
|
restore()
|
|
|
|
# Total is bounded by the cap plus the truncation marker (if the last
|
|
# block was cut mid-flight).
|
|
assert len(out) <= cap_total + 20, len(out)
|
|
|
|
|
|
def test_fetch_additional_context_empty_returns_empty():
|
|
assert ai_review.fetch_additional_context([]) == ""
|
|
|
|
|
|
def test_build_user_prompt_injects_additional_context():
|
|
prompt = build_user_prompt(
|
|
"T", "B", "diff", config=None, prior_reviews=None,
|
|
additional_context="### https://a/x.md\n\nalpha body",
|
|
)
|
|
assert "## Repo-provided context" in prompt
|
|
assert "alpha body" in prompt
|
|
# URL header preserved so the agent knows which block is which.
|
|
assert "https://a/x.md" in prompt
|
|
|
|
|
|
def test_build_user_prompt_skips_additional_context_when_empty():
|
|
prompt = build_user_prompt("T", "B", "diff")
|
|
assert "## Repo-provided context" not in prompt
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_repo_config: reviewers[] + triage (multi-lens orchestration)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_repo_config_reviewers_array_basic():
|
|
raw = json.dumps({
|
|
"reviewers": [
|
|
{"id": "security", "severity_floor": "high", "max_findings": 10},
|
|
{"id": "docs", "agent_file": ".opencode/agents/docs.md"},
|
|
{"id": "perf", "model": "headroom/glm-5.2:cloud",
|
|
"skip_if_all_changed_paths": "docs/**"},
|
|
]
|
|
})
|
|
cfg = parse_repo_config(raw)
|
|
assert cfg["reviewers"] == [
|
|
{"id": "security", "severity_floor": "high", "max_findings": 10},
|
|
{"id": "docs", "agent_file": ".opencode/agents/docs.md"},
|
|
{"id": "perf", "model": "headroom/glm-5.2:cloud",
|
|
"skip_if_all_changed_paths": "docs/**"},
|
|
]
|
|
|
|
|
|
def test_parse_repo_config_reviewers_rejects_bad_id():
|
|
# Punctuation, leading dash, underscore, empty — all silently dropped.
|
|
cfg = parse_repo_config(json.dumps({
|
|
"reviewers": [
|
|
{"id": "BAD!!!"},
|
|
{"id": "-bad-start"},
|
|
{"id": "ok_under"},
|
|
{"id": ""},
|
|
{"id": "good-one"},
|
|
]
|
|
}))
|
|
assert cfg["reviewers"] == [{"id": "good-one"}]
|
|
|
|
|
|
def test_parse_repo_config_reviewers_caps_at_8():
|
|
cfg = parse_repo_config(json.dumps({
|
|
"reviewers": [{"id": f"l{i}"} for i in range(12)]
|
|
}))
|
|
assert len(cfg["reviewers"]) == 8
|
|
|
|
|
|
def test_parse_repo_config_reviewers_drop_non_dict_entries():
|
|
cfg = parse_repo_config(json.dumps({
|
|
"reviewers": ["not-a-dict", 42, None, {"id": "ok"}]
|
|
}))
|
|
assert cfg["reviewers"] == [{"id": "ok"}]
|
|
|
|
|
|
def test_parse_repo_config_reviewers_absent_yields_no_key():
|
|
cfg = parse_repo_config("{}")
|
|
assert "reviewers" not in cfg
|
|
|
|
|
|
def test_parse_repo_config_reviewers_activation_validated():
|
|
cfg = parse_repo_config(json.dumps({
|
|
"reviewers": [
|
|
{"id": "a", "activation": "auto"},
|
|
{"id": "b", "activation": "always"},
|
|
{"id": "c", "activation": "off"},
|
|
{"id": "d", "activation": "BOGUS"}, # dropped (unsupported)
|
|
]
|
|
}))
|
|
# Only the entries with valid activation carry the key — the BOGUS one
|
|
# just keeps id (the unknown field is silently dropped, not rejected).
|
|
assert [r.get("activation") for r in cfg["reviewers"]] == [
|
|
"auto", "always", "off", None
|
|
]
|
|
|
|
|
|
def test_parse_repo_config_triage_object_full():
|
|
cfg = parse_repo_config(json.dumps({
|
|
"triage": {"enabled": True, "model": "headroom/haiku", "max_lenses": 3}
|
|
}))
|
|
assert cfg["triage"] == {"enabled": True, "model": "headroom/haiku", "max_lenses": 3}
|
|
|
|
|
|
def test_parse_repo_config_triage_disabled():
|
|
cfg = parse_repo_config(json.dumps({"triage": {"enabled": False}}))
|
|
assert cfg["triage"] == {"enabled": False}
|
|
|
|
|
|
def test_parse_repo_config_triage_malformed_yields_disabled():
|
|
# Non-object triage value (string, list, number) should disable, not crash.
|
|
for raw in (
|
|
'{"triage": "off"}',
|
|
'{"triage": []}',
|
|
'{"triage": 42}',
|
|
):
|
|
cfg = parse_repo_config(raw)
|
|
assert cfg.get("triage") == {"enabled": False}, f"failed for {raw}"
|
|
|
|
|
|
def test_parse_repo_config_triage_absent_yields_no_key():
|
|
cfg = parse_repo_config("{}")
|
|
assert "triage" not in cfg
|
|
|
|
|
|
def test_parse_repo_config_triage_max_lenses_capped_at_8():
|
|
cfg = parse_repo_config(json.dumps({"triage": {"max_lenses": 100}}))
|
|
# 100 is out of range; the key is dropped, not clamped. Caller defaults.
|
|
assert "max_lenses" not in cfg.get("triage", {})
|
|
|
|
|
|
def test_render_collapsible_usage_shows_lenses_when_multi():
|
|
usage = {
|
|
"input": 100, "output": 50, "reasoning": 0,
|
|
"cache_read": 0, "cache_write": 0, "total": 150,
|
|
"steps": 12, "duration_s": 8.4,
|
|
"lenses": ["security", "docs", "tests"],
|
|
"lens_steps": 12,
|
|
}
|
|
out = _render_collapsible_usage(usage, "headroom/glm-5.2:cloud", None)
|
|
assert "Lenses" in out
|
|
# All three lens ids are shown in backticks.
|
|
assert "`security`" in out
|
|
assert "`docs`" in out
|
|
assert "`tests`" in out
|
|
# Step count is surfaced.
|
|
assert "12" in out
|
|
|
|
|
|
def test_render_collapsible_usage_omits_lenses_when_single_primary():
|
|
usage = {
|
|
"input": 100, "output": 50, "reasoning": 0,
|
|
"cache_read": 0, "cache_write": 0, "total": 150,
|
|
"steps": 4, "duration_s": 2.0,
|
|
}
|
|
out = _render_collapsible_usage(usage, "headroom/glm-5.2:cloud", None)
|
|
assert "Lenses" not in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fmt_tokens
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fmt_tokens_zero():
|
|
assert fmt_tokens(0) == "0"
|
|
|
|
|
|
def test_fmt_tokens_small_no_short():
|
|
assert fmt_tokens(42) == "42"
|
|
assert fmt_tokens(999) == "999"
|
|
|
|
|
|
def test_fmt_tokens_thousands():
|
|
assert fmt_tokens(1000) == "1,000 (1.0K)"
|
|
assert fmt_tokens(1234) == "1,234 (1.2K)"
|
|
assert fmt_tokens(9999) == "9,999 (10.0K)"
|
|
|
|
|
|
def test_fmt_tokens_millions():
|
|
assert fmt_tokens(1_000_000) == "1,000,000 (1.0M)"
|
|
assert fmt_tokens(2_071_025) == "2,071,025 (2.1M)"
|
|
assert fmt_tokens(1_234_567) == "1,234,567 (1.2M)"
|
|
|
|
|
|
def test_fmt_tokens_billions():
|
|
assert fmt_tokens(1_234_567_890) == "1,234,567,890 (1.2B)"
|
|
|
|
|
|
def test_fmt_tokens_none():
|
|
assert fmt_tokens(None) == "?"
|
|
|
|
|
|
def test_fmt_tokens_negative():
|
|
assert fmt_tokens(-1) == "?"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fmt_tokens — applied in usage + inline comment bodies (Task 3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_collapsible_usage_renders_humanized_tokens():
|
|
usage = {"input": 2_071_025, "output": 17303, "reasoning": 0,
|
|
"cache_read": 0, "cache_write": 0, "total": 2_088_328,
|
|
"cost": 0.0, "steps": 1, "duration_s": 10.0}
|
|
block = _render_collapsible_usage(usage, "glm-5.2:cloud", config={})
|
|
assert "2,071,025 (2.1M) in" in block
|
|
assert "17,303 (17.3K) out" in block
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Multi-provider equivalent-cost table — Task 10
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_collapsible_usage_renders_multi_provider_table():
|
|
usage = {"input": 1_000_000, "output": 1000, "reasoning": 0,
|
|
"cache_read": 0, "cache_write": 0, "total": 1_001_000,
|
|
"cost": 0.0, "steps": 1, "duration_s": 10.0}
|
|
block = _render_collapsible_usage(usage, "glm-5.2:cloud", config={"compare_against": ["claude-sonnet-5", "gpt-5"]})
|
|
assert "Claude Sonnet 5" in block
|
|
assert "GPT-5" in block
|
|
assert "| Provider | Cost |" in block
|
|
|
|
|
|
def test_collapsible_usage_uses_default_compare_against_when_absent():
|
|
usage = {"input": 1_000_000, "output": 0, "reasoning": 0,
|
|
"cache_read": 0, "cache_write": 0, "total": 1_000_000,
|
|
"cost": 0.0, "steps": 1, "duration_s": 5.0}
|
|
block = _render_collapsible_usage(usage, "glm-5.2:cloud", config={})
|
|
assert "Claude Sonnet 5" in block
|
|
assert "GPT-5" in block
|
|
assert "Gemini 2.5 Pro" in block
|
|
assert "Grok 4.5" in block
|
|
|
|
|
|
def test_collapsible_usage_bolds_cost_target_row():
|
|
usage = {"input": 1_000_000, "output": 0, "reasoning": 0,
|
|
"cache_read": 0, "cache_write": 0, "total": 1_000_000,
|
|
"cost": 0.0, "steps": 1, "duration_s": 5.0}
|
|
block = _render_collapsible_usage(usage, "glm-5.2:cloud", config={"cost_target": "gpt-5"})
|
|
assert "**GPT-5**" in block
|
|
assert "Claude Sonnet 5" in block # still in default compare set
|
|
|
|
|
|
def test_collapsible_usage_skips_zero_cost_rows():
|
|
usage = {"input": 0, "output": 0, "reasoning": 0,
|
|
"cache_read": 0, "cache_write": 0, "total": 0,
|
|
"cost": 0.0, "steps": 1, "duration_s": 1.0}
|
|
block = _render_collapsible_usage(usage, "glm-5.2:cloud", config={})
|
|
# With zero tokens, all costs are $0 — skip the entire table.
|
|
assert "| Provider | Cost |" not in block
|
|
|
|
|
|
def test_inline_comment_body_humanized_tokens():
|
|
# Value chosen > 1000 so fmt_tokens actually adds the comma + short suffix;
|
|
# the plan's 362 would render identically with or without fmt_tokens.
|
|
f = {"severity": "medium", "path": "x.py", "line": 1,
|
|
"problem": "p", "fix": "", "suggestion": "", "reference": "",
|
|
"_tok_attrib": 17303, "_tok_pct": 0.11}
|
|
body = inline_comment_body(f)
|
|
assert "🪙 ~17,303 (17.3K) tok" in body
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Severity levels — Task 4 (add trivial + info)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_severities_includes_trivial_and_info():
|
|
assert "trivial" in SEVERITIES
|
|
assert "info" in SEVERITIES
|
|
|
|
|
|
def test_severity_rank_orders_new_levels():
|
|
assert SEVERITY_RANK["info"] < SEVERITY_RANK["trivial"] < SEVERITY_RANK["low"]
|
|
|
|
|
|
def test_threshold_medium_keeps_low_below_trivial_below_info():
|
|
# medium+ threshold:
|
|
# medium (rank 2) → kept
|
|
# low (rank 1) → DROPPED
|
|
# trivial (rank 0) → DROPPED
|
|
# info (rank -1) → DROPPED
|
|
cfg = {"style": "lenient", "severity_threshold": "medium"}
|
|
findings = [
|
|
{"severity": "info", "path": "a", "line": 1, "problem": "", "fix": "", "suggestion": "", "reference": ""},
|
|
{"severity": "trivial", "path": "b", "line": 1, "problem": "", "fix": "", "suggestion": "", "reference": ""},
|
|
{"severity": "low", "path": "c", "line": 1, "problem": "", "fix": "", "suggestion": "", "reference": ""},
|
|
{"severity": "medium", "path": "d", "line": 1, "problem": "", "fix": "", "suggestion": "", "reference": ""},
|
|
]
|
|
kept, dropped = ai_review.apply_repo_config(findings, cfg, changed_paths=["x.py"])
|
|
sev_kept = [f["severity"] for f in kept]
|
|
sev_dropped = [f["severity"] for f in dropped]
|
|
assert "info" in sev_dropped
|
|
assert "trivial" in sev_dropped
|
|
assert "low" in sev_dropped
|
|
assert "medium" in sev_kept
|
|
# and nothing else
|
|
assert len(kept) == 1
|
|
|
|
|
|
def test_unknown_severity_still_normalizes_to_medium():
|
|
# Backward compat
|
|
n = _normalize_finding({"severity": "emergency", "path": "x", "line": 1, "problem": "p"})
|
|
assert n["severity"] == "medium"
|
|
|
|
|
|
def test_emoji_for_trivial_and_info_is_neutral():
|
|
# The plan's emoji table maps trivial/info to ⚪
|
|
assert _SEVERITY_EMOJI["trivial"] == "⚪"
|
|
assert _SEVERITY_EMOJI["info"] == "⚪"
|
|
|
|
|
|
def test_severity_badge_labels_each_known_severity():
|
|
# Trivial and info (and legacy nit) should render with their own name,
|
|
# not fall back to "INFO".
|
|
for sev in ("critical", "high", "medium", "low", "trivial", "info", "nit"):
|
|
badge = _severity_badge(sev)
|
|
assert f"[{sev.upper()}]" in badge, (sev, badge)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_repo_config — `enabled` (kill-switch) + `compare_against` (cost roster)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_repo_config_enabled_true():
|
|
cfg = parse_repo_config('{"enabled": true}')
|
|
assert cfg.get("enabled") is True
|
|
|
|
|
|
def test_parse_repo_config_enabled_false_explicit():
|
|
cfg = parse_repo_config('{"enabled": false}')
|
|
assert cfg.get("enabled") is False
|
|
|
|
|
|
def test_parse_repo_config_enabled_missing_defaults_false():
|
|
cfg = parse_repo_config('{}')
|
|
assert cfg.get("enabled") is False
|
|
|
|
|
|
def test_parse_repo_config_enabled_wrong_type_ignored():
|
|
cfg = parse_repo_config('{"enabled": "yes"}')
|
|
assert cfg.get("enabled") is False
|
|
|
|
|
|
def test_parse_repo_config_compare_against_default_absent():
|
|
# absent in returned cfg; defaults applied in render, not parse_repo_config
|
|
cfg = parse_repo_config('{}')
|
|
assert "compare_against" not in cfg
|
|
|
|
|
|
def test_parse_repo_config_compare_against_valid():
|
|
cfg = parse_repo_config(
|
|
'{"compare_against": ["claude-sonnet-5", "gpt-5", "gemini-2.5-pro"]}')
|
|
assert cfg["compare_against"] == ["claude-sonnet-5", "gpt-5", "gemini-2.5-pro"]
|
|
|
|
|
|
def test_parse_repo_config_compare_against_drops_unknown_keys(capfd):
|
|
cfg = parse_repo_config(
|
|
'{"compare_against": ["claude-sonnet-5", "bogus-1", "gpt-5"]}')
|
|
assert "bogus-1" not in cfg["compare_against"]
|
|
assert "claude-sonnet-5" in cfg["compare_against"]
|
|
captured = capfd.readouterr()
|
|
assert "bogus-1" in captured.err
|
|
|
|
|
|
def test_parse_repo_config_compare_against_caps_at_12(monkeypatch):
|
|
"""13+ valid keys must be truncated to the first 12; invalid keys are
|
|
dropped and do not count. Inject a 13th PRICES entry via monkeypatch so
|
|
the [:12] cap actually fires (cost_model.PRICES has exactly 12 keys
|
|
today, which would otherwise make the cap a no-op)."""
|
|
import cost_model as cm
|
|
monkeypatch.setitem(
|
|
cm.PRICES, "fake-model-13", cm.Price("Fake", 1.00, 2.00, 1.00, 0.10))
|
|
valid = list(cm.PRICES) # 13 unique keys (12 real + 1 test-only)
|
|
raw = valid + ["bogus-extra"] # 13 valid + 1 invalid
|
|
cfg = parse_repo_config(json.dumps({"compare_against": raw}))
|
|
assert len(cfg["compare_against"]) == 12
|
|
assert cfg["compare_against"] == valid[:12]
|
|
assert "fake-model-13" not in cfg["compare_against"]
|
|
assert "bogus-extra" not in cfg["compare_against"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task 6 — merge_confidence + REVIEW_HEADER confidence badge
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_merge_confidence_clean_is_five():
|
|
assert merge_confidence([]) == 5
|
|
|
|
|
|
def test_merge_confidence_only_low_is_five():
|
|
f = {"severity": "low"}
|
|
assert merge_confidence([f, f, f]) == 5
|
|
|
|
|
|
def test_merge_confidence_medium_drops_one():
|
|
f = {"severity": "medium"}
|
|
assert merge_confidence([f]) == 4
|
|
|
|
|
|
def test_merge_confidence_high_drops_two():
|
|
f = {"severity": "high"}
|
|
assert merge_confidence([f]) == 3
|
|
|
|
|
|
def test_merge_confidence_critical_drops_to_one():
|
|
f = {"severity": "critical"}
|
|
assert merge_confidence([f]) == 1
|
|
|
|
|
|
def test_merge_confidence_multi_lens_drops_extra():
|
|
# The flag has moved to a kwarg; passing `_multi_lens` on the dict is no
|
|
# longer enough — the kwarg is the only path that drops the score.
|
|
f = {"severity": "low"}
|
|
assert merge_confidence([f], multi_lens_observed=True) == 4
|
|
|
|
|
|
def test_merge_confidence_multi_lens_survives_normalization():
|
|
"""Real flow: `_multi_lens` is set on the raw finding, but stripped by
|
|
`_normalize_finding`. `merge_confidence(...)` with only the kwarg sees a
|
|
normalized finding; the dedup must be triggered by `multi_lens_observed=`
|
|
being true, not by reading `_multi_lens` off the dict."""
|
|
raw = {"_multi_lens": True, "severity": "low", "path": "x", "line": 1,
|
|
"problem": "p", "fix": "", "suggestion": "", "reference": ""}
|
|
normalized = _normalize_finding(raw)
|
|
assert "_multi_lens" not in normalized # confirms the strip
|
|
# Now call merge_confidence the way review_pr will:
|
|
assert merge_confidence([normalized], multi_lens_observed=True) == 4
|
|
# And without the kwarg, the flag-on-dict path is gone:
|
|
assert merge_confidence([normalized]) == 5
|
|
|
|
|
|
def test_merge_confidence_clamped():
|
|
# Three critical findings must NOT take the score below 1.
|
|
f = {"severity": "critical"}
|
|
assert merge_confidence([f, f, f]) == 1
|
|
|
|
|
|
def test_review_header_includes_confidence():
|
|
# REVIEW_HEADER gains a {confidence} placeholder; verify the format works.
|
|
h = REVIEW_HEADER.format(model="glm-5.2:cloud", sha="abc1234567", confidence="3/5 🟡")
|
|
assert "Merge confidence: 3/5 🟡" in h
|
|
|
|
|
|
def test_confidence_badge_table_complete():
|
|
# Sanity-check the badge table the render layer reads from.
|
|
assert _CONFIDENCE_BADGE == {5: "🟢", 4: "🟢", 3: "🟡", 2: "🟠", 1: "🔴"}
|
|
|
|
|
|
def test_format_review_body_default_confidence_is_green():
|
|
# Default confidence kwarg should produce a green 5/5 badge in the header,
|
|
# matching the pre-existing "clean PR" semantics.
|
|
body = format_review_body("- [high] x:1 — bug", "glm-5.2:cloud", "abcdef1234567890")
|
|
assert "Merge confidence: 5/5 🟢" in body
|
|
|
|
|
|
def test_format_review_body_low_confidence_shows_red_badge():
|
|
body = format_review_body(
|
|
"- [critical] x:1 — bug", "glm-5.2:cloud", "abcdef1234567890",
|
|
confidence=1,
|
|
)
|
|
assert "Merge confidence: 1/5 🔴" in body
|
|
|
|
|
|
def test_format_review_body_confidence_clamps_out_of_range():
|
|
# Out-of-range confidence is clamped to [1, 5] in the badge string.
|
|
body_hi = format_review_body("- x", "glm-5.2:cloud", "abcdef1234567890", confidence=99)
|
|
assert "Merge confidence: 5/5 🟢" in body_hi
|
|
body_lo = format_review_body("- x", "glm-5.2:cloud", "abcdef1234567890", confidence=0)
|
|
assert "Merge confidence: 1/5 🔴" in body_lo
|
|
|