from typing import Any import httpx import pytest import respx from app.config import CISettings from app.db.models import PipelineStatus from app.pipeline.gitea import GiteaCI, GiteaError from app.pipeline.provider import CIRun API = "https://gitea.example.test" RUNS = f"{API}/api/v1/repos/HSE/up/actions/runs" SHA = "eda569427093acb3b39ad5b301216cb5b7400ab8" def run( id: int, path: str = "check.yml@refs/heads/main", status: str = "completed", conclusion: Any = "success" ) -> Any: """Shape of a real Gitea 1.27 run (fields the client doesn't read trimmed).""" run = {"id": id, "path": path, "status": status, "head_sha": SHA, "html_url": f"{API}/HSE/up/actions/runs/{id}"} return run | ({"conclusion": conclusion} if conclusion else {}) # in-progress runs have no conclusion key def job(id: int, conclusion: str, steps: list[tuple[str, str]]) -> Any: return { "id": id, "name": "check", "status": "completed", "conclusion": conclusion, "steps": [{"name": n, "number": i, "status": "completed", "conclusion": c} for i, (n, c) in enumerate(steps)], } async def no_sleep(_: float) -> None: pass @pytest.fixture async def ci() -> Any: c = GiteaCI(CISettings(token="secret", api_url=API, repo="HSE/up", max_attempts=3), sleep=no_sleep) # type: ignore[arg-type] yield c await c.aclose() @respx.mock async def test_finds_newest_run_of_the_workflow(ci: GiteaCI) -> None: other = run(9, path="other.yml@refs/heads/main") route = respx.get(RUNS).respond( json={"workflow_runs": [run(5), other, run(7, status="in_progress", conclusion=None)]} ) found = await ci.find(SHA) assert found == CIRun("7", PipelineStatus.RUNNING, f"{API}/HSE/up/actions/runs/7") assert route.calls.last.request.url.params["head_sha"] == SHA assert route.calls.last.request.headers["Authorization"] == "token secret" @respx.mock async def test_no_run_yet(ci: GiteaCI) -> None: respx.get(RUNS).respond(json={"workflow_runs": [], "total_count": 0}) assert await ci.find(SHA) is None @pytest.mark.parametrize( ("status", "conclusion", "expected"), [ ("queued", None, PipelineStatus.PENDING), ("waiting", None, PipelineStatus.PENDING), ("in_progress", None, PipelineStatus.RUNNING), ("completed", "success", PipelineStatus.SUCCESS), ("completed", "skipped", PipelineStatus.SKIPPED), ], ) @respx.mock async def test_status_mapping(ci: GiteaCI, status: str, conclusion: str | None, expected: PipelineStatus) -> None: respx.get(RUNS).respond(json={"workflow_runs": [run(1, status=status, conclusion=conclusion)]}) found = await ci.find(SHA) assert found is not None and found.status is expected @respx.mock async def test_failure_names_the_step_and_reads_its_log(ci: GiteaCI) -> None: respx.get(RUNS).respond(json={"workflow_runs": [run(3, conclusion="failure")]}) steps = [("Run actions/checkout@v4", "success"), ("lint", "failure"), ("tests", "skipped")] respx.get(f"{RUNS}/3/jobs").respond(json={"jobs": [job(31, "failure", steps)], "total_count": 1}) log = "2026-10-18T20:44:56.7721431Z ruff check .\n2026-10-18T20:44:57.1Z F821 undefined name 'x'\n" respx.get(f"{API}/api/v1/repos/HSE/up/actions/jobs/31/logs").respond(text=log) found = await ci.find(SHA) assert found is not None text = await ci.failure_log(found) assert (found.status, found.failed_step) == (PipelineStatus.FAILED, "check / lint") assert text == "ruff check .\nF821 undefined name 'x'\n" @respx.mock async def test_cancelled_run_is_a_failure(ci: GiteaCI) -> None: respx.get(RUNS).respond(json={"workflow_runs": [run(4, conclusion="cancelled")]}) respx.get(f"{RUNS}/4/jobs").respond(json={"jobs": [job(41, "cancelled", [("lint", "success")])]}) found = await ci.find(SHA) assert found is not None and (found.status, found.failed_step) == (PipelineStatus.FAILED, "check / cancelled") @respx.mock async def test_retries_transport_errors_and_5xx(ci: GiteaCI) -> None: route = respx.get(RUNS) route.side_effect = [ httpx.ConnectError("reset"), httpx.Response(502), httpx.Response(200, json={"workflow_runs": []}), ] assert await ci.find(SHA) is None assert route.call_count == 3 @respx.mock async def test_gives_up_with_a_typed_error(ci: GiteaCI) -> None: respx.get(RUNS).respond(404, json={"message": "not found"}) with pytest.raises(GiteaError, match="404"): await ci.find(SHA)