mirror of
https://github.com/Sea-Haven-Industries/pr-reviewer.git
synced 2026-09-30 08:03:17 +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.
346 lines
10 KiB
Python
346 lines
10 KiB
Python
"""Endpoint tests via FastAPI TestClient.
|
|
|
|
The lazy singletons ``app.main.gh`` / ``app.main.reviewer`` are monkeypatched to
|
|
return fakes, so no network and no secrets are required. TestClient uses the
|
|
already-installed httpx2, so no extra test deps.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import main as main_mod
|
|
from app.github_client import GitHubError
|
|
from app.store import ReviewStore
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> TestClient:
|
|
return TestClient(main_mod.app)
|
|
|
|
|
|
class FakeGH:
|
|
def __init__(self) -> None:
|
|
self.posted: dict[str, Any] | None = None
|
|
|
|
def search_prs(self) -> list[dict[str, Any]]:
|
|
return [{"owner": "o", "repo": "r", "number": 1, "title": "t"}]
|
|
|
|
def whoami(self) -> str:
|
|
return "octocat"
|
|
|
|
def pr_diff(self, owner: str, repo: str, number: int) -> str:
|
|
return "diff --git a/x b/x\n+hi\n"
|
|
|
|
def submit_review(self, owner, repo, number, body, event) -> dict[str, Any]:
|
|
self.posted = {"event": event, "body": body}
|
|
return {"html_url": "https://github.com/o/r/pull/1#review"}
|
|
|
|
|
|
class FakeReviewer:
|
|
def review(self, pr: dict[str, Any], diff: str) -> dict[str, Any]:
|
|
return {
|
|
"summary": "ok",
|
|
"recommended_event": "COMMENT",
|
|
"_body_markdown": "**Summary:** ok",
|
|
"_diff_seen": diff,
|
|
}
|
|
|
|
|
|
def _patch(monkeypatch, gh=None, reviewer=None):
|
|
if gh is not None:
|
|
monkeypatch.setattr(main_mod, "gh", lambda: gh)
|
|
if reviewer is not None:
|
|
monkeypatch.setattr(main_mod, "reviewer", lambda: reviewer)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/config
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_api_config(client: TestClient) -> None:
|
|
r = client.get("/api/config")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert set(data) == {"org", "filter", "model", "mention_authors"}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/prs
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_api_prs_ok(client: TestClient, monkeypatch) -> None:
|
|
_patch(monkeypatch, gh=FakeGH())
|
|
r = client.get("/api/prs")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["me"] == "octocat"
|
|
assert data["prs"][0]["number"] == 1
|
|
|
|
|
|
def test_api_prs_github_error_maps_to_400(client: TestClient, monkeypatch) -> None:
|
|
class BoomGH(FakeGH):
|
|
def search_prs(self):
|
|
raise GitHubError("GitHub 401: Bad credentials")
|
|
|
|
_patch(monkeypatch, gh=BoomGH())
|
|
r = client.get("/api/prs")
|
|
assert r.status_code == 400
|
|
assert "Bad credentials" in r.json()["detail"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/review
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _review_body() -> dict[str, Any]:
|
|
return {
|
|
"owner": "o",
|
|
"repo": "r",
|
|
"number": 1,
|
|
"title": "t",
|
|
"author": "octocat",
|
|
"body": "b",
|
|
}
|
|
|
|
|
|
def test_api_review_ok(client: TestClient, monkeypatch) -> None:
|
|
_patch(monkeypatch, gh=FakeGH(), reviewer=FakeReviewer())
|
|
r = client.post("/api/review", json=_review_body())
|
|
assert r.status_code == 200
|
|
assert r.json()["review"]["summary"] == "ok"
|
|
|
|
|
|
def test_api_review_github_error_maps_to_400(client: TestClient, monkeypatch) -> None:
|
|
class BoomGH(FakeGH):
|
|
def pr_diff(self, *a):
|
|
raise GitHubError("GitHub 404: Not Found")
|
|
|
|
_patch(monkeypatch, gh=BoomGH(), reviewer=FakeReviewer())
|
|
r = client.post("/api/review", json=_review_body())
|
|
assert r.status_code == 400
|
|
assert "Not Found" in r.json()["detail"]
|
|
|
|
|
|
def test_api_review_generic_error_maps_to_500(client: TestClient, monkeypatch) -> None:
|
|
class BoomReviewer:
|
|
def review(self, pr, diff):
|
|
raise ValueError("fireworks exploded")
|
|
|
|
_patch(monkeypatch, gh=FakeGH(), reviewer=BoomReviewer())
|
|
r = client.post("/api/review", json=_review_body())
|
|
assert r.status_code == 500
|
|
assert "Review failed" in r.json()["detail"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/revise
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_api_revise_folds_notes_into_diff(client: TestClient, monkeypatch) -> None:
|
|
reviewer = FakeReviewer()
|
|
_patch(monkeypatch, gh=FakeGH(), reviewer=reviewer)
|
|
body = _review_body()
|
|
body["notes"] = "be harsher on error handling"
|
|
r = client.post("/api/revise", json=body)
|
|
assert r.status_code == 200
|
|
seen = r.json()["review"]["_diff_seen"]
|
|
assert "REVIEWER REVISION NOTES" in seen
|
|
assert "be harsher on error handling" in seen
|
|
|
|
|
|
def test_api_revise_generic_error_maps_to_500(client: TestClient, monkeypatch) -> None:
|
|
class BoomReviewer:
|
|
def review(self, pr, diff):
|
|
raise RuntimeError("nope")
|
|
|
|
_patch(monkeypatch, gh=FakeGH(), reviewer=BoomReviewer())
|
|
body = _review_body()
|
|
body["notes"] = "x"
|
|
r = client.post("/api/revise", json=body)
|
|
assert r.status_code == 500
|
|
assert "Revision failed" in r.json()["detail"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/post
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_api_post_ok(client: TestClient, monkeypatch) -> None:
|
|
fake = FakeGH()
|
|
_patch(monkeypatch, gh=fake)
|
|
r = client.post(
|
|
"/api/post",
|
|
json={
|
|
"owner": "o",
|
|
"repo": "r",
|
|
"number": 1,
|
|
"body": "lgtm",
|
|
"event": "APPROVE",
|
|
},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["ok"] is True
|
|
assert fake.posted == {"event": "APPROVE", "body": "lgtm"}
|
|
|
|
|
|
def test_api_post_github_error_maps_to_400(client: TestClient, monkeypatch) -> None:
|
|
class BoomGH(FakeGH):
|
|
def submit_review(self, *a):
|
|
raise GitHubError("Invalid event: LGTM")
|
|
|
|
_patch(monkeypatch, gh=BoomGH())
|
|
r = client.post(
|
|
"/api/post",
|
|
json={"owner": "o", "repo": "r", "number": 1, "body": "b", "event": "LGTM"},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "Invalid event" in r.json()["detail"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/reviews and /api/refresh (background-worker cache)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_api_reviews_returns_cached(client: TestClient, monkeypatch, tmp_path) -> None:
|
|
s = ReviewStore(str(tmp_path / "cache.db"))
|
|
s.upsert(
|
|
"o",
|
|
"r",
|
|
1,
|
|
status="ready",
|
|
review_json=json.dumps({"summary": "hi"}),
|
|
pr_title="t",
|
|
pr_author="a",
|
|
pr_url="u",
|
|
pr_updated_at="2026-01-01",
|
|
)
|
|
monkeypatch.setattr(main_mod, "_store", s)
|
|
r = client.get("/api/reviews")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["poll_interval"] > 0
|
|
item = data["reviews"][0]
|
|
assert item["status"] == "ready"
|
|
assert item["review"]["summary"] == "hi"
|
|
|
|
|
|
def test_api_reviews_503_without_store(client: TestClient, monkeypatch) -> None:
|
|
monkeypatch.setattr(main_mod, "_store", None)
|
|
assert client.get("/api/reviews").status_code == 503
|
|
|
|
|
|
def test_api_refresh_triggers_worker(client: TestClient, monkeypatch) -> None:
|
|
class FakeWorker:
|
|
def __init__(self) -> None:
|
|
self.triggered = False
|
|
|
|
def trigger(self) -> None:
|
|
self.triggered = True
|
|
|
|
w = FakeWorker()
|
|
monkeypatch.setattr(main_mod, "_worker", w)
|
|
r = client.post("/api/refresh")
|
|
assert r.status_code == 202
|
|
assert r.json()["scheduled"] is True
|
|
assert w.triggered is True
|
|
|
|
|
|
def test_api_refresh_503_without_worker(client: TestClient, monkeypatch) -> None:
|
|
monkeypatch.setattr(main_mod, "_worker", None)
|
|
assert client.post("/api/refresh").status_code == 503
|
|
|
|
|
|
def test_api_automerge_ok(client: TestClient, monkeypatch) -> None:
|
|
class MergeGH(FakeGH):
|
|
def enable_automerge(self, node_id, method):
|
|
self.merged = {"node_id": node_id, "method": method}
|
|
return {"pullRequest": {"number": 1}}
|
|
|
|
gh = MergeGH()
|
|
_patch(monkeypatch, gh=gh)
|
|
r = client.post(
|
|
"/api/automerge",
|
|
json={
|
|
"owner": "o",
|
|
"repo": "r",
|
|
"number": 1,
|
|
"node_id": "PR_x",
|
|
"method": "SQUASH",
|
|
},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["ok"] is True
|
|
assert gh.merged == {"node_id": "PR_x", "method": "SQUASH"}
|
|
|
|
|
|
def test_api_automerge_error_maps_to_400(client: TestClient, monkeypatch) -> None:
|
|
class BoomGH(FakeGH):
|
|
def enable_automerge(self, node_id, method):
|
|
raise GitHubError("Auto merge is not allowed for this repository")
|
|
|
|
_patch(monkeypatch, gh=BoomGH())
|
|
r = client.post(
|
|
"/api/automerge",
|
|
json={
|
|
"owner": "o",
|
|
"repo": "r",
|
|
"number": 1,
|
|
"node_id": "PR_x",
|
|
"method": "SQUASH",
|
|
},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "not allowed" in r.json()["detail"]
|
|
|
|
|
|
def test_api_handbook_disabled_without_provider(
|
|
client: TestClient, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setattr(main_mod, "_handbook", None)
|
|
r = client.get("/api/handbook")
|
|
assert r.status_code == 200
|
|
assert r.json()["enabled"] is False
|
|
|
|
|
|
def test_api_handbook_reports_status(client: TestClient, monkeypatch) -> None:
|
|
class FakeHB:
|
|
def status(self) -> dict:
|
|
return {
|
|
"enabled": True,
|
|
"head_sha": "abc1234",
|
|
"distilled_at": 123.0,
|
|
"digest_chars": 42,
|
|
}
|
|
|
|
monkeypatch.setattr(main_mod, "_handbook", FakeHB())
|
|
r = client.get("/api/handbook")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["head_sha"] == "abc1234"
|
|
assert body["digest_chars"] == 42
|
|
assert "digest" not in body # never expose the digest text
|
|
|
|
|
|
def test_api_review_writes_through_to_cache(
|
|
client: TestClient, monkeypatch, tmp_path
|
|
) -> None:
|
|
s = ReviewStore(str(tmp_path / "cache.db"))
|
|
monkeypatch.setattr(main_mod, "_store", s)
|
|
_patch(monkeypatch, gh=FakeGH(), reviewer=FakeReviewer())
|
|
r = client.post("/api/review", json=_review_body())
|
|
assert r.status_code == 200
|
|
row = s.get("o", "r", 1)
|
|
assert row is not None
|
|
assert row["status"] == "ready"
|
|
assert json.loads(row["review_json"])["summary"] == "ok"
|