"""GitService.get_pr_ci_status — the CI signal behind the pr_pass gate. Reads GitHub check-runs on a PR's head SHA (falling back to list-workflows when zero check-runs exist yet) and classifies the result into one of: success, failure, pending, pending_not_scheduled, no_ci_configured, error. Every unresolvable case is classified explicitly — a missing project/git_url/ token, or an unreachable/nonexistent repo or PR, is ``no_ci_configured`` (the guard passes through with an evidence stamp); a genuine GitHub API failure on a real, reachable repo is ``error`` (the guard stays fail-closed). """ from __future__ import annotations from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from roboco.services.git import GitService _PR = 42 _SHA = "deadbeefcafebabe0000111122223333aaaabbbb" _HTTP_SUCCESS_RANGE = range(200, 300) def _service() -> GitService: session = MagicMock() session.execute = AsyncMock() svc = GitService(session) object.__setattr__(svc, "_token_for_project", AsyncMock(return_value="tok")) return svc def _resp(status_code: int, *, json_payload: Any = None) -> MagicMock: resp = MagicMock() resp.status_code = status_code resp.is_success = status_code in _HTTP_SUCCESS_RANGE resp.json.return_value = json_payload return resp def _client(*get_responses: MagicMock) -> MagicMock: """A fake httpx.AsyncClient whose ``.get`` serves responses in call order — every ``async with httpx.AsyncClient(...) as client`` in the service reuses the SAME instance (the patch target returns it unconditionally), so a list ``side_effect`` lines up with the sequential PR-head / check-runs / workflows calls.""" client = MagicMock() client.__aenter__ = AsyncMock(return_value=client) client.__aexit__ = AsyncMock(return_value=False) client.get = AsyncMock(side_effect=list(get_responses)) return client def _patch_project() -> Any: fake = MagicMock() fake.get_by_slug = AsyncMock( return_value=MagicMock(git_url="https://github.com/acme/repo.git") ) return patch("roboco.services.git.get_project_service", return_value=fake) def _pr_head_resp() -> MagicMock: return _resp(200, json_payload={"head": {"sha": _SHA}}) def _check_run(name: str, *, status: str, conclusion: str | None) -> dict[str, Any]: return {"name": name, "status": status, "conclusion": conclusion} # --------------------------------------------------------------------------- # Six CI-guard branches # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_all_checks_green_is_success() -> None: checks = _resp( 200, json_payload={ "check_runs": [ _check_run("lint", status="completed", conclusion="success"), _check_run("tests", status="completed", conclusion="neutral"), ] }, ) client = _client(_pr_head_resp(), checks) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "success", "head_sha": _SHA} @pytest.mark.asyncio async def test_failing_check_names_it() -> None: checks = _resp( 200, json_payload={ "check_runs": [ _check_run("lint", status="completed", conclusion="success"), _check_run("tests", status="completed", conclusion="failure"), ] }, ) client = _client(_pr_head_resp(), checks) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out is not None assert out["state"] == "failure" assert out["failing_checks"] == ["tests"] assert out["head_sha"] == _SHA @pytest.mark.asyncio async def test_cancelled_duplicate_run_superseded_by_newer_green() -> None: """A superseded duplicate workflow run's cancelled check-run must not mask the newer same-name run's green (the push + pull_request double-trigger leaves both on the same head SHA).""" checks = _resp( 200, json_payload={ "check_runs": [ { "id": 1, "name": "tests", "status": "completed", "conclusion": "cancelled", }, { "id": 2, "name": "tests", "status": "completed", "conclusion": "success", }, ] }, ) client = _client(_pr_head_resp(), checks) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "success", "head_sha": _SHA} @pytest.mark.asyncio async def test_newest_same_name_run_failing_still_fails() -> None: """Dedup keeps the newest run per name — a red re-run supersedes an older green, never the reverse.""" checks = _resp( 200, json_payload={ "check_runs": [ { "id": 1, "name": "tests", "status": "completed", "conclusion": "success", }, { "id": 2, "name": "tests", "status": "completed", "conclusion": "failure", }, ] }, ) client = _client(_pr_head_resp(), checks) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out is not None assert out["state"] == "failure" assert out["failing_checks"] == ["tests"] @pytest.mark.asyncio async def test_still_running_check_is_pending() -> None: checks = _resp( 200, json_payload={ "check_runs": [ _check_run("lint", status="completed", conclusion="success"), _check_run("tests", status="in_progress", conclusion=None), ] }, ) client = _client(_pr_head_resp(), checks) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "pending", "head_sha": _SHA} @pytest.mark.asyncio async def test_check_runs_api_error_is_error_state() -> None: checks = _resp(500, json_payload={"message": "internal error"}) client = _client(_pr_head_resp(), checks) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "error", "head_sha": _SHA} @pytest.mark.asyncio async def test_zero_checks_with_no_workflows_is_no_ci_configured() -> None: checks = _resp(200, json_payload={"check_runs": []}) workflows = _resp(200, json_payload={"total_count": 0}) client = _client(_pr_head_resp(), checks, workflows) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "no_ci_configured", "head_sha": _SHA} @pytest.mark.asyncio async def test_zero_checks_with_workflows_configured_is_pending_not_scheduled() -> None: checks = _resp(200, json_payload={"check_runs": []}) workflows = _resp(200, json_payload={"total_count": 3}) client = _client(_pr_head_resp(), checks, workflows) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "pending_not_scheduled", "head_sha": _SHA} # --------------------------------------------------------------------------- # Config gaps and an unreachable/nonexistent repo pass through cleanly as # no_ci_configured (pr_pass stamps the evidence note, never mistaken for a # CI signal) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_no_ci_configured_on_missing_token() -> None: svc = _service() object.__setattr__(svc, "_token_for_project", AsyncMock(return_value=None)) with _patch_project(): out = await svc.get_pr_ci_status("roboco", _PR) assert out == {"state": "no_ci_configured", "head_sha": None} @pytest.mark.asyncio async def test_no_ci_configured_on_missing_project() -> None: fake = MagicMock() fake.get_by_slug = AsyncMock(return_value=None) with patch("roboco.services.git.get_project_service", return_value=fake): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "no_ci_configured", "head_sha": None} @pytest.mark.asyncio async def test_no_ci_configured_when_pr_lookup_404s() -> None: # The PR lookup itself 404s — the repo/PR doesn't exist or isn't reachable. pr_lookup = _resp(404, json_payload={"message": "not found"}) client = _client(pr_lookup) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "no_ci_configured", "head_sha": None} @pytest.mark.asyncio async def test_no_ci_configured_when_pr_lookup_unreachable() -> None: # A connection/network failure resolving the PR's head SHA — the repo is # unreachable, not just returning an error response. client = MagicMock() client.__aenter__ = AsyncMock(return_value=client) client.__aexit__ = AsyncMock(return_value=False) client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused")) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "no_ci_configured", "head_sha": None} # --------------------------------------------------------------------------- # A real, reachable repo's genuinely failing GitHub API call stays # fail-closed (error), never conflated with the no_ci_configured cases above # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_error_when_pr_lookup_api_fails_on_real_repo() -> None: # The repo/project/token all resolve fine, but the PR-head-sha lookup # itself returns a genuine 5xx — this must stay fail-closed (error), not # be conflated with the unreachable/nonexistent-repo no_ci_configured case. pr_lookup = _resp(500, json_payload={"message": "internal error"}) client = _client(pr_lookup) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "error", "head_sha": None} @pytest.mark.asyncio async def test_error_when_pr_lookup_body_unparseable() -> None: pr_lookup = _resp(200, json_payload={"head": {}}) # missing "sha" key client = _client(pr_lookup) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "error", "head_sha": None} @pytest.mark.asyncio async def test_workflows_api_error_after_zero_checks_is_error_state() -> None: checks = _resp(200, json_payload={"check_runs": []}) workflows = _resp(503, json_payload={"message": "busy"}) client = _client(_pr_head_resp(), checks, workflows) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "error", "head_sha": _SHA} # --------------------------------------------------------------------------- # A 404 on the check-runs or workflows endpoint means the repo has no CI # integration at all (e.g. the e2e harness's fake GitHub with no routes # mounted for either) — no_ci_configured, never mistaken for error. 500s and # network failures on the same two endpoints stay fail-closed (error). # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_no_ci_configured_when_check_runs_404s() -> None: checks = _resp(404, json_payload={"message": "not found"}) client = _client(_pr_head_resp(), checks) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "no_ci_configured", "head_sha": _SHA} @pytest.mark.asyncio async def test_error_when_check_runs_request_times_out() -> None: client = MagicMock() client.__aenter__ = AsyncMock(return_value=client) client.__aexit__ = AsyncMock(return_value=False) client.get = AsyncMock( side_effect=[_pr_head_resp(), httpx.ReadTimeout("timed out")] ) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "error", "head_sha": _SHA} @pytest.mark.asyncio async def test_no_ci_configured_when_workflows_404s() -> None: checks = _resp(200, json_payload={"check_runs": []}) workflows = _resp(404, json_payload={"message": "not found"}) client = _client(_pr_head_resp(), checks, workflows) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "no_ci_configured", "head_sha": _SHA} @pytest.mark.asyncio async def test_error_when_workflows_request_times_out() -> None: checks = _resp(200, json_payload={"check_runs": []}) client = MagicMock() client.__aenter__ = AsyncMock(return_value=client) client.__aexit__ = AsyncMock(return_value=False) client.get = AsyncMock( side_effect=[_pr_head_resp(), checks, httpx.ReadTimeout("timed out")] ) with ( _patch_project(), patch("roboco.services.git.httpx.AsyncClient", return_value=client), ): out = await _service().get_pr_ci_status("roboco", _PR) assert out == {"state": "error", "head_sha": _SHA}