feat(pipeline): уведомления CI/CD — PipelineWatcher, CI загрузок на Gitea Actions
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>
This commit is contained in:
2026-10-19 00:24:08 +03:00
co-authored by Claude Opus 5.5
parent eda5694270
commit 95a72437ec
29 changed files with 1293 additions and 117 deletions
+36 -5
View File
@@ -3,6 +3,7 @@
import asyncio
import contextlib
import logging
from datetime import timedelta
from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
@@ -26,6 +27,8 @@ from app.health import heartbeat
from app.llm.factory import make_provider
from app.logs.journal import LogJournal
from app.logs_setup import setup_logging
from app.pipeline.factory import make_ci
from app.pipeline.watcher import PipelineWatcher
from app.repo.drafts import DraftService
from app.repo.git import UploadsRepo
from app.repo.publisher import Publisher
@@ -50,6 +53,7 @@ def build_dispatcher(
agent: Agent | None = None,
alerts: AlertService | None = None,
runner: RunnerClient | None = None,
pipeline: PipelineWatcher | None = None,
) -> Dispatcher:
dp = Dispatcher(storage=make_storage(sessions))
gate = AccessMiddleware(access, texts)
@@ -57,10 +61,9 @@ def build_dispatcher(
dp.message.outer_middleware(gate)
dp.callback_query.outer_middleware(gate)
dp.include_router(members.build_router(texts, access))
deployer = Deployer(repo, runner, alerts) if runner else None
dp.include_router(
commit.build_router(
texts, repo, DraftService(sessions), Publisher(repo, sessions), commit_url_template, deployer
texts, repo, DraftService(sessions), Publisher(repo, sessions), commit_url_template, pipeline
)
)
dp.include_router(status.build_router(texts, docker, checker, runner))
@@ -95,35 +98,59 @@ async def run(settings: Settings) -> None:
alerts_cfg,
log_source=(lambda cid, n: docker.logs(cid, tail=n)) if docker else None,
)
repo = UploadsRepo(settings.git)
runner = RunnerClient(settings.runner) if settings.runner.host else None
ci = make_ci(settings.ci)
deployer = Deployer(repo, runner, alerts) if runner else None
# Without CI and runner there is nothing to watch: the push message just says what was pushed
pipeline = (
PipelineWatcher(
sessions,
bot,
texts,
ci,
deployer,
commit_url_template=settings.git.commit_url_template,
poll_interval=settings.ci.poll_interval,
max_poll_interval=settings.ci.max_poll_interval,
run_timeout=timedelta(minutes=settings.ci.run_timeout_minutes),
)
if ci or deployer
else None
)
dp = build_dispatcher(
texts,
sessions,
access,
UploadsRepo(settings.git),
repo,
settings.git.commit_url_template,
docker,
checker,
agent,
alerts,
RunnerClient(settings.runner) if settings.runner.host else None,
runner,
pipeline,
)
rt = settings.runtime
background = [
asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)),
asyncio.create_task(cleanup_loop(sessions)),
]
if pipeline:
background.append(asyncio.create_task(pipeline.run()))
if docker:
monitor = AlertMonitor(alerts, store, alerts_cfg)
background.append(
asyncio.create_task(snapshot_loop(docker, store, settings.docker.snapshot_interval, monitor.on_round))
)
log.info(
"tgit %s starting, %d bootstrap admin(s), telegram %s, docker %s, agent %s, runner %s",
"tgit %s starting, %d bootstrap admin(s), telegram %s, docker %s, agent %s, ci %s, runner %s",
__version__,
len(settings.access.admin_ids),
"via proxy" if proxy else "direct",
"on" if docker else "off",
settings.llm.provider if agent else "off",
settings.ci.provider if ci else "off",
settings.runner.host or "off",
)
try:
@@ -134,6 +161,10 @@ async def run(settings: Settings) -> None:
for task in background:
with contextlib.suppress(asyncio.CancelledError):
await task
if pipeline:
await pipeline.aclose()
if ci:
await ci.aclose()
await bot.session.close()
if docker:
await docker.aclose()