mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
_classify_check_runs counted any completed cancelled check-run as failing. The push + pull_request double-trigger leaves cancelled same-name check-runs on the same head SHA next to the surviving run's green, so the pr_pass gate saw permanent red on a genuinely green PR. Keep only the newest (highest-id) run per check name before classifying.
408 lines
15 KiB
Python
408 lines
15 KiB
Python
"""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}
|