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