Compare commits
3
Commits
282920e06b
...
912fc8b9d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
912fc8b9d2 | ||
|
|
4e5930b4c0 | ||
|
|
25735fee96 |
+2
-1
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 не имеют.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"logs": "TGIT_COMMIT_SHA=932f4b1a144cfed4fed8eafd94433e0995e5b12d\n",
|
||||
"page_complete": false,
|
||||
"done": true
|
||||
}
|
||||
+396
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+104
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user