From 25735fee96a76245f75dfa981a12c5e62f4a2664 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Wed, 14 Oct 2026 18:02:52 +0300 Subject: [PATCH] =?UTF-8?q?feat(bot):=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=20SourceCraft=20API=20=E2=80=94=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=D0=BD=D1=8B,=20=D0=BB=D0=BE=D0=B3=D0=B8=20=D0=BA?= =?UTF-8?q?=D1=83=D0=B1=D0=BE=D0=B2,=20sha=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D0=B0,=20=D1=80=D0=B5=D1=82=D1=80=D0=B0=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 3 +- app/config.py | 13 + app/sourcecraft/__init__.py | 0 app/sourcecraft/cli.py | 28 ++ app/sourcecraft/client.py | 140 ++++++ app/sourcecraft/models.py | 113 +++++ docs/ARCHITECTURE.md | 4 +- docs/DECISIONS.md | 8 + docs/dev/SOURCECRAFT_API.md | 6 + infra/uploads-template/.sourcecraft/ci.yaml | 6 + pyproject.toml | 2 +- .../fixtures/sourcecraft/log_lint_failed.json | 5 + tests/fixtures/sourcecraft/log_meta.json | 5 + tests/fixtures/sourcecraft/run_failed.json | 396 ++++++++++++++++ .../fixtures/sourcecraft/run_processing.json | 437 ++++++++++++++++++ tests/fixtures/sourcecraft/runs.json | 104 +++++ tests/sourcecraft/__init__.py | 0 tests/sourcecraft/test_client.py | 167 +++++++ 18 files changed, 1433 insertions(+), 4 deletions(-) create mode 100644 app/sourcecraft/__init__.py create mode 100644 app/sourcecraft/cli.py create mode 100644 app/sourcecraft/client.py create mode 100644 app/sourcecraft/models.py create mode 100644 tests/fixtures/sourcecraft/log_lint_failed.json create mode 100644 tests/fixtures/sourcecraft/log_meta.json create mode 100644 tests/fixtures/sourcecraft/run_failed.json create mode 100644 tests/fixtures/sourcecraft/run_processing.json create mode 100644 tests/fixtures/sourcecraft/runs.json create mode 100644 tests/sourcecraft/__init__.py create mode 100644 tests/sourcecraft/test_client.py diff --git a/.env.example b/.env.example index dbc60ed..e4b4d1e 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,8 @@ POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postg # --- SourceCraft (#7, #10) --- SOURCECRAFT_TOKEN= # (secret) персональный токен API -UPLOADS_REPO=rogachevegorushka/tgit-uploads +SOURCECRAFT_UPLOADS_REPO=rogachevegorushka/tgit-uploads +# SOURCECRAFT_RUN_URL_TEMPLATE=https://sourcecraft.dev/{repo}/cicd/runs/{run} # --- Git: коммиты в репозиторий загрузок (#7, #9) --- GIT_SSH_KEY_PATH= # путь к приватному deploy-ключу бота (монтируется read-only); diff --git a/app/config.py b/app/config.py index b7cd05e..aaac296 100644 --- a/app/config.py +++ b/app/config.py @@ -49,6 +49,18 @@ class DatabaseSettings(BaseSettings): url: str = "sqlite+aiosqlite:///./tgit.db" +class SourceCraftSettings(BaseSettings): + model_config = SettingsConfigDict(**_ENV, env_prefix="SOURCECRAFT_") + + token: SecretStr = SecretStr("") + api_url: str = "https://api.sourcecraft.tech" + uploads_repo: str = "rogachevegorushka/tgit-uploads" + # Web link to a CI run. The SPA answers 200 on any path, so the format is unverified — adjust if it 404s in UI. + run_url_template: str = "https://sourcecraft.dev/{repo}/cicd/runs/{run}" + timeout: float = Field(default=30.0, gt=0) + max_attempts: int = Field(default=5, ge=1) + + class RuntimeSettings(BaseSettings): model_config = _ENV @@ -68,4 +80,5 @@ class Settings(BaseSettings): access: AccessSettings = Field(default_factory=AccessSettings) database: DatabaseSettings = Field(default_factory=DatabaseSettings) llm: LLMSettings = Field(default_factory=LLMSettings) + sourcecraft: SourceCraftSettings = Field(default_factory=SourceCraftSettings) runtime: RuntimeSettings = Field(default_factory=RuntimeSettings) diff --git a/app/sourcecraft/__init__.py b/app/sourcecraft/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sourcecraft/cli.py b/app/sourcecraft/cli.py new file mode 100644 index 0000000..500a19e --- /dev/null +++ b/app/sourcecraft/cli.py @@ -0,0 +1,28 @@ +"""Debug CLI: `python -m app.sourcecraft.cli runs [repo]` — recent CI runs with their commit and failed cubes.""" + +import asyncio +import sys + +from app.config import SourceCraftSettings +from app.sourcecraft.client import SourceCraftClient + + +async def runs(repo: str | None) -> None: + settings = SourceCraftSettings() + repo = repo or settings.uploads_repo + async with SourceCraftClient(settings) as client: + for run in await client.list_runs(repo, page_size=10): + full = await client.get_run(repo, run.slug) + sha = await client.run_commit_sha(repo, full) + failed = ", ".join(f"{t}/{c.slug}" for _, t, c in full.failed_cubes()) or "-" + print(f"{run.slug} {run.event_type:<8} {run.status:<10} sha={sha or '?':<40.12} failed={failed}") + + +def main() -> None: + if len(sys.argv) < 2 or sys.argv[1] != "runs": + sys.exit("usage: python -m app.sourcecraft.cli runs [org/repo]") + asyncio.run(runs(sys.argv[2] if len(sys.argv) > 2 else None)) + + +if __name__ == "__main__": + main() diff --git a/app/sourcecraft/client.py b/app/sourcecraft/client.py new file mode 100644 index 0000000..72258cc --- /dev/null +++ b/app/sourcecraft/client.py @@ -0,0 +1,140 @@ +"""SourceCraft REST API client (ARCHITECTURE §2.4). The API drops TLS connections now and then: every call retries. + +Runs don't carry a commit sha, so the uploads CI prints it from its first cube (`check/meta`, ADR-014); +`run_commit_sha` reads it back. +""" + +import asyncio +import logging +import re +from collections.abc import Awaitable, Callable +from types import TracebackType +from typing import Any, Self + +import httpx + +from app.config import SourceCraftSettings +from app.sourcecraft.models import CubeLog, Run + +log = logging.getLogger(__name__) + +META_TASK = "check" +META_CUBE = "meta" +_SHA_LINE = re.compile(r"^TGIT_COMMIT_SHA=([0-9a-f]{40})\s*$", re.MULTILINE) + +Sleep = Callable[[float], Awaitable[None]] + + +class SourceCraftError(Exception): + def __init__(self, status: int, code: str, message: str) -> None: + super().__init__(f"{status} {code}: {message}") + self.status = status + self.code = code + self.message = message + + +class SourceCraftClient: + def __init__( + self, + settings: SourceCraftSettings, + transport: httpx.AsyncBaseTransport | None = None, + sleep: Sleep = asyncio.sleep, + ) -> None: + self._settings = settings + self._sleep = sleep + self._http = httpx.AsyncClient( + base_url=settings.api_url, + headers={"Authorization": f"Bearer {settings.token.get_secret_value()}"}, + timeout=settings.timeout, + transport=transport, + ) + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None + ) -> None: + await self.aclose() + + async def aclose(self) -> None: + await self._http.aclose() + + def run_url(self, repo: str, run: str) -> str: + return self._settings.run_url_template.format(repo=repo, run=run) + + async def list_runs(self, repo: str, page_size: int = 20) -> list[Run]: + """Newest first. One page is enough for the watcher: it only looks for runs of recent pushes.""" + data = await self._get(f"/repos/{repo}/cicd/runs", params={"page_size": page_size}) + return [Run.model_validate(r) for r in data.get("runs", [])] + + async def get_run(self, repo: str, run: str) -> Run: + return Run.model_validate(await self._get(f"/repos/{repo}/cicd/runs/{run}")) + + async def get_cube_logs(self, repo: str, run: str, workflow: str, task: str, cube: str) -> CubeLog: + """Concatenate log pages in order; a page that isn't complete yet is the last readable one.""" + parts: list[str] = [] + done = False + page = 1 + while True: + try: + data = await self._get(f"/repos/{repo}/cicd/logs/{run}/{workflow}/{task}/{cube}", params={"page": page}) + except SourceCraftError as e: + if e.status == 404 and page > 1: + break + raise + parts.append(data.get("logs", "")) + done = bool(data.get("done", False)) + if not data.get("page_complete", False): + break + page += 1 + return CubeLog(text="".join(parts), done=done) + + async def run_commit_sha(self, repo: str, run: Run) -> str | None: + """The commit a run was started for, read from the meta cube's log (None if not printed yet).""" + for workflow in run.workflows: + if run.task(workflow.slug, META_TASK) is None: + continue + try: + logs = await self.get_cube_logs(repo, run.slug, workflow.slug, META_TASK, META_CUBE) + except SourceCraftError as e: + if e.status == 404: + return None + raise + match = _SHA_LINE.search(logs.text) + return match.group(1) if match else None + return None + + async def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + attempts = self._settings.max_attempts + for attempt in range(1, attempts + 1): + try: + response = await self._http.get(path, params=params) + except httpx.TransportError as e: + if attempt == attempts: + raise + log.warning("sourcecraft %s: %s, retry %d/%d", path, type(e).__name__, attempt, attempts - 1) + else: + if response.status_code < 500: + return self._parse(response) + if attempt == attempts: + return self._parse(response) + log.warning("sourcecraft %s: HTTP %d, retry %d/%d", path, response.status_code, attempt, attempts - 1) + await self._sleep(min(0.5 * 2 ** (attempt - 1), 8.0)) + raise AssertionError("unreachable") + + @staticmethod + def _parse(response: httpx.Response) -> dict[str, Any]: + try: + body = response.json() + except ValueError: + body = {} + if response.status_code >= 400: + raise SourceCraftError( + response.status_code, + str(body.get("error_code", "")), + str(body.get("message", response.text[:200])), + ) + if not isinstance(body, dict): + raise SourceCraftError(response.status_code, "BadResponse", "expected a JSON object") + return body diff --git a/app/sourcecraft/models.py b/app/sourcecraft/models.py new file mode 100644 index 0000000..2b37d26 --- /dev/null +++ b/app/sourcecraft/models.py @@ -0,0 +1,113 @@ +"""Typed SourceCraft CI/CD API responses (only the fields the bot uses).""" + +from collections.abc import Iterator +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class RunStatus(StrEnum): + CREATED = "created" + PREPARED = "prepared" + PROCESSING = "processing" + SUCCESS = "success" + FAILED = "failed" + CANCELED = "canceled" + TIMEOUT = "timeout" + SKIPPED = "skipped" + AWAITING_APPROVAL = "awaiting_approval" + REJECTED = "rejected" + + @property + def finished(self) -> bool: + return self in _FINISHED + + +_FINISHED = { + RunStatus.SUCCESS, + RunStatus.FAILED, + RunStatus.CANCELED, + RunStatus.TIMEOUT, + RunStatus.SKIPPED, + RunStatus.REJECTED, +} + + +class EventType(StrEnum): + PUSH = "push" + PR_UPDATE = "pr_update" + MANUAL = "manual" + RESTART = "restart" + SCHEDULE = "schedule" + REPOSITORY_EVENT = "repository_event" + + +class _Model(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + +class Dates(_Model): + created_at: datetime | None = None + started_at: datetime | None = None + finished_at: datetime | None = None + + @field_validator("created_at", "started_at", "finished_at", mode="after") + @classmethod + def _epoch_is_unset(cls, value: datetime | None) -> datetime | None: + # The API reports "not yet" as 1970-01-01T00:00:00Z + return None if value is not None and value.year == 1970 else value + + +class Cube(_Model): + slug: str + status: RunStatus + dates: Dates = Field(default_factory=Dates) + + @property + def is_service(self) -> bool: + """Platform steps (#git-clone, #prepare-image-for-…): not in our config, logs not readable.""" + return self.slug.startswith("#") + + +class Task(_Model): + slug: str + status: RunStatus + cubes: list[Cube] = [] + + +class Workflow(_Model): + slug: str + status: RunStatus + tasks: list[Task] = [] + + +class Run(_Model): + slug: str + status: RunStatus + event_type: EventType + dates: Dates = Field(default_factory=Dates) + workflows: list[Workflow] = [] + error_messages: list[str] = [] + + def task(self, workflow: str, task: str) -> Task | None: + for wf in self.workflows: + if wf.slug == workflow: + return next((t for t in wf.tasks if t.slug == task), None) + return None + + def cubes(self) -> Iterator[tuple[str, str, Cube]]: + """(workflow, task, cube) for every user-defined cube.""" + for wf in self.workflows: + for t in wf.tasks: + for c in t.cubes: + if not c.is_service: + yield wf.slug, t.slug, c + + def failed_cubes(self) -> list[tuple[str, str, Cube]]: + return [entry for entry in self.cubes() if entry[2].status is RunStatus.FAILED] + + +class CubeLog(_Model): + text: str + done: bool diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cbcc2e6..46aaca6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -70,11 +70,11 @@ aiogram 3. Главное меню — reply-клавиатура (📝 Комм ### 2.4 `app/sourcecraft` — клиент API `httpx.AsyncClient`, Bearer-токен, ретраи на сетевые ошибки и 5xx (экспоненциально, до 5 попыток), -pydantic-модели ответов. Методы: `list_runs`, `get_run`, `get_cube_logs`. Подробности — [`dev/SOURCECRAFT_API.md`](dev/SOURCECRAFT_API.md). +pydantic-модели ответов. Методы: `list_runs`, `get_run`, `get_cube_logs` (постранично), `run_commit_sha` — sha из лога куба `check/meta` (у прогона в API нет коммита, ADR-014). Подробности — [`dev/SOURCECRAFT_API.md`](dev/SOURCECRAFT_API.md). ### 2.5 `app/pipeline` — наблюдатель CI/CD Фоновая задача. Пока есть незавершённые пуши — опрашивает `/cicd/runs` (10 с, бэкофф до 60 с), сопоставляет прогон с -пушем по sha. Отслеживает workflow `check` (CI) и `deploy` (CD) отдельно; на смене статуса — редактирует сообщение +пушем по sha (из куба `meta`, ADR-014). Отслеживает задачи `check` (CI) и `deploy` (CD) workflow `check-and-deploy` отдельно; на смене статуса — редактирует сообщение о пуше и шлёт автору уведомление (успех / падение + лог упавшего куба). Падение `deploy` → событие для `alerts`. Таймаут — 30 мин. Webhooks у SourceCraft нет (ADR-003). diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 7242a2b..4e0b4f0 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -121,3 +121,11 @@ **Решение:** конвейер из пяти шагов (SPEC §4.3.1): детерминированный фильтр → отдельный LLM-вызов «страж» с JSON-вердиктом `safe|attack` (невалидный ответ = `attack`) → отдельный LLM-вызов «планировщик», который получает только промт и список контейнеров и возвращает `LogQuery` → валидация → выполнение в Python. Пользователю показывается итоговый grep и найденные строки. Атака → отказ + алерт админам. **Альтернативы:** один вызов LLM с инструкцией «откажись, если атака» — страж и исполнитель в одном контексте, injection обходит оба; передавать логи в LLM для поиска — injection из логов и утечка секретов. **Последствия:** два LLM-вызова на запрос (латентность, токены → квота 30/сутки); даже при обходе стража вред ограничен схемой `LogQuery`. + +## ADR-014: Связь «прогон ↔ коммит» — через куб `meta`, печатающий sha +**Дата:** 2026-10-14 · **Статус:** принято (уточняет ADR-003) + +**Контекст:** бот должен понять, какой прогон CI относится к его пушу. В API SourceCraft у прогона нет sha коммита (ни в списке, ни в деталях), фильтра по коммиту нет; логи служебных кубов (`#git-clone`) API не отдаёт. +**Решение:** первый куб задачи `check` в `tgit-uploads` — `meta`: `echo "TGIT_COMMIT_SHA=$SOURCECRAFT_COMMIT_SHA"`. Бот читает его лог (`SourceCraftClient.run_commit_sha`) и сопоставляет прогон с пушем. +**Альтернативы:** запускать CI из бота вручную (`POST /cicd/runs` с `head.commit`) — номер прогона известен сразу, но push-триггер пришлось бы убрать, и ручные пуши в репозиторий остались бы без CI; сопоставление по времени — ломается при параллельных пушах. +**Последствия:** +1 короткий куб на прогон; куб `meta` нельзя удалять или переименовывать (он в `.sourcecraft/`, который бот защищает от правок); прогоны старше этого решения sha не имеют. diff --git a/docs/dev/SOURCECRAFT_API.md b/docs/dev/SOURCECRAFT_API.md index a78de55..16d1a79 100644 --- a/docs/dev/SOURCECRAFT_API.md +++ b/docs/dev/SOURCECRAFT_API.md @@ -79,6 +79,12 @@ curl -s -X PUT -H "$H" -H "$J" "$R/secrets/DEPLOY_HOST" -d "{\"value\":\"$(print - Прогон: `slug` — числовой (`1000000`…), `status`: `created` → `processing` → `success`/`failed`; `event_type`: `push`/`manual`/…. У кубов служебные шаги со `slug`, начинающимся на `#` (`#git-clone`, `#prepare-image-for-`). - Если куб упал, остальные кубы задачи остаются в `created` и не запускаются. +- **В прогоне нет sha коммита** (ни в списке, ни в деталях), фильтра по коммиту нет → в `tgit-uploads` куб `check/meta` печатает `TGIT_COMMIT_SHA=…`, бот читает его лог (ADR-014). +- Логи служебных кубов (`#git-clone` и т.п.) API не отдаёт: `400 CITaskName.Pattern`. +- Логи постранично: `?page=N` с 1; читать по порядку, пока `page_complete: true`; следующая страница незаконченной — 404. +- Статусы прогона: `created`, `prepared`, `processing`, `success`, `failed`, `canceled`, `timeout`, `skipped`, `awaiting_approval`, `rejected`. Типы события: `push`, `pr_update`, `manual`, `restart`, `schedule`, `repository_event`. +- Очередь бесплатных раннеров бывает минутами: прогон может стоять в `prepared`/`created` до ~6 мин до старта. +- Веб-интерфейс — SPA, на любой путь отвечает 200: формат ссылки на прогон снаружи не проверить (`SOURCECRAFT_RUN_URL_TEMPLATE`). - Код в кубе склонирован в `/sourcecraft/workspace` (`$SOURCECRAFT_WORKSPACE`) — это рабочий каталог. Переменные: `SOURCECRAFT_COMMIT_SHA`, `SOURCECRAFT_COMMIT_REF_NAME` и др. - Исходящих webhooks нет — статус CI получаем опросом (ADR-003). diff --git a/infra/uploads-template/.sourcecraft/ci.yaml b/infra/uploads-template/.sourcecraft/ci.yaml index 00a08a3..6456ab1 100644 --- a/infra/uploads-template/.sourcecraft/ci.yaml +++ b/infra/uploads-template/.sourcecraft/ci.yaml @@ -13,7 +13,13 @@ workflows: tasks: - name: check cubes: + # The API doesn't tell which commit a run is for — the bot reads it from this log (ADR-014). Keep it first. + - name: meta + image: ghcr.io/astral-sh/uv:0.8-python3.12-bookworm-slim + script: + - echo "TGIT_COMMIT_SHA=$SOURCECRAFT_COMMIT_SHA" - name: compile + needs: ["-"] image: ghcr.io/astral-sh/uv:0.8-python3.12-bookworm-slim script: - python -m compileall -q -x '(^|/)\.' . diff --git a/pyproject.toml b/pyproject.toml index c271ab6..dee183b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,4 +63,4 @@ omit = ["app/__main__.py"] show_missing = true skip_covered = true # Ratchet: raise as code lands, never lower. Target by M4 — 70 (SPEC §5.2). -fail_under = 45 +fail_under = 80 diff --git a/tests/fixtures/sourcecraft/log_lint_failed.json b/tests/fixtures/sourcecraft/log_lint_failed.json new file mode 100644 index 0000000..37ac668 --- /dev/null +++ b/tests/fixtures/sourcecraft/log_lint_failed.json @@ -0,0 +1,5 @@ +{ + "logs": "Downloading ruff (12.8MiB)\n Downloading ruff\nInstalled 1 package in 1ms\nF401 [*] `os` imported but unused\n --> services/broken/main.py:1:8\n |\n1 | import os\n | ^^\n2 |\n3 | def run():\n |\nhelp: Remove unused import: `os`\n\nF821 Undefined name `undefined_name`\n --> services/broken/main.py:4:12\n |\n3 | def run():\n4 | return undefined_name\n | ^^^^^^^^^^^^^^\n |\n\nFound 2 errors.\n[*] 1 fixable with the `--fix` option.\n", + "page_complete": false, + "done": true +} diff --git a/tests/fixtures/sourcecraft/log_meta.json b/tests/fixtures/sourcecraft/log_meta.json new file mode 100644 index 0000000..bf85133 --- /dev/null +++ b/tests/fixtures/sourcecraft/log_meta.json @@ -0,0 +1,5 @@ +{ + "logs": "TGIT_COMMIT_SHA=932f4b1a144cfed4fed8eafd94433e0995e5b12d\n", + "page_complete": false, + "done": true +} diff --git a/tests/fixtures/sourcecraft/run_failed.json b/tests/fixtures/sourcecraft/run_failed.json new file mode 100644 index 0000000..53a6660 --- /dev/null +++ b/tests/fixtures/sourcecraft/run_failed.json @@ -0,0 +1,396 @@ +{ + "id": "", + "slug": "1000001", + "dates": { + "created_at": "2026-10-13T20:58:52.273658Z", + "started_at": "2026-10-13T20:58:53.940173Z", + "finished_at": "2026-10-13T20:59:17.833911Z", + "updated_at": "2026-10-13T20:59:17.867686Z" + }, + "status": "failed", + "workflows": [ + { + "id": "", + "slug": "check-and-deploy", + "description": "", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "2026-10-13T20:58:53.940173Z", + "finished_at": "2026-10-13T20:59:17.833911Z", + "updated_at": "2026-10-13T20:59:17.862572Z" + }, + "status": "failed", + "tasks": [ + { + "id": "", + "slug": "check", + "description": "", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "2026-10-13T20:58:53.940173Z", + "finished_at": "2026-10-13T20:59:11.150164Z", + "updated_at": "2026-10-13T20:59:12.846332Z" + }, + "status": "failed", + "cubes": [ + { + "id": "", + "slug": "#create-environment", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "2026-10-13T20:58:54.031424Z", + "finished_at": "2026-10-13T20:58:54.090132Z", + "updated_at": "2026-10-13T20:58:54.155008Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [] + } + }, + { + "id": "", + "slug": "#git-clone", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "2026-10-13T20:58:54.090481Z", + "finished_at": "2026-10-13T20:58:55.142644Z", + "updated_at": "2026-10-13T20:58:55.211046Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#create-environment" + } + ] + } + }, + { + "id": "", + "slug": "#prepare-image-for-compile", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "2026-10-13T20:58:55.143033Z", + "finished_at": "2026-10-13T20:58:59.605194Z", + "updated_at": "2026-10-13T20:58:59.706155Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#git-clone" + } + ] + } + }, + { + "id": "", + "slug": "#prepare-image-for-lint", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "2026-10-13T20:58:59.605538Z", + "finished_at": "2026-10-13T20:59:00.536788Z", + "updated_at": "2026-10-13T20:59:00.604498Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#git-clone" + } + ] + } + }, + { + "id": "", + "slug": "#prepare-image-for-security", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "2026-10-13T20:59:00.537171Z", + "finished_at": "2026-10-13T20:59:01.447150Z", + "updated_at": "2026-10-13T20:59:01.511994Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#git-clone" + } + ] + } + }, + { + "id": "", + "slug": "#prepare-image-for-tests", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "2026-10-13T20:59:01.447481Z", + "finished_at": "2026-10-13T20:59:02.624921Z", + "updated_at": "2026-10-13T20:59:02.700313Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#git-clone" + } + ] + } + }, + { + "id": "", + "slug": "compile", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "2026-10-13T20:59:02.625267Z", + "finished_at": "2026-10-13T20:59:09.366621Z", + "updated_at": "2026-10-13T20:59:09.454706Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#prepare-image-for-compile" + } + ] + } + }, + { + "id": "", + "slug": "lint", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "2026-10-13T20:59:09.366953Z", + "finished_at": "2026-10-13T20:59:11.088201Z", + "updated_at": "2026-10-13T20:59:11.173518Z" + }, + "status": "failed", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#prepare-image-for-lint" + } + ] + } + }, + { + "id": "", + "slug": "security", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "2026-10-13T20:59:11.088571Z", + "updated_at": "2026-10-13T20:59:11.206851Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#prepare-image-for-security" + } + ] + } + }, + { + "id": "", + "slug": "tests", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "2026-10-13T20:59:11.088804Z", + "updated_at": "2026-10-13T20:59:11.242177Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#prepare-image-for-tests" + } + ] + } + }, + { + "id": "", + "slug": "#tear-down-environment", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "2026-10-13T20:59:11.089254Z", + "finished_at": "2026-10-13T20:59:11.150164Z", + "updated_at": "2026-10-13T20:59:11.346169Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "compile" + }, + { + "name": "lint" + }, + { + "name": "security" + }, + { + "name": "tests" + } + ] + } + } + ], + "relations": { + "needs": [] + } + }, + { + "id": "", + "slug": "deploy", + "description": "", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "2026-10-13T20:59:17.833911Z", + "updated_at": "2026-10-13T20:59:17.840273Z" + }, + "status": "created", + "cubes": [ + { + "id": "", + "slug": "#create-environment", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-13T20:58:52.727666Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [] + } + }, + { + "id": "", + "slug": "#git-clone", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-13T20:58:52.727666Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#create-environment" + } + ] + } + }, + { + "id": "", + "slug": "#prepare-artifacts", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-13T20:58:52.727666Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#git-clone" + } + ] + } + }, + { + "id": "", + "slug": "#prepare-image-for-deploy", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-13T20:58:52.727666Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#prepare-artifacts" + } + ] + } + }, + { + "id": "", + "slug": "deploy", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-13T20:58:52.727666Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#prepare-image-for-deploy" + } + ] + } + }, + { + "id": "", + "slug": "#tear-down-environment", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-13T20:58:52.727666Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "deploy" + } + ] + } + } + ], + "relations": { + "needs": [ + { + "name": "check" + } + ] + } + } + ], + "environment_id": "" + } + ], + "event_type": "manual", + "error_messages": [], + "user": { + "id": "01a06158-1562-7db1-b6e9-018ddd4d4c29", + "slug": "rogachevegorushka" + } +} diff --git a/tests/fixtures/sourcecraft/run_processing.json b/tests/fixtures/sourcecraft/run_processing.json new file mode 100644 index 0000000..9dffb7b --- /dev/null +++ b/tests/fixtures/sourcecraft/run_processing.json @@ -0,0 +1,437 @@ +{ + "id": "", + "slug": "1000002", + "dates": { + "created_at": "2026-10-14T14:51:56.652199Z", + "started_at": "2026-10-14T14:57:43.223468Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-14T14:57:43.242955Z" + }, + "status": "processing", + "workflows": [ + { + "id": "", + "slug": "check-and-deploy", + "description": "", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:57:43.223468Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-14T14:57:43.234813Z" + }, + "status": "processing", + "tasks": [ + { + "id": "", + "slug": "check", + "description": "", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:57:43.223468Z", + "finished_at": "2026-10-14T14:58:19.375134Z", + "updated_at": "2026-10-14T14:58:19.571691Z" + }, + "status": "success", + "cubes": [ + { + "id": "", + "slug": "#create-environment", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:57:43.514526Z", + "finished_at": "2026-10-14T14:57:43.584723Z", + "updated_at": "2026-10-14T14:57:43.657871Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [] + } + }, + { + "id": "", + "slug": "#git-clone", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:57:43.585127Z", + "finished_at": "2026-10-14T14:57:45.528681Z", + "updated_at": "2026-10-14T14:57:45.606302Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#create-environment" + } + ] + } + }, + { + "id": "", + "slug": "#prepare-image-for-compile", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:57:45.529064Z", + "finished_at": "2026-10-14T14:57:55.435126Z", + "updated_at": "2026-10-14T14:57:55.537030Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#git-clone" + } + ] + } + }, + { + "id": "", + "slug": "#prepare-image-for-lint", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:57:55.435513Z", + "finished_at": "2026-10-14T14:57:58.411834Z", + "updated_at": "2026-10-14T14:57:58.479777Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#git-clone" + } + ] + } + }, + { + "id": "", + "slug": "#prepare-image-for-meta", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:57:58.412252Z", + "finished_at": "2026-10-14T14:58:03.323469Z", + "updated_at": "2026-10-14T14:58:03.420479Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#git-clone" + } + ] + } + }, + { + "id": "", + "slug": "#prepare-image-for-security", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:58:03.323798Z", + "finished_at": "2026-10-14T14:58:04.898405Z", + "updated_at": "2026-10-14T14:58:04.986588Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#git-clone" + } + ] + } + }, + { + "id": "", + "slug": "#prepare-image-for-tests", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:58:04.898847Z", + "finished_at": "2026-10-14T14:58:09.732911Z", + "updated_at": "2026-10-14T14:58:09.807301Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#git-clone" + } + ] + } + }, + { + "id": "", + "slug": "compile", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:58:09.733227Z", + "finished_at": "2026-10-14T14:58:12.400619Z", + "updated_at": "2026-10-14T14:58:12.533056Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#prepare-image-for-compile" + } + ] + } + }, + { + "id": "", + "slug": "lint", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:58:12.401008Z", + "finished_at": "2026-10-14T14:58:14.235114Z", + "updated_at": "2026-10-14T14:58:14.304352Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#prepare-image-for-lint" + } + ] + } + }, + { + "id": "", + "slug": "meta", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:58:14.235485Z", + "finished_at": "2026-10-14T14:58:14.707956Z", + "updated_at": "2026-10-14T14:58:14.778120Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#prepare-image-for-meta" + } + ] + } + }, + { + "id": "", + "slug": "security", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:58:14.708362Z", + "finished_at": "2026-10-14T14:58:17.148819Z", + "updated_at": "2026-10-14T14:58:17.221280Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#prepare-image-for-security" + } + ] + } + }, + { + "id": "", + "slug": "tests", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:58:17.149213Z", + "finished_at": "2026-10-14T14:58:19.298832Z", + "updated_at": "2026-10-14T14:58:19.405984Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#prepare-image-for-tests" + } + ] + } + }, + { + "id": "", + "slug": "#tear-down-environment", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:58:19.299366Z", + "finished_at": "2026-10-14T14:58:19.375117Z", + "updated_at": "2026-10-14T14:58:19.528162Z" + }, + "status": "success", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "compile" + }, + { + "name": "lint" + }, + { + "name": "meta" + }, + { + "name": "security" + }, + { + "name": "tests" + } + ] + } + } + ], + "relations": { + "needs": [] + } + }, + { + "id": "", + "slug": "deploy", + "description": "", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-14T14:58:21.737007Z" + }, + "status": "created", + "cubes": [ + { + "id": "", + "slug": "#create-environment", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-14T14:51:57.522605Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [] + } + }, + { + "id": "", + "slug": "#git-clone", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-14T14:51:57.522605Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#create-environment" + } + ] + } + }, + { + "id": "", + "slug": "#prepare-artifacts", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-14T14:51:57.522605Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#git-clone" + } + ] + } + }, + { + "id": "", + "slug": "#prepare-image-for-deploy", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-14T14:51:57.522605Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#prepare-artifacts" + } + ] + } + }, + { + "id": "", + "slug": "deploy", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-14T14:51:57.522605Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "#prepare-image-for-deploy" + } + ] + } + }, + { + "id": "", + "slug": "#tear-down-environment", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-14T14:51:57.522605Z" + }, + "status": "created", + "artifacts": [], + "relations": { + "needs": [ + { + "name": "deploy" + } + ] + } + } + ], + "relations": { + "needs": [ + { + "name": "check" + } + ] + } + } + ], + "environment_id": "" + } + ], + "event_type": "push", + "error_messages": [], + "user": { + "id": "01a06158-1562-7db1-b6e9-018ddd4d4c29", + "slug": "rogachevegorushka" + } +} diff --git a/tests/fixtures/sourcecraft/runs.json b/tests/fixtures/sourcecraft/runs.json new file mode 100644 index 0000000..6f83bfe --- /dev/null +++ b/tests/fixtures/sourcecraft/runs.json @@ -0,0 +1,104 @@ +{ + "runs": [ + { + "id": "", + "slug": "1000002", + "dates": { + "created_at": "2026-10-14T14:51:56.652199Z", + "started_at": "2026-10-14T14:57:43.223468Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-14T14:57:43.242955Z" + }, + "status": "processing", + "workflows": [ + { + "id": "", + "slug": "check-and-deploy", + "description": "", + "dates": { + "created_at": "2026-10-14T14:51:57.521264Z", + "started_at": "2026-10-14T14:57:43.223468Z", + "finished_at": "1970-01-01T00:00:00Z", + "updated_at": "2026-10-14T14:57:43.234813Z" + }, + "status": "processing", + "tasks": [], + "environment_id": "" + } + ], + "event_type": "push", + "error_messages": [], + "user": { + "id": "01a06158-1562-7db1-b6e9-018ddd4d4c29", + "slug": "rogachevegorushka" + } + }, + { + "id": "", + "slug": "1000001", + "dates": { + "created_at": "2026-10-13T20:58:52.273658Z", + "started_at": "2026-10-13T20:58:53.940173Z", + "finished_at": "2026-10-13T20:59:17.833911Z", + "updated_at": "2026-10-13T20:59:17.867686Z" + }, + "status": "failed", + "workflows": [ + { + "id": "", + "slug": "check-and-deploy", + "description": "", + "dates": { + "created_at": "2026-10-13T20:58:52.726601Z", + "started_at": "2026-10-13T20:58:53.940173Z", + "finished_at": "2026-10-13T20:59:17.833911Z", + "updated_at": "2026-10-13T20:59:17.862572Z" + }, + "status": "failed", + "tasks": [], + "environment_id": "" + } + ], + "event_type": "manual", + "error_messages": [], + "user": { + "id": "01a06158-1562-7db1-b6e9-018ddd4d4c29", + "slug": "rogachevegorushka" + } + }, + { + "id": "", + "slug": "1000000", + "dates": { + "created_at": "2026-10-13T20:57:06.596315Z", + "started_at": "2026-10-13T20:57:06.821003Z", + "finished_at": "2026-10-13T20:58:26.698635Z", + "updated_at": "2026-10-13T20:58:27.884131Z" + }, + "status": "success", + "workflows": [ + { + "id": "", + "slug": "check-and-deploy", + "description": "", + "dates": { + "created_at": "2026-10-13T20:57:06.725971Z", + "started_at": "2026-10-13T20:57:06.821003Z", + "finished_at": "2026-10-13T20:58:26.698635Z", + "updated_at": "2026-10-13T20:58:27.878032Z" + }, + "status": "success", + "tasks": [], + "environment_id": "" + } + ], + "event_type": "push", + "error_messages": [], + "user": { + "id": "01a06158-1562-7db1-b6e9-018ddd4d4c29", + "slug": "rogachevegorushka" + } + } + ], + "next_page_token": "147400973399132381" +} diff --git a/tests/sourcecraft/__init__.py b/tests/sourcecraft/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sourcecraft/test_client.py b/tests/sourcecraft/test_client.py new file mode 100644 index 0000000..505d705 --- /dev/null +++ b/tests/sourcecraft/test_client.py @@ -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"