mirror of
https://github.com/Sea-Haven-Industries/pr-reviewer.git
synced 2026-09-30 19:43: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.
285 lines
9.5 KiB
Python
285 lines
9.5 KiB
Python
"""Tests for app.github_client: _raise_for_status, _resolve_token, and the
|
|
GitHubClient read/write methods. All network mocked; no secrets needed."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from app import github_client as gh_mod
|
|
from app.github_client import (
|
|
GitHubClient,
|
|
GitHubError,
|
|
_raise_for_status,
|
|
_resolve_token,
|
|
)
|
|
|
|
from .conftest import FakeResponse, patch_httpx
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _raise_for_status
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_raise_for_status_extracts_json_message() -> None:
|
|
r = FakeResponse(401, json_data={"message": "Bad credentials"})
|
|
with pytest.raises(GitHubError, match=r"GitHub 401: Bad credentials"):
|
|
_raise_for_status(r)
|
|
|
|
|
|
def test_raise_for_status_falls_back_to_text() -> None:
|
|
r = FakeResponse(502, text="upstream boom") # non-JSON body
|
|
with pytest.raises(GitHubError, match=r"GitHub 502: upstream boom"):
|
|
_raise_for_status(r)
|
|
|
|
|
|
def test_raise_for_status_noop_on_2xx() -> None:
|
|
r = FakeResponse(200, json_data={"ok": True})
|
|
assert _raise_for_status(r) is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _resolve_token
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_resolve_token_prefers_config(make_cfg) -> None:
|
|
cfg = make_cfg(GITHUB_TOKEN="cfg-token")
|
|
assert _resolve_token(cfg) == "cfg-token"
|
|
|
|
|
|
def test_resolve_token_falls_back_to_gh_cli(monkeypatch, make_cfg) -> None:
|
|
cfg = make_cfg(GITHUB_TOKEN="")
|
|
|
|
def fake_run(*args, **kwargs):
|
|
return subprocess.CompletedProcess(args, 0, stdout="gh-cli-token\n", stderr="")
|
|
|
|
monkeypatch.setattr(gh_mod.subprocess, "run", fake_run)
|
|
assert _resolve_token(cfg) == "gh-cli-token"
|
|
|
|
|
|
def test_resolve_token_raises_when_none_available(monkeypatch, make_cfg) -> None:
|
|
cfg = make_cfg(GITHUB_TOKEN="")
|
|
|
|
def fake_run(*args, **kwargs):
|
|
raise FileNotFoundError("gh not installed")
|
|
|
|
monkeypatch.setattr(gh_mod.subprocess, "run", fake_run)
|
|
with pytest.raises(GitHubError, match=r"No GitHub token"):
|
|
_resolve_token(cfg)
|
|
|
|
|
|
def test_resolve_token_raises_when_gh_returns_nonzero(monkeypatch, make_cfg) -> None:
|
|
cfg = make_cfg(GITHUB_TOKEN="")
|
|
|
|
def fake_run(*args, **kwargs):
|
|
return subprocess.CompletedProcess(args, 1, stdout="", stderr="not logged in")
|
|
|
|
monkeypatch.setattr(gh_mod.subprocess, "run", fake_run)
|
|
with pytest.raises(GitHubError, match=r"No GitHub token"):
|
|
_resolve_token(cfg)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# GitHubClient.search_prs
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _client(make_cfg, **overrides) -> GitHubClient:
|
|
# GITHUB_TOKEN set so __init__ -> _resolve_token needs no subprocess.
|
|
return GitHubClient(make_cfg(GITHUB_TOKEN="test-token", **overrides))
|
|
|
|
|
|
def test_search_prs_parses_owner_repo_number(monkeypatch, make_cfg) -> None:
|
|
payload = {
|
|
"items": [
|
|
{
|
|
"repository_url": "https://api.github.com/repos/Sea-Haven-Industries/pr-reviewer",
|
|
"number": 42,
|
|
"title": "Add tests",
|
|
"html_url": "https://github.com/Sea-Haven-Industries/pr-reviewer/pull/42",
|
|
"user": {"login": "octocat"},
|
|
"updated_at": "2026-07-01T00:00:00Z",
|
|
"body": "body text",
|
|
"draft": False,
|
|
}
|
|
]
|
|
}
|
|
client = _client(make_cfg)
|
|
calls = patch_httpx(monkeypatch, gh_mod, FakeResponse(200, json_data=payload))
|
|
|
|
prs = client.search_prs()
|
|
|
|
assert len(prs) == 1
|
|
pr = prs[0]
|
|
assert pr["owner"] == "Sea-Haven-Industries"
|
|
assert pr["repo"] == "pr-reviewer"
|
|
assert pr["number"] == 42
|
|
assert pr["title"] == "Add tests"
|
|
assert pr["author"] == "octocat"
|
|
# Search hit the issues endpoint with the configured filter.
|
|
assert calls[0]["url"].endswith("/search/issues")
|
|
assert calls[0]["params"]["q"] == client.cfg.PR_SEARCH_FILTER
|
|
|
|
|
|
def test_search_prs_includes_created_at_and_node_id(monkeypatch, make_cfg) -> None:
|
|
payload = {
|
|
"items": [
|
|
{
|
|
"repository_url": "https://api.github.com/repos/o/r",
|
|
"number": 7,
|
|
"title": "t",
|
|
"html_url": "u",
|
|
"user": {"login": "octocat"},
|
|
"updated_at": "2026-07-01T00:00:00Z",
|
|
"created_at": "2026-06-20T00:00:00Z",
|
|
"node_id": "PR_kwABC123",
|
|
}
|
|
]
|
|
}
|
|
client = _client(make_cfg)
|
|
patch_httpx(monkeypatch, gh_mod, FakeResponse(200, json_data=payload))
|
|
pr = client.search_prs()[0]
|
|
assert pr["created_at"] == "2026-06-20T00:00:00Z"
|
|
assert pr["node_id"] == "PR_kwABC123"
|
|
|
|
|
|
def test_search_prs_handles_missing_user(monkeypatch, make_cfg) -> None:
|
|
payload = {
|
|
"items": [
|
|
{
|
|
"repository_url": "https://api.github.com/repos/o/r",
|
|
"number": 1,
|
|
"title": "t",
|
|
"html_url": "u",
|
|
"updated_at": "2026-07-01T00:00:00Z",
|
|
}
|
|
]
|
|
}
|
|
client = _client(make_cfg)
|
|
patch_httpx(monkeypatch, gh_mod, FakeResponse(200, json_data=payload))
|
|
prs = client.search_prs()
|
|
assert prs[0]["author"] == ""
|
|
assert prs[0]["body"] == ""
|
|
|
|
|
|
def test_search_prs_raises_on_non_2xx(monkeypatch, make_cfg) -> None:
|
|
client = _client(make_cfg)
|
|
patch_httpx(
|
|
monkeypatch, gh_mod, FakeResponse(403, json_data={"message": "rate limited"})
|
|
)
|
|
with pytest.raises(GitHubError, match=r"GitHub 403: rate limited"):
|
|
client.search_prs()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# GitHubClient.submit_review
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_submit_review_rejects_invalid_event(make_cfg) -> None:
|
|
client = _client(make_cfg)
|
|
with pytest.raises(GitHubError, match=r"Invalid event: LGTM"):
|
|
client.submit_review("o", "r", 1, "body", "LGTM")
|
|
|
|
|
|
def test_submit_review_posts_valid_event(monkeypatch, make_cfg) -> None:
|
|
client = _client(make_cfg)
|
|
calls = patch_httpx(
|
|
monkeypatch,
|
|
gh_mod,
|
|
FakeResponse(
|
|
200, json_data={"html_url": "https://github.com/o/r/pull/1#review"}
|
|
),
|
|
)
|
|
res = client.submit_review("o", "r", 1, "great work", "approve")
|
|
|
|
assert res["html_url"].endswith("#review")
|
|
call = calls[0]
|
|
assert call["method"] == "POST"
|
|
assert call["url"].endswith("/repos/o/r/pulls/1/reviews")
|
|
# Event is upper-cased before send.
|
|
assert call["json"] == {"body": "great work", "event": "APPROVE"}
|
|
|
|
|
|
def test_submit_review_raises_on_non_2xx(monkeypatch, make_cfg) -> None:
|
|
client = _client(make_cfg)
|
|
patch_httpx(
|
|
monkeypatch,
|
|
gh_mod,
|
|
FakeResponse(422, json_data={"message": "Unprocessable"}),
|
|
)
|
|
with pytest.raises(GitHubError, match=r"GitHub 422: Unprocessable"):
|
|
client.submit_review("o", "r", 1, "body", "COMMENT")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# GitHubClient.pr_diff
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_pr_diff_returns_raw_text(monkeypatch, make_cfg) -> None:
|
|
client = _client(make_cfg)
|
|
diff = "diff --git a/x b/x\n+added\n"
|
|
calls = patch_httpx(monkeypatch, gh_mod, FakeResponse(200, text=diff))
|
|
out = client.pr_diff("o", "r", 5)
|
|
assert out == diff
|
|
assert calls[0]["url"].endswith("/repos/o/r/pulls/5")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# GitHubClient.enable_automerge
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_enable_automerge_builds_graphql_mutation(monkeypatch, make_cfg) -> None:
|
|
client = _client(make_cfg)
|
|
calls = patch_httpx(
|
|
monkeypatch,
|
|
gh_mod,
|
|
FakeResponse(200, json_data={"data": {"enablePullRequestAutoMerge": {}}}),
|
|
)
|
|
client.enable_automerge("PR_node1", "squash") # lowercase normalized
|
|
assert calls[0]["url"].endswith("/graphql")
|
|
body = calls[0]["json"]
|
|
assert body["variables"] == {"pr": "PR_node1", "method": "SQUASH"}
|
|
assert "enablePullRequestAutoMerge" in body["query"]
|
|
|
|
|
|
def test_enable_automerge_rejects_bad_method(make_cfg) -> None:
|
|
client = _client(make_cfg)
|
|
with pytest.raises(GitHubError, match=r"Invalid merge method"):
|
|
client.enable_automerge("PR_node1", "FASTFORWARD")
|
|
|
|
|
|
def test_enable_automerge_rejects_missing_node_id(make_cfg) -> None:
|
|
client = _client(make_cfg)
|
|
with pytest.raises(GitHubError, match=r"Missing PR node id"):
|
|
client.enable_automerge("", "SQUASH")
|
|
|
|
|
|
def test_enable_automerge_raises_on_graphql_errors(monkeypatch, make_cfg) -> None:
|
|
client = _client(make_cfg)
|
|
patch_httpx(
|
|
monkeypatch,
|
|
gh_mod,
|
|
FakeResponse(
|
|
200,
|
|
json_data={
|
|
"errors": [{"message": "Auto merge is not allowed for this repository"}]
|
|
},
|
|
),
|
|
)
|
|
with pytest.raises(GitHubError, match=r"not allowed for this repository"):
|
|
client.enable_automerge("PR_node1", "SQUASH")
|
|
|
|
|
|
def test_enable_automerge_raises_on_http_error(monkeypatch, make_cfg) -> None:
|
|
client = _client(make_cfg)
|
|
patch_httpx(
|
|
monkeypatch, gh_mod, FakeResponse(401, json_data={"message": "Bad credentials"})
|
|
)
|
|
with pytest.raises(GitHubError, match=r"GitHub 401: Bad credentials"):
|
|
client.enable_automerge("PR_node1", "SQUASH")
|