mirror of
https://github.com/Sea-Haven-Industries/pr-reviewer.git
synced 2026-09-30 12:43:17 +00:00
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.
207 lines
6.8 KiB
Python
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")
|