mirror of
https://github.com/Sea-Haven-Industries/pr-reviewer.git
synced 2026-09-30 17:23:16 +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.
144 lines
4.8 KiB
Python
144 lines
4.8 KiB
Python
"""Tests for the SQLite review cache (stdlib sqlite3, temp DB per test)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
|
|
import pytest
|
|
|
|
from app.store import ReviewStore
|
|
|
|
# The schema as it shipped before pr_created_at / pr_node_id were added.
|
|
_OLD_SCHEMA = """
|
|
CREATE TABLE reviews (
|
|
owner TEXT NOT NULL, repo TEXT NOT NULL, number INTEGER NOT NULL,
|
|
status TEXT NOT NULL, diff_hash TEXT, review_json TEXT, error TEXT,
|
|
attempts INTEGER NOT NULL DEFAULT 0, pr_title TEXT, pr_author TEXT,
|
|
pr_url TEXT, pr_updated_at TEXT, cached_at REAL NOT NULL, closed_at REAL,
|
|
PRIMARY KEY (owner, repo, number)
|
|
);
|
|
"""
|
|
|
|
|
|
@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_migration_adds_columns_to_old_schema(tmp_path) -> None:
|
|
db = str(tmp_path / "old.db")
|
|
con = sqlite3.connect(db)
|
|
con.executescript(_OLD_SCHEMA)
|
|
con.execute(
|
|
"INSERT INTO reviews (owner, repo, number, status, cached_at) "
|
|
"VALUES ('o', 'r', 1, 'ready', 1.0)"
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
|
|
store = ReviewStore(db) # __init__ migrates
|
|
|
|
con = sqlite3.connect(db)
|
|
cols = {row[1] for row in con.execute("PRAGMA table_info(reviews)")}
|
|
con.close()
|
|
assert "pr_created_at" in cols
|
|
assert "pr_node_id" in cols
|
|
|
|
# Existing row survived, and the new columns are writable.
|
|
assert store.get("o", "r", 1)["status"] == "ready"
|
|
store.upsert(
|
|
"o", "r", 1, status="ready", pr_created_at="2026-06-01", pr_node_id="PR_x"
|
|
)
|
|
row = store.get("o", "r", 1)
|
|
assert row["pr_created_at"] == "2026-06-01"
|
|
assert row["pr_node_id"] == "PR_x"
|
|
|
|
|
|
def test_migration_is_idempotent(tmp_path) -> None:
|
|
db = str(tmp_path / "c.db")
|
|
ReviewStore(db)
|
|
ReviewStore(db) # second open must not fail on already-present columns
|
|
con = sqlite3.connect(db)
|
|
cols = {row[1] for row in con.execute("PRAGMA table_info(reviews)")}
|
|
con.close()
|
|
assert {"pr_created_at", "pr_node_id"} <= cols
|
|
|
|
|
|
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
|