Files
ElebobaandClaude Opus 5.5 95a72437ec
CI / ruff · mypy · pytest (pull_request) Failing after 3m40s
feat(pipeline): уведомления CI/CD — PipelineWatcher, CI загрузок на Gitea Actions
- app/pipeline: протокол CIProvider, клиент Gitea Actions (прогон по head_sha, упавший
  шаг, лог джоба, ретраи), PipelineWatcher — опрос только при незавершённых push,
  сообщение о push обновляется CI ⏳ → ✅/❌ → CD 🚀/⛔, автору — отдельные уведомления
- деплой сервисов — только после зелёного CI; старый push не откатывает сервис,
  который развернёт более новый; после рестарта бот продолжает с того же места
- CI_PROVIDER=none — прежнее поведение (деплой сразу после push)
- шаблон tgit-uploads: .gitea/workflows/check.yml; .gitea/ защищён от правок из бота
- миграция 0002: старые push помечены skipped, чтобы не слать им «таймаут CI»
- ADR-016, ARCHITECTURE §2.5/§5.2, README, DEPLOY, .env.example

Refs #11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:24:08 +03:00

119 lines
4.7 KiB
Python

"""Gitea Actions as the uploads CI (ADR-016). Unlike SourceCraft, a Gitea run carries `head_sha` — no meta cube."""
import asyncio
import logging
import re
from collections.abc import Awaitable, Callable
from typing import Any
import httpx
from app.config import CISettings
from app.db.models import PipelineStatus
from app.pipeline.provider import CIRun
log = logging.getLogger(__name__)
_TIMESTAMP = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z ?", re.MULTILINE)
_FAILED = {"failure", "cancelled"}
Sleep = Callable[[float], Awaitable[None]]
class GiteaError(Exception):
pass
def _status(status: str, conclusion: str | None) -> PipelineStatus:
if status != "completed":
return PipelineStatus.RUNNING if status == "in_progress" else PipelineStatus.PENDING # queued · waiting
if conclusion == "success":
return PipelineStatus.SUCCESS
if conclusion == "skipped":
return PipelineStatus.SKIPPED
return PipelineStatus.FAILED # failure · cancelled
class GiteaCI:
def __init__(
self,
settings: CISettings,
transport: httpx.AsyncBaseTransport | None = None,
sleep: Sleep = asyncio.sleep,
) -> None:
self._s = settings
self._sleep = sleep
self._base = f"/api/v1/repos/{settings.repo}/actions"
self._http = httpx.AsyncClient(
base_url=settings.api_url,
headers={"Authorization": f"token {settings.token.get_secret_value()}"},
timeout=settings.timeout,
transport=transport,
)
async def aclose(self) -> None:
await self._http.aclose()
async def find(self, sha: str) -> CIRun | None:
data = await self._get(f"{self._base}/runs", params={"head_sha": sha, "limit": 20})
# path is "<file>@<ref>"; other workflows of the repo (if any) don't gate the deploy
runs = [r for r in data.get("workflow_runs") or [] if str(r.get("path", "")).split("@")[0] == self._s.workflow]
if not runs:
return None
run = max(runs, key=lambda r: int(r["id"])) # a re-run is a new id
status = _status(str(run.get("status")), run.get("conclusion"))
failed_step = await self._failed_step(run["id"]) if status is PipelineStatus.FAILED else None
return CIRun(str(run["id"]), status, str(run.get("html_url", "")), failed_step)
async def failure_log(self, run: CIRun) -> str:
job = await self._failed_job(run.id)
if job is None:
return ""
text = await self._get_text(f"{self._base}/jobs/{job['id']}/logs")
return _TIMESTAMP.sub("", text)
async def _failed_job(self, run_id: object) -> dict[str, Any] | None:
data = await self._get(f"{self._base}/runs/{run_id}/jobs")
jobs = data.get("jobs") or []
return next((j for j in jobs if j.get("conclusion") in _FAILED), None)
async def _failed_step(self, run_id: object) -> str | None:
job = await self._failed_job(run_id)
if job is None:
return None
step = next((s for s in job.get("steps") or [] if s.get("conclusion") in _FAILED), None)
if job.get("conclusion") == "cancelled" and step is None:
return f"{job.get('name')} / cancelled"
return f"{job.get('name')} / {step['name']}" if step else str(job.get("name"))
async def _request(self, path: str, params: dict[str, Any] | None = None) -> httpx.Response:
attempts = self._s.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 GiteaError(f"{path}: {type(e).__name__}") from e
log.warning("gitea %s: %s, retry %d/%d", path, type(e).__name__, attempt, attempts - 1)
else:
if response.status_code < 500 or attempt == attempts:
if response.status_code >= 400:
raise GiteaError(f"{path}: HTTP {response.status_code}")
return response
log.warning("gitea %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")
async def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
response = await self._request(path, params)
try:
body = response.json()
except ValueError as e:
raise GiteaError(f"{path}: not JSON") from e
if not isinstance(body, dict):
raise GiteaError(f"{path}: expected a JSON object")
return body
async def _get_text(self, path: str) -> str:
return (await self._request(path)).text