pr-reviewer/tests/test_github_client.py
Adam Moussa 26f0558589
Group PRs by repo in the sidebar and add auto-merge
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.
2026-07-01 19:11:09 -04:00

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")