pr-reviewer/tests/test_github_client.py
Adam Moussa 46cd856fb3
Add pr-reviewer local PR review tool
A single-user local dashboard that pulls open PRs from the
Sea-Haven-Industries org, reviews each with a Fireworks model in the
BLOCK/FIX/NIT/QUESTION format, and posts the review to GitHub as the
token owner. Runs only on localhost; secrets stay in a gitignored .env
and never reach the browser.

Structured as an app/ package plus a static/ frontend so the module
imports and static mount resolve. HTTP uses httpx2 (the runtime lib
starlette's TestClient now prefers), pinned in requirements.txt.

Includes a stdlib-only pytest suite (network mocked, no extra test
deps so CI needs only pytest) with a skip-by-default live Fireworks
test, and CI wired to the org ci-python-app reusable workflow to lint
app and tests and run the mocked suite fully offline. Dependabot covers
pip and github-actions.
2026-07-01 13:48:10 -04:00

207 lines
6.8 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_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")