feat(bot): клиент SourceCraft API — прогоны, логи кубов, sha коммита, ретраи

- SourceCraftClient (httpx): list_runs, get_run, get_cube_logs (постранично), run_commit_sha;
  ретраи на сетевые ошибки и 5xx с экспоненциальной задержкой, 4xx — SourceCraftError без повторов
- модели: RunStatus/EventType из swagger, «1970-01-01» = не задано, failed_cubes без служебных #-кубов
- у прогона в API нет sha → куб check/meta в tgit-uploads печатает TGIT_COMMIT_SHA (ADR-014)
- CLI: python -m app.sourcecraft.cli runs
- тесты на реальных ответах API (фикстуры), порог покрытия 45 → 80

Refs #10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 18:02:52 +03:00
co-authored by Claude Opus 5.5
parent 15f354d06b
commit c016b47a8a
18 changed files with 1433 additions and 4 deletions
+167
View File
@@ -0,0 +1,167 @@
import json
from pathlib import Path
from typing import Any
import httpx
import pytest
import respx
from app.config import SourceCraftSettings
from app.sourcecraft.client import SourceCraftClient, SourceCraftError
from app.sourcecraft.models import EventType, RunStatus
FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" / "sourcecraft"
API = "https://api.example.test"
REPO = "org/repo"
SHA = "932f4b1a144cfed4fed8eafd94433e0995e5b12d"
def fixture(name: str) -> Any:
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
class Sleeps:
def __init__(self) -> None:
self.delays: list[float] = []
async def __call__(self, delay: float) -> None:
self.delays.append(delay)
@pytest.fixture
def sleeps() -> Sleeps:
return Sleeps()
@pytest.fixture
async def client(sleeps: Sleeps) -> Any:
settings = SourceCraftSettings(token="secret", api_url=API, max_attempts=3) # type: ignore[arg-type]
async with SourceCraftClient(settings, sleep=sleeps) as c:
yield c
@respx.mock
async def test_list_runs_parses_real_response(client: SourceCraftClient) -> None:
route = respx.get(f"{API}/repos/{REPO}/cicd/runs").respond(json=fixture("runs.json"))
runs = await client.list_runs(REPO, page_size=3)
assert route.calls.last.request.headers["Authorization"] == "Bearer secret"
assert route.calls.last.request.url.params["page_size"] == "3"
assert [r.slug for r in runs] == ["1000002", "1000001", "1000000"]
assert runs[1].event_type is EventType.MANUAL
@respx.mock
async def test_failed_run_points_at_failed_cube(client: SourceCraftClient) -> None:
respx.get(f"{API}/repos/{REPO}/cicd/runs/1000001").respond(json=fixture("run_failed.json"))
run = await client.get_run(REPO, "1000001")
assert run.status is RunStatus.FAILED and run.status.finished
assert [(t, c.slug) for _, t, c in run.failed_cubes()] == [("check", "lint")]
assert all(not c.is_service for _, _, c in run.cubes())
assert run.dates.finished_at is not None
@respx.mock
async def test_unfinished_run_has_no_finish_date(client: SourceCraftClient) -> None:
respx.get(f"{API}/repos/{REPO}/cicd/runs/1000002").respond(json=fixture("run_processing.json"))
run = await client.get_run(REPO, "1000002")
assert not run.status.finished
assert run.dates.finished_at is None # API sends 1970-01-01 for "not yet"
check = run.task("check-and-deploy", "check")
assert check is not None and check.status is RunStatus.SUCCESS
assert run.task("check-and-deploy", "nope") is None
@respx.mock
async def test_commit_sha_from_meta_cube(client: SourceCraftClient) -> None:
respx.get(f"{API}/repos/{REPO}/cicd/runs/1000002").respond(json=fixture("run_processing.json"))
respx.get(f"{API}/repos/{REPO}/cicd/logs/1000002/check-and-deploy/check/meta").respond(
json=fixture("log_meta.json")
)
run = await client.get_run(REPO, "1000002")
assert await client.run_commit_sha(REPO, run) == SHA
@respx.mock
async def test_commit_sha_none_when_meta_log_missing(client: SourceCraftClient) -> None:
respx.get(f"{API}/repos/{REPO}/cicd/runs/1000002").respond(json=fixture("run_processing.json"))
respx.get(f"{API}/repos/{REPO}/cicd/logs/1000002/check-and-deploy/check/meta").respond(
404, json={"error_code": "NotFound", "message": "no logs"}
)
run = await client.get_run(REPO, "1000002")
assert await client.run_commit_sha(REPO, run) is None
@respx.mock
async def test_logs_are_read_page_by_page(client: SourceCraftClient) -> None:
url = f"{API}/repos/{REPO}/cicd/logs/1/wf/task/cube"
respx.get(url, params={"page": "1"}).respond(json={"logs": "a\n", "page_complete": True, "done": False})
respx.get(url, params={"page": "2"}).respond(json={"logs": "b\n", "page_complete": True, "done": True})
respx.get(url, params={"page": "3"}).respond(404, json={"error_code": "NotFound", "message": ""})
logs = await client.get_cube_logs(REPO, "1", "wf", "task", "cube")
assert logs.text == "a\nb\n" and logs.done
@respx.mock
async def test_real_failed_lint_log(client: SourceCraftClient) -> None:
respx.get(f"{API}/repos/{REPO}/cicd/logs/1000001/check-and-deploy/check/lint").respond(
json=fixture("log_lint_failed.json")
)
logs = await client.get_cube_logs(REPO, "1000001", "check-and-deploy", "check", "lint")
assert "F821 Undefined name" in logs.text and logs.done
@respx.mock
async def test_retries_transport_errors_and_5xx(client: SourceCraftClient, sleeps: Sleeps) -> None:
respx.get(f"{API}/repos/{REPO}/cicd/runs").mock(
side_effect=[httpx.ConnectError("tls eof"), httpx.Response(503), httpx.Response(200, json={"runs": []})]
)
assert await client.list_runs(REPO) == []
assert sleeps.delays == [0.5, 1.0]
@respx.mock
async def test_gives_up_after_max_attempts(client: SourceCraftClient) -> None:
respx.get(f"{API}/repos/{REPO}/cicd/runs").mock(side_effect=httpx.ConnectError("down"))
with pytest.raises(httpx.ConnectError):
await client.list_runs(REPO)
@respx.mock
async def test_persistent_5xx_raises_api_error(client: SourceCraftClient) -> None:
respx.get(f"{API}/repos/{REPO}/cicd/runs").respond(502, text="bad gateway")
with pytest.raises(SourceCraftError) as exc:
await client.list_runs(REPO)
assert exc.value.status == 502
@respx.mock
async def test_4xx_is_not_retried(client: SourceCraftClient, sleeps: Sleeps) -> None:
route = respx.get(f"{API}/repos/{REPO}/cicd/runs/9").respond(
403, json={"error_code": "PermissionDenied", "message": "no access"}
)
with pytest.raises(SourceCraftError) as exc:
await client.get_run(REPO, "9")
assert (exc.value.status, exc.value.code) == (403, "PermissionDenied")
assert route.call_count == 1 and sleeps.delays == []
async def test_run_url(client: SourceCraftClient) -> None:
assert client.run_url(REPO, "7") == "https://sourcecraft.dev/org/repo/cicd/runs/7"