Merge agent-A: move feedback*.py from WIP into tree

This commit is contained in:
Marcos
2026-08-22 14:46:46 +00:00
8 changed files with 2177 additions and 0 deletions
+317
View File
@@ -0,0 +1,317 @@
"""Tests for pilot/feedback.py — SQLite storage for review feedback signals.
Covers: schema bootstrap, posthash stability, dedup-on-insert, reaction /
thread-state / reply upserts, the analyzer-side `findings_with_votes` join,
and graceful failure on bad inputs.
"""
import sqlite3
import tempfile
import unittest
from pilot import feedback
class TestPosthash(unittest.TestCase):
def test_stable_across_calls(self):
a = feedback.posthash("src/api/foo.ts", 42, "HIGH", "Race condition in handler")
b = feedback.posthash("src/api/foo.ts", 42, "HIGH", "Race condition in handler")
self.assertEqual(a, b)
def test_length_is_short(self):
h = feedback.posthash("a", 1, "low", "x")
self.assertEqual(len(h), 16)
def test_different_path_different_hash(self):
self.assertNotEqual(
feedback.posthash("a", 1, "LOW", "x"),
feedback.posthash("b", 1, "LOW", "x"),
)
def test_different_line_different_hash(self):
self.assertNotEqual(
feedback.posthash("a", 1, "LOW", "x"),
feedback.posthash("a", 2, "LOW", "x"),
)
def test_different_severity_different_hash(self):
# Same line, same problem, different severity → different signal.
self.assertNotEqual(
feedback.posthash("a", 1, "LOW", "x"),
feedback.posthash("a", 1, "CRITICAL", "x"),
)
def test_problem_prefix_used_only(self):
# First 80 chars participate; rest is ignored.
self.assertEqual(
feedback.posthash("a", 1, "LOW", "x" * 80 + "tail"),
feedback.posthash("a", 1, "LOW", "x" * 80),
)
def test_case_and_whitespace_normalized_in_problem(self):
# Lowercased + stripped → same hash.
self.assertEqual(
feedback.posthash("a", 1, "LOW", " Same Finding "),
feedback.posthash("a", 1, "LOW", "same finding"),
)
class TestInit(unittest.TestCase):
def test_init_creates_db(self):
with tempfile.TemporaryDirectory() as d:
db = f"{d}/f.db"
conn = feedback.init(db)
# Application tables exist (sqlite_sequence is a bookkeeping table
# created by AUTOINCREMENT — not part of the contract).
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
self.assertTrue(
{"review", "inline_finding", "reaction", "thread_state", "reply"}.issubset(tables),
f"missing tables: got {tables}",
)
conn.close()
def test_init_is_idempotent(self):
with tempfile.TemporaryDirectory() as d:
db = f"{d}/f.db"
feedback.init(db)
# Second call must not raise.
feedback.init(db)
class TestRecordReview(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.conn = feedback.init(f"{self.tmp.name}/f.db")
def tearDown(self):
self.conn.close(); self.tmp.cleanup()
def test_returns_id_and_row(self):
rid = feedback.record_review(
self.conn, repo="o/r", pr=1, head_sha="abc",
review_id_gitea=99, body_comment_id=42,
)
self.assertIsNotNone(rid)
row = self.conn.execute("SELECT * FROM review WHERE id = ?", (rid,)).fetchone()
self.assertEqual(row[1], "o/r")
self.assertEqual(row[4], 99)
self.assertEqual(row[5], 42)
class TestRecordInlineFinding(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.conn = feedback.init(f"{self.tmp.name}/f.db")
def tearDown(self):
self.conn.close(); self.tmp.cleanup()
def _new_review(self):
return feedback.record_review(
self.conn, repo="o/r", pr=1, head_sha="x",
)
def test_insert_returns_id(self):
rid = self._new_review()
fid = feedback.record_inline_finding(
self.conn, review_id=rid, repo="o/r", pr=1,
path="a/b.ts", line=10, severity="HIGH",
problem="bug", fix="patch", suggestion="code",
comment_id=555,
)
self.assertIsNotNone(fid)
def test_dedup_on_posthash(self):
# Two reviews of the SAME finding on different PRs insert two
# rows — deduplication by posthash is the *analyzer's* job
# (findings_with_votes GROUP BY posthash). Storing one row per
# review preserves per-comment reactions across PRs.
rid1 = self._new_review()
feedback.record_inline_finding(
self.conn, review_id=rid1, repo="o/r", pr=1,
path="a/b.ts", line=10, severity="HIGH", problem="race",
comment_id=100,
)
rid2 = feedback.record_review(self.conn, repo="o/r", pr=2, head_sha="y")
feedback.record_inline_finding(
self.conn, review_id=rid2, repo="o/r", pr=2,
path="a/b.ts", line=10, severity="HIGH", problem="race",
comment_id=200,
)
rows = self.conn.execute(
"SELECT id, comment_id FROM inline_finding WHERE path='a/b.ts' AND line=10 ORDER BY id"
).fetchall()
self.assertEqual(len(rows), 2)
# Both comment_ids preserved (PK dedup is the *reaction* table's job).
self.assertEqual([r[1] for r in rows], [100, 200])
def test_posthash_set(self):
rid = self._new_review()
feedback.record_inline_finding(
self.conn, review_id=rid, repo="o/r", pr=1,
path="a", line=1, severity="LOW", problem="nit",
)
ph = self.conn.execute(
"SELECT posthash FROM inline_finding LIMIT 1"
).fetchone()[0]
expected = feedback.posthash("a", 1, "LOW", "nit")
self.assertEqual(ph, expected)
class TestRecordReaction(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.conn = feedback.init(f"{self.tmp.name}/f.db")
def tearDown(self):
self.conn.close(); self.tmp.cleanup()
def test_insert_upsert(self):
ok = feedback.record_reaction(
self.conn, comment_id=10, user="alice", content="+1",
)
self.assertTrue(ok)
n = self.conn.execute("SELECT COUNT(*) FROM reaction").fetchone()[0]
self.assertEqual(n, 1)
# Re-insert same PK → no duplicate.
feedback.record_reaction(self.conn, comment_id=10, user="alice", content="+1")
n = self.conn.execute("SELECT COUNT(*) FROM reaction").fetchone()[0]
self.assertEqual(n, 1)
def test_distinct_users_can_react(self):
feedback.record_reaction(self.conn, comment_id=10, user="a", content="+1")
feedback.record_reaction(self.conn, comment_id=10, user="b", content="-1")
n = self.conn.execute("SELECT COUNT(*) FROM reaction").fetchone()[0]
self.assertEqual(n, 2)
class TestRecordThreadState(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.conn = feedback.init(f"{self.tmp.name}/f.db")
rid = feedback.record_review(self.conn, repo="o/r", pr=1, head_sha="x")
self.fid = feedback.record_inline_finding(
self.conn, review_id=rid, repo="o/r", pr=1,
path="a", line=1, severity="LOW", problem="x",
)
def tearDown(self):
self.conn.close(); self.tmp.cleanup()
def test_upsert_overwrites(self):
feedback.record_thread_state(self.conn, finding_id=self.fid, resolved=True)
row = self.conn.execute(
"SELECT resolved FROM thread_state WHERE finding_id = ?", (self.fid,)
).fetchone()
self.assertEqual(row[0], 1)
feedback.record_thread_state(self.conn, finding_id=self.fid, resolved=False)
row = self.conn.execute(
"SELECT resolved FROM thread_state WHERE finding_id = ?", (self.fid,)
).fetchone()
self.assertEqual(row[0], 0)
class TestRecordReply(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.conn = feedback.init(f"{self.tmp.name}/f.db")
rid = feedback.record_review(self.conn, repo="o/r", pr=1, head_sha="x")
self.fid = feedback.record_inline_finding(
self.conn, review_id=rid, repo="o/r", pr=1,
path="a", line=1, severity="LOW", problem="x",
)
def tearDown(self):
self.conn.close(); self.tmp.cleanup()
def test_insert_idempotent(self):
feedback.record_reply(
self.conn, finding_id=self.fid, author="a",
body="hi", created_at=1000,
)
feedback.record_reply(
self.conn, finding_id=self.fid, author="a",
body="hi", created_at=1000, # same PK
)
n = self.conn.execute("SELECT COUNT(*) FROM reply").fetchone()[0]
self.assertEqual(n, 1)
class TestFindingsWithVotes(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.conn = feedback.init(f"{self.tmp.name}/f.db")
def tearDown(self):
self.conn.close(); self.tmp.cleanup()
def _seed(self):
rid = feedback.record_review(self.conn, repo="o/r", pr=1, head_sha="x")
fid = feedback.record_inline_finding(
self.conn, review_id=rid, repo="o/r", pr=1,
path="a/b.ts", line=10, severity="HIGH",
problem="race", comment_id=500,
)
feedback.record_reaction(self.conn, comment_id=500, user="u1", content="+1")
feedback.record_reaction(self.conn, comment_id=500, user="u2", content="-1")
feedback.record_thread_state(self.conn, finding_id=fid, resolved=True)
feedback.record_reply(
self.conn, finding_id=fid, author="u3",
body="this is fine because of X", created_at=2000,
)
return fid
def test_join_rolls_up_votes(self):
self._seed()
rows = list(feedback.findings_with_votes(self.conn))
self.assertEqual(len(rows), 1)
r = rows[0]
self.assertEqual(r["upvotes"], 1)
self.assertEqual(r["downvotes"], 1)
self.assertEqual(r["resolved"], 1)
self.assertEqual(r["reply_count"], 1)
self.assertIn("this is fine", r["reply_bodies"])
def test_repo_filter(self):
self._seed()
# Add a finding under a different repo.
feedback.record_inline_finding(
self.conn, review_id=None, repo="other/r", pr=99,
path="x", line=1, severity="LOW", problem="y",
)
rows = list(feedback.findings_with_votes(self.conn, repo="o/r"))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["repo"], "o/r")
def test_findings_with_no_signals_return_zero_votes(self):
feedback.record_inline_finding(
self.conn, review_id=None, repo="x/y", pr=1,
path="p", line=1, severity="LOW", problem="z",
)
rows = list(feedback.findings_with_votes(self.conn))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["upvotes"], 0)
self.assertEqual(rows[0]["downvotes"], 0)
self.assertIsNone(rows[0]["resolved"])
class TestKnownPosthashes(unittest.TestCase):
def test_returns_distinct_set(self):
with tempfile.TemporaryDirectory() as d:
conn = feedback.init(f"{d}/f.db")
feedback.record_inline_finding(
conn, review_id=None, repo="o/r", pr=1,
path="a", line=1, severity="LOW", problem="x",
)
feedback.record_inline_finding(
conn, review_id=None, repo="o/r", pr=1,
path="a", line=2, severity="LOW", problem="y",
)
phs = feedback.known_posthashes_for_repo(conn, "o/r")
self.assertEqual(len(phs), 2)
conn.close()
if __name__ == "__main__":
unittest.main()
+231
View File
@@ -0,0 +1,231 @@
"""Tests for pilot/feedback_analyze.py.
Verify:
- empty DB produces a friendly empty-state report (no crash)
- findings are aggregated by posthash across multiple PRs
- net false-positive score weights downvotes + unresolved + negation
replies; acceptance weights upvotes + resolved
- restraint metric reports the right ratio
- case-review queue lists every disagreement
- markdown + JSON output modes both work
"""
import json
import os
import sys
import tempfile
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "..", "pilot"))
import feedback # noqa: E402
import feedback_analyze # noqa: E402
def _seed(conn, findings):
"""Helper: insert a list of (repo, pr, path, line, severity, problem,
[reaction users/contents], [reply bodies], [resolved]) tuples.
Each finding gets a fresh review row + a unique comment_id so the
reaction-join in `findings_with_votes` matches."""
for f in findings:
(repo, pr_idx, path, line, sev, problem, reacts, replies,
resolved) = f
rid = feedback.record_review(conn, repo=repo, pr=pr_idx, head_sha="x")
cid = (hash((repo, pr_idx, path, line, sev, problem)) & 0xFFFFFFFF) or 1
fid = feedback.record_inline_finding(
conn, review_id=rid, repo=repo, pr=pr_idx,
path=path, line=line, severity=sev, problem=problem,
comment_id=cid,
)
for user, content in reacts:
feedback.record_reaction(
conn, comment_id=cid, user=user, content=content,
)
for i, body in enumerate(replies):
feedback.record_reply(
conn, finding_id=fid, author="alice",
body=body, created_at=1000 + i,
)
if resolved is not None:
feedback.record_thread_state(
conn, finding_id=fid, resolved=resolved,
)
class TestEmptyState(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db = f"{self.tmp.name}/f.db"
def tearDown(self):
self.tmp.cleanup()
def test_empty_db_markdown_does_not_crash(self):
report = feedback_analyze.analyze(self.db)
self.assertIn("# pragent feedback report", report)
self.assertIn("findings analyzed**: 0", report)
self.assertIn("Restraint", report)
def test_empty_db_json_has_zero_findings(self):
report = feedback_analyze.analyze(self.db, as_json=True)
d = json.loads(report)
self.assertEqual(d["total_findings"], 0)
self.assertEqual(d["restraint"]["total"], 0)
class TestScoring(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db = f"{self.tmp.name}/f.db"
self.conn = feedback.init(self.db)
# Two PRs, three findings:
# A: 👍×2, resolved=true → acceptance
# B: 👎×2, unresolved, "false positive" reply → false-positive
# C: no signals → ignored
_seed(self.conn, [
("o/r", 1, "a.ts", 10, "HIGH", "race in handler",
[("u1", "+1"), ("u2", "+1")], [], True),
("o/r", 1, "b.ts", 20, "LOW", "missing semicolon",
[("u1", "-1"), ("u2", "-1")],
["False positive — this is fine."], False),
("o/r", 1, "c.ts", 30, "INFO", "naming nit",
[], [], None),
])
def tearDown(self):
self.conn.close()
self.tmp.cleanup()
def test_accepted_ranked_above_fp(self):
d = json.loads(feedback_analyze.analyze(self.db, as_json=True))
self.assertEqual(len(d["top_accepted"]), 1)
self.assertEqual(d["top_accepted"][0]["path"], "a.ts")
self.assertEqual(len(d["top_false_positive"]), 1)
self.assertEqual(d["top_false_positive"][0]["path"], "b.ts")
def test_fp_score_combines_signals(self):
d = json.loads(feedback_analyze.analyze(self.db, as_json=True))
fp = d["top_false_positive"][0]
# 2 downvotes + 1 unresolved + 2 (negation phrase) = 5
self.assertEqual(fp["fp_score"], 5)
def test_acceptance_score(self):
d = json.loads(feedback_analyze.analyze(self.db, as_json=True))
ac = d["top_accepted"][0]
# 2 upvotes + 1 resolved = 3
self.assertEqual(ac["ac_score"], 3)
def test_case_queue_contains_only_disagreements(self):
d = json.loads(feedback_analyze.analyze(self.db, as_json=True))
queue = d["case_review_queue"]
self.assertEqual(len(queue), 1)
self.assertEqual(queue[0]["path"], "b.ts")
def test_no_signal_finding_is_ignored(self):
d = json.loads(feedback_analyze.analyze(self.db, as_json=True))
# c.ts has no votes, no replies → not in either top list.
paths = {e["path"] for e in d["top_accepted"]}
paths.update(e["path"] for e in d["top_false_positive"])
self.assertNotIn("c.ts", paths)
class TestRestraint(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db = f"{self.tmp.name}/f.db"
self.conn = feedback.init(self.db)
def tearDown(self):
self.conn.close()
self.tmp.cleanup()
def test_high_ratio_triggers_recommendation(self):
# 3 reviews, all with findings → 100% "noisy".
for pr_i in range(3):
feedback.record_review(self.conn, repo="o/r", pr=pr_i, head_sha="x")
# Distinct (path, line) per PR so posthash doesn't dedup.
feedback.record_inline_finding(
self.conn, review_id=None, repo="o/r", pr=pr_i,
path=f"a{pr_i}.ts", line=1, severity="LOW",
problem=f"x {pr_i}",
)
report = feedback_analyze.analyze(self.db)
self.assertIn("⚠️", report)
self.assertIn("100%", report)
def test_low_ratio_passes(self):
# 4 reviews, 1 with findings → 25% noisy = at threshold.
for pr_i in range(4):
feedback.record_review(self.conn, repo="o/r", pr=pr_i, head_sha="x")
feedback.record_inline_finding(
self.conn, review_id=None, repo="o/r", pr=0,
path="a.ts", line=1, severity="LOW", problem="x",
)
report = feedback_analyze.analyze(self.db)
self.assertIn("", report)
class TestMarkdownOutput(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db = f"{self.tmp.name}/f.db"
self.conn = feedback.init(self.db)
_seed(self.conn, [
("o/r", 1, "a.ts", 10, "HIGH", "race in handler",
[("u1", "+1")], [], True),
])
def tearDown(self):
self.conn.close()
self.tmp.cleanup()
def test_report_has_sections(self):
r = feedback_analyze.analyze(self.db)
for section in (
"# pragent feedback report",
"## Restraint",
"## Top",
"## Case-review queue",
"## Where this report goes",
):
self.assertIn(section, r)
def test_doordash_rule_quoted(self):
r = feedback_analyze.analyze(self.db)
# The "noise on clean code" sentence from the DoorDash recap.
self.assertIn("noise on clean code", r)
class TestPosthashAggregation(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db = f"{self.tmp.name}/f.db"
self.conn = feedback.init(self.db)
# Same finding on three PRs → one aggregated row.
# Each PR has its own review + finding (comment_id differs but
# posthash is identical, so they collapse on aggregation).
for pr_i in range(3):
rid = feedback.record_review(self.conn, repo="o/r", pr=pr_i, head_sha="x")
feedback.record_inline_finding(
self.conn, review_id=rid, repo="o/r", pr=pr_i,
path="a.ts", line=10, severity="HIGH",
problem="identical problem text",
comment_id=1000 + pr_i,
)
feedback.record_reaction(
self.conn, comment_id=1000 + pr_i, user="u", content="+1",
)
def tearDown(self):
self.conn.close()
self.tmp.cleanup()
def test_three_occurrences_one_row(self):
d = json.loads(feedback_analyze.analyze(self.db, as_json=True))
self.assertEqual(len(d["top_accepted"]), 1)
self.assertEqual(d["top_accepted"][0]["occurrences"], 3)
self.assertEqual(d["top_accepted"][0]["ac_score"], 3)
if __name__ == "__main__":
unittest.main()
+243
View File
@@ -0,0 +1,243 @@
"""Tests for pilot/feedback_harvest.py.
Mock `gitea_get` so we exercise the harvester's flow against canned Gitea
responses. Verify:
- bot-authored reviews only are processed
- reactions + thread state + replies all get recorded
- best-effort failures don't raise (one bad endpoint shouldn't kill the
whole harvest)
- posthash dedup: harvesting the same PR twice does NOT double-count
reactions.
"""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "..", "pilot"))
import ai_review # noqa: E402
import feedback # noqa: E402
import feedback_harvest # noqa: E402
def _make_fake_gitea(routes: dict):
"""Build a stand-in for `ai_review.gitea_get` that returns canned bodies.
`routes` maps relative path (substring) → (status, json_body). Sort
keys longest-first so e.g. `pulls/5/reviews/100/comments` matches
before `pulls/5/reviews` (which is also a substring of the longer
path).
"""
def fake(api, repo, path, token, accept="application/json"):
for needle in sorted(routes.keys(), key=len, reverse=True):
if needle in path:
status, body = routes[needle]
return status, json.dumps(body).encode()
return 404, b'{"message":"not found"}'
return fake
def _patch(fake):
"""Apply the fake to ai_review.gitea_get and feedback_harvest's import."""
return patch("ai_review.gitea_get", side_effect=fake)
class TestHarvestForPr(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db = f"{self.tmp.name}/f.db"
def tearDown(self):
self.tmp.cleanup()
def _review_payload(self, body="", commit_id="abc123", review_id=100):
return [{
"id": review_id, "user": {"login": "pragent-bot"},
"commit_id": commit_id, "body": body,
"created_at": "2026-08-20T10:00:00Z",
}]
def _inline_payload(self, comment_id=500, body="**[LOW]** x", path="a/b.ts", position=42, resolver=""):
return [{
"id": comment_id, "path": path, "position": position,
"body": body, "resolver": resolver,
}]
def test_happy_path_records_reaction_and_thread(self):
fake = _make_fake_gitea({
"pulls/5/reviews": (200, self._review_payload()),
"pulls/5/reviews/100/comments": (200, self._inline_payload(resolver="masi")),
"issues/comments/500/reactions": (200, [
{"user": {"login": "alice"}, "content": "+1",
"created_at": "2026-08-20T11:00:00Z"},
{"user": {"login": "bob"}, "content": "-1",
"created_at": "2026-08-20T11:01:00Z"},
]),
"issues/5/comments": (200, []), # no replies
})
with _patch(fake):
stats = feedback_harvest.harvest_for_pr(
api="http://x", token="t", repo="o/r", pr_index=5,
db_path=self.db,
)
self.assertEqual(stats["reviews_seen"], 1)
self.assertEqual(stats["findings_seen"], 1)
self.assertEqual(stats["reactions_recorded"], 2)
self.assertEqual(stats["thread_states_recorded"], 1)
# DB should have 1 review, 1 finding, 2 reactions, 1 thread_state
conn = feedback.init(self.db)
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM review").fetchone()[0], 1,
)
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM inline_finding").fetchone()[0], 1,
)
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM reaction").fetchone()[0], 2,
)
self.assertEqual(
conn.execute("SELECT resolved FROM thread_state").fetchone()[0], 1,
)
conn.close()
def test_skips_non_bot_reviews(self):
fake = _make_fake_gitea({
"pulls/5/reviews": (200, [{
"id": 999, "user": {"login": "masi"}, # not the bot
"commit_id": "x", "body": "", "created_at": "2026-08-20T10:00:00Z",
}]),
})
with _patch(fake):
stats = feedback_harvest.harvest_for_pr(
api="http://x", token="t", repo="o/r", pr_index=5,
db_path=self.db,
)
self.assertEqual(stats["reviews_seen"], 0)
conn = feedback.init(self.db)
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM review").fetchone()[0], 0,
)
conn.close()
def test_review_list_failure_does_not_raise(self):
fake = _make_fake_gitea({
"pulls/5/reviews": (500, None),
})
with _patch(fake):
stats = feedback_harvest.harvest_for_pr(
api="http://x", token="t", repo="o/r", pr_index=5,
db_path=self.db,
)
self.assertEqual(stats["reviews_seen"], 0)
self.assertGreaterEqual(stats["errors"], 1)
def test_reactions_endpoint_returns_null_is_tolerated(self):
# Some Gitea endpoints return JSON `null` for empty lists. We must
# not crash — treat it as "no reactions".
fake = _make_fake_gitea({
"pulls/5/reviews": (200, self._review_payload()),
"pulls/5/reviews/100/comments": (200, self._inline_payload()),
"issues/comments/500/reactions": (200, None),
"issues/5/comments": (200, []),
})
with _patch(fake):
stats = feedback_harvest.harvest_for_pr(
api="http://x", token="t", repo="o/r", pr_index=5,
db_path=self.db,
)
self.assertEqual(stats["reactions_recorded"], 0)
def test_reactions_dedup_via_pk_across_harvests(self):
# Two harvests of the same PR — both produce an inline_finding row,
# but reactions are PK-deduped on (comment_id, user, content) so
# the SECOND harvest does NOT double-record them.
fake = _make_fake_gitea({
"pulls/5/reviews": (200, self._review_payload()),
"pulls/5/reviews/100/comments": (200, self._inline_payload()),
"issues/comments/500/reactions": (200, [
{"user": {"login": "alice"}, "content": "+1",
"created_at": "2026-08-20T11:00:00Z"},
]),
"issues/5/comments": (200, []),
})
with _patch(fake):
feedback_harvest.harvest_for_pr(
api="http://x", token="t", repo="o/r", pr_index=5,
db_path=self.db,
)
feedback_harvest.harvest_for_pr(
api="http://x", token="t", repo="o/r", pr_index=5,
db_path=self.db,
)
conn = feedback.init(self.db)
# Two findings (no DB-level posthash UNIQUE), one reaction.
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM inline_finding").fetchone()[0], 2,
)
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM reaction").fetchone()[0], 1,
)
conn.close()
def test_replies_with_review_comment_id_recorded(self):
fake = _make_fake_gitea({
"pulls/5/reviews": (200, self._review_payload()),
"pulls/5/reviews/100/comments": (200, self._inline_payload(comment_id=500)),
"issues/comments/500/reactions": (200, []),
"issues/5/comments": (200, [{
"id": 900, "review_comment_id": 500,
"user": {"login": "alice"},
"body": "False positive — this is intentional",
"created_at": "2026-08-20T12:00:00Z",
}]),
})
with _patch(fake):
stats = feedback_harvest.harvest_for_pr(
api="http://x", token="t", repo="o/r", pr_index=5,
db_path=self.db,
)
self.assertEqual(stats["replies_recorded"], 1)
conn = feedback.init(self.db)
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM reply").fetchone()[0], 1,
)
conn.close()
class TestParseHelpers(unittest.TestCase):
def test_severity_extracted(self):
self.assertEqual(
feedback_harvest._parse_severity("**[HIGH]** race in foo"),
"HIGH",
)
def test_severity_defaults_to_info(self):
self.assertEqual(feedback_harvest._parse_severity("plain text"), "INFO")
def test_path_line_extracted(self):
p, l = feedback_harvest._parse_path_line("see `src/foo.ts:42` here")
self.assertEqual(p, "src/foo.ts")
self.assertEqual(l, 42)
def test_negation_phrases_caught(self):
self.assertTrue(feedback_harvest._is_negation_reply("This is intentional."))
self.assertTrue(feedback_harvest._is_negation_reply("false positive — see X"))
self.assertFalse(feedback_harvest._is_negation_reply("thanks for catching this!"))
# Empty / None safe
self.assertFalse(feedback_harvest._is_negation_reply(""))
self.assertFalse(feedback_harvest._is_negation_reply(None))
def test_classify_reaction(self):
self.assertEqual(feedback_harvest.classify_reaction("+1"), "positive")
self.assertEqual(feedback_harvest.classify_reaction("-1"), "negative")
self.assertEqual(feedback_harvest.classify_reaction("rocket"), "positive")
self.assertEqual(feedback_harvest.classify_reaction("confused"), "negative")
self.assertEqual(feedback_harvest.classify_reaction("eyes"), "neutral")
if __name__ == "__main__":
unittest.main()
+97
View File
@@ -0,0 +1,97 @@
"""Tests for pilot/feedback_post.py — report delivery to Gitea.
Mock `ai_review.gitea_get` + `gitea_post` so we exercise the find-or-create
+ comment-post flow without hitting the real API.
"""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "..", "pilot"))
import feedback # noqa: E402
import feedback_analyze # noqa: E402
import feedback_post # noqa: E402
def _make_fake(method_routes: dict):
"""`method_routes` maps HTTP path substring → (status, body, method).
For our purposes both gitea_get and gitea_post share the same fake —
gitea_get is GET, gitea_post is POST, and the post helper also has a
body param. The fake returns whatever the route's body says.
"""
def fake_get(api, repo, path, token, accept="application/json"):
for needle in sorted(method_routes.keys(), key=len, reverse=True):
status, body, _m = method_routes[needle]
if needle in path:
return status, json.dumps(body).encode()
return 404, b'{"message":"not found"}'
def fake_post(api, repo, path, token, body):
for needle in sorted(method_routes.keys(), key=len, reverse=True):
status, resp_body, _m = method_routes[needle]
if needle in path:
return status, json.dumps(resp_body).encode()
return 404, b'{"message":"not found"}'
return fake_get, fake_post
class TestDeliver(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db = f"{self.tmp.name}/f.db"
conn = feedback.init(self.db)
rid = feedback.record_review(conn, repo="o/r", pr=1, head_sha="x")
feedback.record_inline_finding(
conn, review_id=rid, repo="o/r", pr=1,
path="a.ts", line=1, severity="HIGH",
problem="x", comment_id=99,
)
conn.close()
def tearDown(self):
self.tmp.cleanup()
def test_creates_issue_then_posts_comment(self):
routes = {
"issues?state=open": (200, [], "GET"), # no existing issue
"issues": (201, {"id": 42, "number": 7, "title": "..."}, "POST"),
"issues/7/comments": (201, {"id": 777}, "POST"),
}
fake_get, fake_post = _make_fake(routes)
with patch("ai_review.gitea_get", side_effect=fake_get), \
patch("ai_review.gitea_post", side_effect=fake_post):
stats = feedback_post.deliver(
api="http://x", token="t", db_path=self.db,
repo="gitea_admin/pragent", title="pragent feedback roll-up",
)
self.assertEqual(stats["issue_id"], 7)
self.assertEqual(stats["comment_id"], 777)
def test_reuses_existing_issue(self):
routes = {
"issues?state=open": (200, [
{"id": 99, "number": 9, "title": "pragent feedback roll-up"},
{"id": 100, "number": 10, "title": "something else"},
], "GET"),
"issues/9/comments": (201, {"id": 888}, "POST"),
}
fake_get, fake_post = _make_fake(routes)
with patch("ai_review.gitea_get", side_effect=fake_get), \
patch("ai_review.gitea_post", side_effect=fake_post):
stats = feedback_post.deliver(
api="http://x", token="t", db_path=self.db,
repo="gitea_admin/pragent", title="pragent feedback roll-up",
)
self.assertEqual(stats["issue_id"], 9)
self.assertEqual(stats["comment_id"], 888)
if __name__ == "__main__":
unittest.main()