pilot: dedupe + repo config + inline comments w/ suggestions
- Dedupe: Gitea-as-state. Scan existing reviews for a hidden
<!-- pragent:sha=... --> marker matching the head sha; skip if present
(kills duplicate reviews on label-toggle / re-fire). Prior review bodies
fed back as 'already said' context (light framework §6.1).
- Repo-local focus: optional .pr-review.json at repo root
({focus,exclude_paths,languages,instructions}), fetched at head ref.
- Inline comments + apply-able suggestions: model emits JSON findings
{severity,path,line,problem,fix,suggestion}; diff hunks parsed into valid
(path,new_line) RIGHT-side anchors; anchored findings become positional
review comments with a ```suggestion fence (Gitea apply-button);
unanchored findings fold into the summary body.
- Tests: parse_diff_anchors, parse_findings (tolerant JSON), split_findings,
inline_comment_body, summary_bullets, parse_repo_config, reviewed_shas,
prior_review_bodies, sha-marker. 35 pass.
- Bump OLLAMA_MAX_TOKENS default 6000 -> 8000 (suggestions add length).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,15 @@ sys.path.insert(0, os.path.join(ROOT, "pilot"))
|
||||
from ai_review import ( # noqa: E402
|
||||
build_user_prompt,
|
||||
format_review_body,
|
||||
inline_comment_body,
|
||||
parse_diff_anchors,
|
||||
parse_findings,
|
||||
parse_repo_config,
|
||||
parse_text_blocks,
|
||||
prior_review_bodies,
|
||||
reviewed_shas,
|
||||
split_findings,
|
||||
summary_bullets,
|
||||
truncate_diff,
|
||||
)
|
||||
|
||||
@@ -130,4 +138,206 @@ def test_build_user_prompt_truncates_long_body():
|
||||
|
||||
def test_build_user_prompt_no_body():
|
||||
p = build_user_prompt("t", "", "d")
|
||||
assert "Description:" not in p
|
||||
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)
|
||||
assert "**[HIGH]**" in body
|
||||
assert "bad" in body
|
||||
assert "```suggestion\n" in body
|
||||
assert "good()" 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 "```suggestion" not in body
|
||||
assert "Fix: f" 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"}') == {}
|
||||
assert parse_repo_config('{"focus":["ok"]}') == {"focus": ["ok"]}
|
||||
assert parse_repo_config("") == {}
|
||||
assert parse_repo_config("not json") == {}
|
||||
assert parse_repo_config('{"instructions":" "}') == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
Reference in New Issue
Block a user