CI / ruff · mypy · pytest (pull_request) Failing after 3m40s
- 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>
119 lines
4.7 KiB
Python
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
|