release: dev → main #54

Merged
Eleboba merged 62 commits from dev into main 2026-10-22 17:50:43 +03:00
18 changed files with 1433 additions and 4 deletions
Showing only changes of commit 25735fee96 - Show all commits
+2 -1
View File
@@ -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);
+13
View File
@@ -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)
View File
+28
View File
@@ -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()
+140
View File
@@ -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
+113
View File
@@ -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
+2 -2
View File
@@ -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).
+8
View File
@@ -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 не имеют.
+6
View File
@@ -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-<cube>`).
- Если куб упал, остальные кубы задачи остаются в `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).
@@ -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 '(^|/)\.' .
+1 -1
View File
@@ -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
+5
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
{
"logs": "TGIT_COMMIT_SHA=932f4b1a144cfed4fed8eafd94433e0995e5b12d\n",
"page_complete": false,
"done": true
}
+396
View File
@@ -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"
}
}
+437
View File
@@ -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"
}
}
+104
View File
@@ -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"
}
View File
+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"