feat(bot): клиент SourceCraft API — прогоны, логи кубов, sha коммита, ретраи
- SourceCraftClient (httpx): list_runs, get_run, get_cube_logs (постранично), run_commit_sha; ретраи на сетевые ошибки и 5xx с экспоненциальной задержкой, 4xx — SourceCraftError без повторов - модели: RunStatus/EventType из swagger, «1970-01-01» = не задано, failed_cubes без служебных #-кубов - у прогона в API нет sha → куб check/meta в tgit-uploads печатает TGIT_COMMIT_SHA (ADR-014) - CLI: python -m app.sourcecraft.cli runs - тесты на реальных ответах API (фикстуры), порог покрытия 45 → 80 Refs #10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user