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>
183 lines
6.8 KiB
Python
183 lines
6.8 KiB
Python
"""Entry point: settings → texts → dispatcher → long polling (ADR-002)."""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
from datetime import timedelta
|
|
|
|
from aiogram import Bot, Dispatcher
|
|
from aiogram.client.session.aiohttp import AiohttpSession
|
|
|
|
from app import __version__
|
|
from app.access.service import AccessService
|
|
from app.agent.service import Agent
|
|
from app.alerts.broadcast import TelegramNotifier
|
|
from app.alerts.detectors import load_alerts_config
|
|
from app.alerts.monitor import AlertMonitor
|
|
from app.alerts.service import AlertService
|
|
from app.bot.fsm import make_storage
|
|
from app.bot.middlewares.access import AccessMiddleware
|
|
from app.bot.routers import alerts as alerts_router
|
|
from app.bot.routers import commit, common, logs, members, status
|
|
from app.config import Settings
|
|
from app.db.maintenance import cleanup_loop
|
|
from app.db.migrate import upgrade
|
|
from app.db.session import SessionMaker, make_engine, make_sessionmaker
|
|
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
|
|
from app.runner.client import RunnerClient
|
|
from app.runner.deployer import Deployer
|
|
from app.runtime.checks import Checker, load_checks
|
|
from app.runtime.docker import DockerClient
|
|
from app.runtime.snapshots import SnapshotStore, snapshot_loop
|
|
from app.texts import Texts, load_texts
|
|
|
|
log = logging.getLogger("tgit")
|
|
|
|
|
|
def build_dispatcher(
|
|
texts: Texts,
|
|
sessions: SessionMaker,
|
|
access: AccessService,
|
|
repo: UploadsRepo,
|
|
commit_url_template: str = "",
|
|
docker: DockerClient | None = None,
|
|
checker: Checker | None = None,
|
|
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)
|
|
# Outer middleware: runs before filters, so nothing — not even a stale button — reaches a handler unchecked.
|
|
dp.message.outer_middleware(gate)
|
|
dp.callback_query.outer_middleware(gate)
|
|
dp.include_router(members.build_router(texts, access))
|
|
dp.include_router(
|
|
commit.build_router(
|
|
texts, repo, DraftService(sessions), Publisher(repo, sessions), commit_url_template, pipeline
|
|
)
|
|
)
|
|
dp.include_router(status.build_router(texts, docker, checker, runner))
|
|
dp.include_router(logs.build_router(texts, docker, LogJournal(sessions), agent, alerts))
|
|
dp.include_router(alerts_router.build_router(texts, alerts, docker))
|
|
dp.include_router(common.build_router(texts, access))
|
|
return dp
|
|
|
|
|
|
async def run(settings: Settings) -> None:
|
|
texts = load_texts(settings.runtime.config_dir)
|
|
checks_cfg = load_checks(settings.runtime.config_dir)
|
|
alerts_cfg = load_alerts_config(settings.runtime.config_dir)
|
|
engine = make_engine(settings.database.url)
|
|
await upgrade(engine)
|
|
sessions = make_sessionmaker(engine)
|
|
access = AccessService(sessions, settings.access)
|
|
await access.bootstrap_admins()
|
|
# No DOCKER_URL (e.g. `make run` without Docker): «Статус» answers «Docker недоступен», the rest works
|
|
docker = DockerClient(settings.docker) if settings.docker.url else None
|
|
store = SnapshotStore(sessions)
|
|
checker = Checker(docker, store, checks_cfg) if docker else None
|
|
# The mock provider answers `{}` to the guard — every question would be an «attack»: no real LLM, no agent
|
|
llm = make_provider(settings.llm) if settings.llm.provider != "mock" else None
|
|
agent = Agent.from_config(llm, sessions, settings.runtime.config_dir) if llm else None
|
|
proxy = settings.telegram.proxy_url
|
|
session = AiohttpSession(proxy=proxy.get_secret_value()) if proxy else None
|
|
bot = Bot(settings.telegram.bot_token.get_secret_value(), session=session)
|
|
alerts = AlertService(
|
|
sessions,
|
|
TelegramNotifier(bot, access, sessions, texts),
|
|
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,
|
|
repo,
|
|
settings.git.commit_url_template,
|
|
docker,
|
|
checker,
|
|
agent,
|
|
alerts,
|
|
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, 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:
|
|
await dp.start_polling(bot, handle_signals=True)
|
|
finally:
|
|
for task in background:
|
|
task.cancel()
|
|
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()
|
|
if checker:
|
|
await checker.aclose()
|
|
if llm:
|
|
await llm.aclose()
|
|
await engine.dispose()
|
|
log.info("tgit stopped")
|
|
|
|
|
|
def main() -> None:
|
|
settings = Settings()
|
|
setup_logging(settings.runtime.log_level)
|
|
asyncio.run(run(settings))
|