mirror of
https://github.com/Sea-Haven-Industries/pr-reviewer.git
synced 2026-09-30 10:23:15 +00:00
Group the review queue into a collapsible section per repo (collapse state persisted in localStorage), with PRs ordered oldest to newest by creation date, so a large multi-repo queue is easier to scan. Add an optional per-PR auto-merge control: a method choice (squash by default per handbook, merge, or rebase) enables GitHub auto-merge via a GraphQL mutation, so the PR merges once required checks pass. It only fires when clicked; nothing merges automatically. Back this with created_at and node_id from the PR search, two new nullable store columns added via an idempotent PRAGMA-guarded migration, and a cheap-gate metadata backfill so already-cached PRs gain node_id without being re-reviewed. New endpoint POST /api/automerge.
199 lines
6.2 KiB
Python
199 lines
6.2 KiB
Python
"""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
|