"""Tests for the auto-review worker's run_cycle and backoff (no network). Uses a real ReviewStore on a temp DB plus fake GitHub/reviewer objects, and drives run_cycle directly (its deps are injected, so no thread/timer needed). """ from __future__ import annotations import json from types import SimpleNamespace import pytest from app.store import ReviewStore from app.worker import _review_with_backoff, run_cycle class FakeGH: def __init__(self, prs: list[dict], diffs: dict[int, str]) -> None: self.prs = prs self.diffs = diffs def search_prs(self) -> list[dict]: return [dict(p) for p in self.prs] # copy: mimic a fresh fetch def pr_diff(self, owner: str, repo: str, number: int) -> str: return self.diffs[number] class CountingReviewer: def __init__(self) -> None: self.calls = 0 def review(self, pr: dict, diff: str) -> dict: self.calls += 1 return {"summary": f"review {pr['number']}", "recommended_event": "COMMENT"} def _pr(number: int, *, updated_at: str, draft: bool = False) -> dict: return { "owner": "o", "repo": "r", "number": number, "title": f"PR {number}", "author": "octocat", "url": f"https://x/{number}", "updated_at": updated_at, "draft": draft, } @pytest.fixture def store(tmp_path) -> ReviewStore: return ReviewStore(str(tmp_path / "cache.db")) def test_reviews_nondraft_skips_draft(store: ReviewStore) -> None: gh = FakeGH( [_pr(1, updated_at="d1"), _pr(2, updated_at="d1", draft=True)], {1: "diff-1", 2: "diff-2"}, ) rev = CountingReviewer() res = run_cycle(gh, rev, store, concurrency=1) assert res["reviewed"] == 1 assert rev.calls == 1 row = store.get("o", "r", 1) assert row["status"] == "ready" assert json.loads(row["review_json"])["summary"] == "review 1" assert store.get("o", "r", 2) is None # draft never touched def test_cheap_gate_skips_unchanged_updated_at(store: ReviewStore) -> None: gh = FakeGH([_pr(1, updated_at="v1")], {1: "diff-1"}) rev = CountingReviewer() run_cycle(gh, rev, store, concurrency=1) res2 = run_cycle(gh, rev, store, concurrency=1) assert res2["reviewed"] == 0 assert res2["skipped"] == 1 assert rev.calls == 1 # not re-reviewed, and no diff even compared def test_cheap_gate_backfills_metadata_without_rereview(store: ReviewStore) -> None: gh = FakeGH([_pr(1, updated_at="v1")], {1: "d1"}) rev = CountingReviewer() run_cycle(gh, rev, store, concurrency=1) assert rev.calls == 1 # Same updated_at, but the PR now carries node_id/created_at (e.g. columns # added in a later release). Backfill them, but do NOT re-review. gh.prs[0]["node_id"] = "PR_new" gh.prs[0]["created_at"] = "2026-06-01T00:00:00Z" res = run_cycle(gh, rev, store, concurrency=1) assert rev.calls == 1 # not re-reviewed assert res["skipped"] == 1 row = store.get("o", "r", 1) assert row["pr_node_id"] == "PR_new" assert row["pr_created_at"] == "2026-06-01T00:00:00Z" def test_precise_gate_skips_when_diff_unchanged(store: ReviewStore) -> None: gh = FakeGH([_pr(1, updated_at="v1")], {1: "diff-1"}) rev = CountingReviewer() run_cycle(gh, rev, store, concurrency=1) # updated_at bumped (e.g. a comment) but the diff is identical. gh.prs[0]["updated_at"] = "v2" res = run_cycle(gh, rev, store, concurrency=1) assert res["unchanged"] == 1 assert rev.calls == 1 # LLM not called again assert store.get("o", "r", 1)["pr_updated_at"] == "v2" # metadata refreshed def test_changed_diff_triggers_rereview(store: ReviewStore) -> None: gh = FakeGH([_pr(1, updated_at="v1")], {1: "diff-1"}) rev = CountingReviewer() run_cycle(gh, rev, store, concurrency=1) gh.prs[0]["updated_at"] = "v2" gh.diffs[1] = "diff-1-CHANGED" res = run_cycle(gh, rev, store, concurrency=1) assert res["reviewed"] == 1 assert rev.calls == 2 def test_error_stored_and_attempts_capped(store: ReviewStore) -> None: class BoomReviewer: def review(self, pr, diff): raise ValueError("fireworks exploded") gh = FakeGH([_pr(1, updated_at="v1")], {1: "diff-1"}) rev = BoomReviewer() r1 = run_cycle(gh, rev, store, concurrency=1, max_attempts=2) assert r1["error"] == 1 assert store.get("o", "r", 1)["status"] == "error" assert store.get("o", "r", 1)["attempts"] == 1 run_cycle(gh, rev, store, concurrency=1, max_attempts=2) assert store.get("o", "r", 1)["attempts"] == 2 # attempts now >= max and updated_at unchanged -> stop retrying r3 = run_cycle(gh, rev, store, concurrency=1, max_attempts=2) assert r3["skipped"] == 1 assert r3["error"] == 0 assert store.get("o", "r", 1)["attempts"] == 2 def test_departed_pr_is_closed(store: ReviewStore) -> None: gh = FakeGH([_pr(1, updated_at="v1")], {1: "diff-1"}) rev = CountingReviewer() run_cycle(gh, rev, store, concurrency=1) gh.prs = [] # PR merged/closed, no longer in the queue run_cycle(gh, rev, store, concurrency=1) assert store.get("o", "r", 1)["status"] == "closed" # --- backoff helper --------------------------------------------------------- # def _http_error(status: int) -> Exception: e = Exception(f"HTTP {status}") e.response = SimpleNamespace(status_code=status) return e def test_backoff_retries_on_429_then_succeeds() -> None: class Flaky: def __init__(self) -> None: self.n = 0 def review(self, pr, diff): self.n += 1 if self.n < 3: raise _http_error(429) return {"summary": "ok"} sleeps: list[float] = [] out = _review_with_backoff( Flaky(), {}, "d", retries=3, base=0.01, sleep=sleeps.append ) assert out["summary"] == "ok" assert len(sleeps) == 2 # two 429s -> two backoffs def test_backoff_does_not_retry_non_429() -> None: class Boom: def review(self, pr, diff): raise ValueError("nope") sleeps: list[float] = [] with pytest.raises(ValueError): _review_with_backoff(Boom(), {}, "d", retries=3, sleep=sleeps.append) assert sleeps == [] # non-429 fails fast