pr-reviewer/tests/test_worker.py

200 lines
6.2 KiB
Python
Raw Normal View History

"""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