pr-reviewer/tests/test_store.py
Adam Moussa 15a52bff40
Add background auto-review worker with SQLite cache
Pre-compute PR reviews so they are ready the moment a PR is opened in
the queue, instead of waiting on an on-demand Fireworks call each time.

A daemon worker polls the queue every POLL_INTERVAL and reviews new or
changed non-draft PRs into a local SQLite cache (gitignored). Change
detection is two-level: skip when the PR's updated_at is unchanged, and
even when it moved, skip the model call when the diff's SHA-256 matches,
so comment-only bumps do not burn tokens. Failed reviews retry up to
MAX_REVIEW_ATTEMPTS with 429 backoff; departed PRs are closed with a
grace window before purge.

Singletons are initialized eagerly in the FastAPI lifespan before the
worker thread starts to avoid an init race; all cache writes are
serialized. New endpoints GET /api/reviews and POST /api/refresh back a
dashboard that polls for status (reviewing/ready/error) and opens a
ready review instantly. Nothing is posted automatically; the human
still decides. Stdlib-only, so no new runtime or test dependencies.
2026-07-01 14:28:11 -04:00

93 lines
3.1 KiB
Python

"""Tests for the SQLite review cache (stdlib sqlite3, temp DB per test)."""
from __future__ import annotations
import json
import pytest
from app.store import ReviewStore
@pytest.fixture
def store(tmp_path) -> ReviewStore:
return ReviewStore(str(tmp_path / "cache.db"))
def test_upsert_insert_then_get(store: ReviewStore) -> None:
store.upsert(
"o",
"r",
1,
status="ready",
diff_hash="abc",
review_json=json.dumps({"summary": "hi"}),
pr_title="Title",
pr_author="octocat",
pr_url="https://x/1",
pr_updated_at="2026-01-01",
)
row = store.get("o", "r", 1)
assert row is not None
assert row["status"] == "ready"
assert row["diff_hash"] == "abc"
assert row["pr_title"] == "Title"
assert json.loads(row["review_json"])["summary"] == "hi"
assert row["cached_at"] > 0
def test_get_missing_returns_none(store: ReviewStore) -> None:
assert store.get("o", "r", 999) is None
def test_update_preserves_unspecified_columns(store: ReviewStore) -> None:
store.upsert("o", "r", 1, status="ready", diff_hash="h1", review_json="{}")
# Update only status; diff_hash and review_json must survive.
store.upsert("o", "r", 1, status="reviewing")
row = store.get("o", "r", 1)
assert row["status"] == "reviewing"
assert row["diff_hash"] == "h1"
assert row["review_json"] == "{}"
def test_unknown_column_raises(store: ReviewStore) -> None:
with pytest.raises(ValueError, match="unknown review columns"):
store.upsert("o", "r", 1, status="ready", bogus="x")
def test_list_orders_and_filters_closed(store: ReviewStore) -> None:
store.upsert("o", "r", 1, status="ready", pr_updated_at="2026-01-01")
store.upsert(
"o", "r", 2, status="closed", pr_updated_at="2026-02-02", closed_at=1.0
)
all_rows = store.list()
assert [r["number"] for r in all_rows] == [2, 1] # updated_at desc
open_rows = store.list(include_closed=False)
assert [r["number"] for r in open_rows] == [1]
def test_reset_stale_reviewing(store: ReviewStore) -> None:
store.upsert("o", "r", 1, status="reviewing")
store.upsert("o", "r", 2, status="ready")
n = store.reset_stale_reviewing()
assert n == 1
assert store.get("o", "r", 1)["status"] == "error"
assert store.get("o", "r", 1)["error"] == "interrupted"
assert store.get("o", "r", 2)["status"] == "ready"
def test_mark_missing_closed_and_purge(store: ReviewStore) -> None:
store.upsert("o", "r", 1, status="ready")
store.upsert("o", "r", 2, status="ready")
# Only PR 1 is still live; PR 2 should be closed.
store.mark_missing_closed({("o", "r", 1)})
assert store.get("o", "r", 1)["status"] == "ready"
closed = store.get("o", "r", 2)
assert closed["status"] == "closed"
assert closed["closed_at"] is not None
# A long grace keeps it; a zero grace purges it.
assert store.purge_closed(older_than_seconds=10_000) == 0
assert store.get("o", "r", 2) is not None
assert store.purge_closed(older_than_seconds=0) == 1
assert store.get("o", "r", 2) is None