Author SHA1 Message Date
Eleboba da01066d5d Merge branch 'dev' into ci/41-gitea-actions
CI / ruff · mypy · pytest (pull_request) Successful in 1m24s
2026-10-17 10:49:52 +03:00
Eleboba 29f9cbe538 Merge pull request 'feat(infra): CD бота на сервер Vobot через Gitea Actions' (#43) from ci/12-cd into dev
CD / deploy → ${{ github.ref_name == 'main' && 'prod' || 'dev' }} (push) Successful in 54s
Reviewed-on: #43
2026-10-17 10:49:44 +03:00
ElebobaandClaude Opus 5.5 164f055c5e docs: деплой (DEPLOY.md), ADR-015 — CI/CD бота на Gitea Actions
Refs #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:23:20 +03:00
ElebobaandClaude Opus 5.5 526eb56c82 feat(bot): Telegram через прокси (TELEGRAM_PROXY_URL); лимит памяти бота 384m
С сервера Vobot api.telegram.org недоступен (ни IPv4, ни IPv6) — как и meditron, ходим
через прокси; aiohttp-socks для http/socks. Бот в простое ~190 МБ — 256m мало.

Refs #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:21:36 +03:00
ElebobaandClaude Opus 5.5 df5224e813 feat(infra): CD бота на сервер Vobot — Gitea Actions + deploy.sh
- infra/deploy/deploy.sh <dev|prod> [ref]: git archive → scp → remote.sh на сервере;
  руками и из CI, хост — DEPLOY_HOST (любой Docker-хост); каждый шаг с ретраями
- remote.sh: атомарная замена app/ (app.old для отката), compose -p tgit-<env> up --build,
  ожидание healthy, REVISION, подсказка отката при неудаче; безопасно перезапускать
- окружение без /srv/tgit/<env>/.env пропускается (prod — когда будет свой токен)
- .gitea/workflows/deploy.yml: push в dev/main → deploy; guard «актуален ли пуш», ключ через env

Refs #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:18:51 +03:00
ElebobaandClaude Opus 5.5 12b2ca22a5 ci: CI бота на Gitea Actions — ruff, mypy, миграции на Postgres, pytest
CI / ruff · mypy · pytest (pull_request) Successful in 1m28s
Как в Vobot: PR и push в dev/main → джоб «ruff · mypy · pytest»; uv через pip (setup-uv на
self-hosted Gitea не работает); миграции up/down/up на сервисе postgres:16 без маппинга портов.

Refs #41

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:13:47 +03:00
Eleboba d1addd2c25 Merge pull request 'feat(bot): кнопка «🚀 Закоммитить» прямо в папке' (#40) from feat/commit-button into dev
Reviewed-on: #40
2026-10-16 00:07:44 +03:00
ElebobaandClaude Opus 5.5 9e1d6322b6 feat(bot): кнопка «🚀 Закоммитить» прямо в папке
При непустом черновике в папке — «🚀 Закоммитить · N»: нет сообщения → спрашивает его и
сразу показывает подтверждение пуша; есть → сразу подтверждение. На подтверждении —
«✏️ Изменить сообщение». Раньше Push был спрятан в «🧾 Черновик» → «💬» → «🚀».

Refs #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:06:52 +03:00
Eleboba 595368b0a2 Merge pull request 'chore: локальный запуск (make run, make demo-repo) + fix относительного GIT_CLONE_DIR' (#39) from chore/local-run into dev
Reviewed-on: #39
2026-10-15 23:56:53 +03:00
ElebobaandClaude Opus 5.5 0f27f405a7 chore: локальный запуск без Docker и SourceCraft; fix: относительный GIT_CLONE_DIR
- make demo-repo: локальный bare-репозиторий загрузок из infra/uploads-template
- make run: бот на этой машине (SQLite, настройки из .env)
- fix: при относительном clone_dir клон уезжал в data/data/uploads (git clone
  запускается из родительской папки) — путь теперь абсолютный; тест на это
- data/ в .gitignore

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:53:40 +03:00
Eleboba b5fbbaf904 Merge pull request 'feat(bot): Коммит — сообщение, Push, конфликты' (#38) from feat/9-push into dev
Reviewed-on: #38
2026-10-15 19:30:23 +03:00
ElebobaandClaude Opus 5.5 ae0cd5d42d feat(bot): Коммит — сообщение, Push, конфликты, публикация в репозиторий загрузок
- Publisher: пуши по одному (lock), fetch → проверка конфликта с base_sha → чистое дерево
  origin/main → черновик → коммит от tgit-bot с трейлером Telegram-User → push;
  отказ push → rebase и повтор (до 3 раз); защита от путей вне клона; лимит 20 пушей/час
- «💬 Сообщение коммита» (1–200 символов, первая строка ≤ 72) и «🚀 Push» с подтверждением;
  исходы: запушено (sha, ссылка, ⏳ CI) · конфликт → «Перезаписать» · нечего коммитить · лимит · сбой
- запись в pushes (chat_id, message_id — для обновления статуса CI/CD в #11), черновик очищается
- ruff: RUF001–003 выключены — кириллица в текстах это норма

Refs #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:23:09 +03:00
ElebobaandClaude Opus 5.5 614f8eafd5 feat(bot): Коммит — создание, замена и удаление файлов, черновик
- новый файл: имя → содержимое текстом (```-обёртка снимается) или документом
- новая папка (виртуальная, пока в ней нет файлов), замена с diff +N/−M, удаление
- валидация: имя, ≤ 100 КБ, UTF-8, без NUL; .py — ast.parse с выбором «исправить / всё равно»;
  .sourcecraft/, .tgit/, ruff.toml из бота не меняются
- DraftService: черновик в БД, 24 ч, ≤ 50 изменений; удаление нового файла убирает его из черновика
- дерево показывает черновик поверх репозитория: 🆕 / ✏️ / 🗑, размер — как будет после пуша
- экран черновика (A/M/D) с «↩️ убрать»

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:18:49 +03:00
ElebobaandClaude Opus 5.5 dbb17bd774 feat(bot): Коммит — навигация по дереву репозитория загрузок
- UploadsRepo: локальный клон, fetch не чаще fetch_interval, ls-tree -z (пробелы/кириллица),
  cat-file, последний коммит по пути; git без shell, ssh с -F /dev/null (без конфига хоста)
- «📝 Коммит»: папки/файлы с размерами, ⬆️ Вверх, страницы по 20, карточка файла
  с последним коммитом, «👁 Показать» (<pre> или документом); в кнопках только индексы
- ключ для контейнера: GIT_SSH_KEY_B64 → временный файл 0600 (смонтированный файл хоста
  контейнерному uid не прочитать); volume /data для клона
- тесты на настоящем bare-репозитории во временной папке

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:10:30 +03:00
ElebobaandClaude Opus 5.5 219bebd5a2 feat(bot): доступ по QR-приглашениям, раздел «Участники», отзыв доступа
- AccessService: одноразовые приглашения (128 бит, в БД только sha256, TTL 24 ч),
  атомарный захват приглашения (условный UPDATE — из двух сканов одного QR проходит один),
  антиперебор (> 5 неудач в час — молчание), отзыв (черновик и FSM удаляются), повторное приглашение
- ADMIN_IDS заводятся в БД при старте; дальше — только по приглашению
- AccessMiddleware: участники из БД (+ last_seen раз в 5 мин), новичкам — только /start <токен>
- «👥 Участники» для админов: список, QR (segno) + ссылка, отзыв с подтверждением
- один ответ на неверный/истёкший/использованный токен — без подсказок

Refs #25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:03:50 +03:00
Eleboba 4e5930b4c0 Merge pull request 'feat(bot): клиент SourceCraft API' (#32) from feat/10-sourcecraft-client into dev
Reviewed-on: #32
2026-10-15 18:52:04 +03:00
ElebobaandClaude Opus 5.5 25735fee96 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>
2026-10-15 18:45:42 +03:00
ElebobaandClaude Opus 5.5 282920e06b feat(bot): модель данных, миграции Alembic, FSM в БД, TTL-чистка
- app/db/models.py: 13 таблиц (ARCHITECTURE §2.10); перечисления — VARCHAR + CHECK,
  JSON → JSONB на Postgres, BIGINT/INTEGER-ключи для SQLite
- миграция 0001 (автогенерация + ручная чистка: импорт sa.Text, дубли CHECK)
- миграции применяются при старте бота программно (app.db.migrate), script_location из пакета
- SqlStorage: FSM aiogram поверх fsm_state — состояние переживает рестарт (перенесено из #4)
- cleanup_loop: истёкшие черновики, снимки контейнеров и попытки входа старше суток
- тесты: upgrade/downgrade, дрейф моделей и миграций, CHECK, CASCADE, FSM, чистка

Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:45:16 +03:00
Eleboba b70797c805 Merge pull request 'feat(llm): провайдер за протоколом, OpenAI-совместимый API и mock' (#33) from feat/17-llm-provider into dev
Reviewed-on: #33
2026-10-15 18:43:27 +03:00
Eleboba 1be665a765 Merge pull request 'docs: guardrails в CLAUDE.md' (#34) from docs/21-guardrails into dev
Reviewed-on: #34
2026-10-14 18:25:44 +03:00
ElebobaandClaude Opus 5.5 8508405b0f docs: guardrails в CLAUDE.md
Refs #21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:08:31 +03:00
ElebobaandClaude Opus 5.5 ab988e6ed0 feat(llm): провайдер за протоколом — OpenAI-совместимый API и mock, фабрика по конфигу
- LLMProvider: complete(messages, schema) -> LLMResult (текст, разобранный JSON, токены)
- openai_compat: /chat/completions, structured output через response_format json_schema,
  JSON из ```-обёртки тоже разбирается; ретраи на 429/5xx и сетевые ошибки, 4xx — сразу ошибка
- mock: детерминированные правила для тестов и демо без сети
- LLMSettings (LLM_PROVIDER/BASE_URL/MODEL/API_KEY…), CLI python -m app.llm.cli

Refs #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:06:30 +03:00
73 changed files with 4574 additions and 69 deletions
+4 -1
View File
@@ -5,6 +5,7 @@
TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather
ADMIN_IDS= # Telegram ID первых администраторов через запятую
LOG_LEVEL=INFO
TELEGRAM_PROXY_URL= # (secret) http://user:pass@host:port или socks5://… — на RF-сервере Telegram недоступен напрямую
# --- База данных ---
POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postgres в Compose
@@ -19,7 +20,9 @@ SOURCECRAFT_UPLOADS_REPO=rogachevegorushka/tgit-uploads
# --- Git: коммиты в репозиторий загрузок (#7, #9) ---
GIT_SSH_KEY_PATH= # путь к приватному deploy-ключу бота (монтируется read-only);
# публичная часть — deploy key с правом записи в tgit-uploads
UPLOADS_GIT_URL=git@ssh.sourcecraft.dev:rogachevegorushka/tgit-uploads.git
GIT_SSH_KEY_B64= # (secret) для Docker: тот же ключ в base64 — base64 -w0 ~/.ssh/tgit_bot_ed25519
GIT_UPLOADS_URL=git@ssh.sourcecraft.dev:rogachevegorushka/tgit-uploads.git
# GIT_CLONE_DIR=data/uploads # локальный клон репозитория загрузок (в Docker — volume /data/uploads)
# --- LLM (#17) ---
LLM_PROVIDER=mock # mock | openai_compat
+72
View File
@@ -0,0 +1,72 @@
name: CI
# Гейт качества на каждый PR и push в dev/main — как в Vobot (.gitea/workflows/backend.yml).
# CD (.gitea/workflows/deploy.yml) тесты не гоняет: они прошли здесь, на PR.
on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-and-test:
# Имя джоба — контекст статуса в PR; если включать branch protection, требовать именно его.
name: ruff · mypy · pytest
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: tgit_test
POSTGRES_USER: tgit
POSTGRES_PASSWORD: tgit
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
# без маппинга портов: act_runner запускает job в контейнере, сервис доступен по имени `postgres`
# (опыт Vobot: маппинг 5432 на хост конфликтует с postgres самого Gitea)
steps:
- uses: actions/checkout@v4
- name: Setup Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
# pip, а не astral-sh/setup-uv: на self-hosted Gitea экшен ходит в GitHub API и падает (опыт Vobot)
run: pip install uv
- name: Install deps
run: uv sync --frozen
- name: Ruff
run: |
uv run ruff check .
uv run ruff format --check .
- name: Mypy
run: uv run mypy
- name: Migrations on PostgreSQL — up, down, up
# pytest гоняет схему на SQLite; здесь — настоящий Postgres: JSONB, BIGINT, CHECK, откат
env:
DATABASE_URL: postgresql+asyncpg://tgit:tgit@postgres:5432/tgit_test
run: |
uv run alembic upgrade head
uv run alembic downgrade base
uv run alembic upgrade head
- name: Pytest (+ порог покрытия из pyproject)
run: |
git config --global user.name ci
git config --global user.email ci@ci
uv run pytest -q --cov --cov-report=term
+45
View File
@@ -0,0 +1,45 @@
name: CD
# Как в Vobot: тесты здесь не гоняем — они прошли в CI на PR.
# dev → /srv/tgit/dev, main → /srv/tgit/prod на хосте Vobot (infra/deploy/deploy.sh).
# Окружение без /srv/tgit/<env>/.env на сервере пропускается (prod — когда будет свой токен бота).
on:
push:
branches: [dev, main]
workflow_dispatch: {}
# Деплои одной ветки — строго по очереди, без отмены.
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: false
jobs:
deploy:
name: deploy → ${{ github.ref_name == 'main' && 'prod' || 'dev' }}
runs-on: ubuntu-latest
env:
DEPLOY_ENV: ${{ github.ref_name == 'main' && 'prod' || 'dev' }}
steps:
- uses: actions/checkout@v4
# Серия мержей подряд: устаревший прогон сам сворачивается, задеплоит актуальный (опыт Vobot)
- id: guard
name: "Актуален ли ещё этот пуш?"
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "go=true" >> "$GITHUB_OUTPUT"; exit 0; fi
REMOTE_SHA=$(git ls-remote origin "refs/heads/${{ github.ref_name }}" | cut -f1)
if [ "$REMOTE_SHA" = "${{ github.sha }}" ]; then echo "go=true" >> "$GITHUB_OUTPUT"
else echo "⏭ ${{ github.sha }} уже не HEAD ${{ github.ref_name }} — пропускаю"; echo "go=false" >> "$GITHUB_OUTPUT"; fi
- name: Deploy
if: steps.guard.outputs.go == 'true'
env:
# Через env:, а не инлайном в run: — многострочный ключ иначе может попасть в лог (опыт Vobot)
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
export DEPLOY_SSH_OPTS="-F /dev/null -i $HOME/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
infra/deploy/deploy.sh "$DEPLOY_ENV" HEAD
+4
View File
@@ -23,3 +23,7 @@ build/
.vscode/
.claude/worktrees/
.DS_Store
# local run without Docker: SQLite DB, uploads clone, demo repo
*.db
data/
+21 -1
View File
@@ -49,6 +49,26 @@ Telegram-бот «git в телеграме» для команды (учебн
| [`docs/dev/ISSUE_TEMPLATE.md`](docs/dev/ISSUE_TEMPLATE.md) | Шаблон тела issue |
| [`docs/dev/GITEA_API.md`](docs/dev/GITEA_API.md) | Gitea API: issue, PR, зависимости, зеркало |
| [`docs/dev/SOURCECRAFT_API.md`](docs/dev/SOURCECRAFT_API.md) | SourceCraft API: CI-прогоны, логи, секреты |
| [`docs/dev/DEPLOY.md`](docs/dev/DEPLOY.md) | **Деплой:** сервер, окружения dev/prod, руками, откат |
---
## Guardrails
Мнемоника: **пусти по приглашению, запри чужой код, не дай LLM команд, спрячь секреты, повтори запрос, держи гейт зелёным.**
| # | Правило | Где живёт правда |
|---|---------|------------------|
| 1 | **Доступ — только участникам.** Новый роутер/хэндлер не обходит `AccessMiddleware` (outer, на `message` и `callback_query`); без приглашения — ни одного действия | `app/bot/middlewares/access.py`, ADR-011 |
| 2 | **Чужой код — только в песочнице `tgit-runner`.** Бот не получает `docker.sock` напрямую: чтение — через read-only proxy, запуск — через runner с forced command | `infra/runner/`, ADR-005/006 |
| 3 | **LLM не исполняет ничего.** Только JSON по схеме (`LogQuery`, вердикт стража) → валидация → выполнение ботом в Python; grep — не в shell; логи в LLM не передаются | `app/agent/`, ADR-007/013 |
| 4 | **Провайдер — за протоколом.** Вендорский код только в `app/llm/<provider>.py`, остальные зависят от `LLMProvider` | `app/llm/provider.py`, ADR-008 |
| 5 | **Конфиг типизирован.** Только `app/config.py` (`pydantic-settings` по подсистемам), без `os.getenv` вразброс; тексты, пороги, промпты — `config/*.yaml` | `app/config.py` |
| 6 | **Секреты — только в `.env`.** Не логируются, не уходят в LLM, не попадают в git и в чат | `.env.example` |
| 7 | **Внешние вызовы — с ретраями.** SourceCraft API и Gitea рвут соединения: повторы с задержкой, перед повторным POST проверить, не создалось ли | `app/sourcecraft/client.py`, `docs/dev/*_API.md` |
| 8 | **Схема БД — только миграциями.** Изменил модель → `alembic revision --autogenerate` + ручное ревью (импорты, дубли CHECK); тест дрейфа должен быть зелёным | `app/db/`, `tests/db/test_migrations.py` |
| 9 | **Гейт зелёный:** `make lint && make test` перед PR; порог покрытия (`fail_under`) только растёт | `pyproject.toml` |
| 10 | **В SourceCraft не пушим и не мержим** — это зеркало Gitea; `.sourcecraft/` и `.tgit/` в `tgit-uploads` правим через шаблон `infra/uploads-template/` | ADR-010, ADR-014 |
---
@@ -56,7 +76,7 @@ Telegram-бот «git в телеграме» для команды (учебн
1. **Задача = Issue в Gitea.** Нет issue — нет кода.
2. **Ветка от `dev`:** `<type>/<issue#>-<slug>`. PR в `dev` (в Gitea); `dev → main` — только человек.
3. **Гейт зелёный:** lint + тесты перед PR.
3. **Гейт зелёный:** `make lint && make test` перед PR; guardrails выше — не нарушать.
4. **Завершил → push + PR** сразу.
5. **Лог выполнения** — в issue (`### YYYY-MM-DD`), архитектурные решения — в `DECISIONS.md`.
6. **Секреты** (токен бота, deploy-ключ, ключи LLM) — только в `.env`, в чат и git не попадают.
+2
View File
@@ -19,6 +19,8 @@ ENV PATH="/opt/venv/bin:$PATH" PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
COPY --from=build /opt/venv /opt/venv
WORKDIR /srv/tgit
COPY config ./config
# /data: the uploads repo clone (named volume inherits this ownership)
RUN install -d -o tgit -g tgit /data
USER tgit
# Liveness: the bot touches a heartbeat file while its event loop runs (app/health.py)
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 CMD ["python", "-m", "app.health"]
+16 -1
View File
@@ -1,4 +1,4 @@
.PHONY: install lint fmt typecheck test cov up down logs ps
.PHONY: install lint fmt typecheck test cov up down logs ps run demo-repo
install:
uv sync
@@ -30,3 +30,18 @@ logs:
ps:
docker compose ps
# Run the bot on this machine without Docker: SQLite (./tgit.db) and the settings from .env
run:
uv run python -m app
# A local uploads repo seeded from the template — try the bot without SourceCraft:
# make demo-repo → put the printed GIT_UPLOADS_URL into .env → make run
DEMO_REPO := $(CURDIR)/data/demo-uploads.git
demo-repo:
@test ! -e $(DEMO_REPO) || { echo "$(DEMO_REPO) already exists"; exit 1; }
@rm -rf data/demo-seed && mkdir -p data && cp -r infra/uploads-template data/demo-seed
@cd data/demo-seed && git init -q -b main && git add -A \
&& git -c user.name=tgit-bot -c user.email=tgit-bot@users.noreply.tgit commit -qm "init: uploads template"
@git clone -q --bare data/demo-seed $(DEMO_REPO) && rm -rf data/demo-seed
@echo "GIT_UPLOADS_URL=$(DEMO_REPO)"
+5
View File
@@ -0,0 +1,5 @@
# Developer CLI: `uv run alembic revision --autogenerate -m "..."`. The DB URL comes from DATABASE_URL (.env),
# the bot itself applies migrations at startup (app/db/migrate.py).
[alembic]
script_location = app.db:migrations
file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s_%%(slug)s
View File
+13
View File
@@ -0,0 +1,13 @@
import io
import segno
def invite_link(bot_username: str, token: str) -> str:
return f"https://t.me/{bot_username}?start={token}"
def qr_png(data: str) -> bytes:
buf = io.BytesIO()
segno.make(data, error="m").save(buf, kind="png", scale=10, border=2)
return buf.getvalue()
+174
View File
@@ -0,0 +1,174 @@
"""Invites, members and revocation (SPEC §3.1, ADR-011)."""
import hashlib
import secrets
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from typing import Any, cast
from sqlalchemy import CursorResult, delete, func, select, update
from app.config import AccessSettings
from app.db.models import AccessAttempt, Draft, FsmState, Invite, Role, User, utcnow
from app.db.session import SessionMaker
LAST_SEEN_EVERY = timedelta(minutes=5)
ATTEMPT_WINDOW = timedelta(hours=1)
class RedeemStatus(StrEnum):
OK = "ok"
ALREADY_MEMBER = "already_member"
REJECTED = "rejected" # unknown, used or expired — one answer, no hints which tokens existed
THROTTLED = "throttled"
@dataclass(frozen=True)
class TgUser:
tg_id: int
full_name: str
username: str | None = None
@dataclass(frozen=True)
class CreatedInvite:
token: str
expires_at: datetime
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
class AccessService:
def __init__(self, sessions: SessionMaker, settings: AccessSettings, clock: Callable[[], datetime] = utcnow):
self._sessions = sessions
self._settings = settings
self._clock = clock
async def bootstrap_admins(self) -> None:
"""ADMIN_IDS become active admins in the DB; everybody else gets in only by invite (SPEC A-8)."""
async with self._sessions.begin() as s:
for tg_id in self._settings.admin_ids:
user = await s.get(User, tg_id)
if user is None:
s.add(User(tg_id=tg_id, full_name=f"admin {tg_id}", role=Role.ADMIN, joined_at=self._clock()))
else:
user.role, user.is_active = Role.ADMIN, True
async def get_member(self, tg_id: int) -> User | None:
async with self._sessions() as s:
user = await s.get(User, tg_id)
return user if user is not None and user.is_active else None
async def touch(self, member: User, seen: TgUser) -> None:
"""Refresh name and last-seen, at most every few minutes — not a write per update."""
now = self._clock()
last = member.last_seen_at
if (
last is not None
and now - _aware(last) < LAST_SEEN_EVERY
and (member.full_name, member.username) == (seen.full_name, seen.username)
):
return
async with self._sessions.begin() as s:
await s.execute(
update(User)
.where(User.tg_id == member.tg_id)
.values(last_seen_at=now, full_name=seen.full_name, username=seen.username)
)
async def create_invite(self, admin_id: int) -> CreatedInvite:
token = secrets.token_urlsafe(16) # 128 bits, 22 chars of [A-Za-z0-9_-]: fits the 64-char deep link payload
expires_at = self._clock() + timedelta(hours=self._settings.invite_ttl_hours)
async with self._sessions.begin() as s:
s.add(
Invite(
token_hash=hash_token(token), created_by=admin_id, created_at=self._clock(), expires_at=expires_at
)
)
return CreatedInvite(token=token, expires_at=expires_at)
async def redeem(self, token: str, who: TgUser) -> RedeemStatus:
now = self._clock()
try:
async with self._sessions.begin() as s:
existing = await s.get(User, who.tg_id)
if existing is not None and existing.is_active:
return RedeemStatus.ALREADY_MEMBER
failed = await s.scalar(
select(func.count())
.select_from(AccessAttempt)
.where(
AccessAttempt.tg_id == who.tg_id,
AccessAttempt.ok.is_(False),
AccessAttempt.at > now - ATTEMPT_WINDOW,
)
)
if (failed or 0) >= self._settings.max_failed_attempts:
return RedeemStatus.THROTTLED
invite = await s.scalar(select(Invite).where(Invite.token_hash == hash_token(token)))
if invite is None or invite.used_by is not None or _aware(invite.expires_at) <= now:
raise _Rejected
if existing is None:
s.add(
User(
tg_id=who.tg_id,
full_name=who.full_name,
username=who.username,
role=Role.MEMBER,
invited_by=invite.created_by,
joined_at=now,
last_seen_at=now,
)
)
else: # revoked earlier, invited again
existing.is_active, existing.role, existing.invited_by = True, Role.MEMBER, invite.created_by
existing.full_name, existing.username, existing.last_seen_at = who.full_name, who.username, now
await s.flush() # the user row must exist before invites.used_by points at it
# Claim atomically: of two people scanning the same QR only one UPDATE matches the row,
# the loser's transaction (with its freshly created user) is rolled back.
result = await s.execute(
update(Invite)
.where(Invite.id == invite.id, Invite.used_by.is_(None))
.values(used_by=who.tg_id, used_at=now)
)
if cast(CursorResult[Any], result).rowcount != 1:
raise _Rejected
s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=True))
except _Rejected:
async with self._sessions.begin() as s:
s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=False))
return RedeemStatus.REJECTED
return RedeemStatus.OK
async def list_members(self) -> list[User]:
async with self._sessions() as s:
rows = await s.scalars(select(User).where(User.is_active.is_(True)).order_by(User.role, User.joined_at))
return list(rows)
async def revoke(self, tg_id: int) -> bool:
"""Cut a member off at once: their updates are ignored from now on, draft and dialog state are dropped."""
async with self._sessions.begin() as s:
user = await s.get(User, tg_id)
if user is None or not user.is_active or user.role is Role.ADMIN:
return False
user.is_active = False
await s.execute(delete(Draft).where(Draft.user_id == tg_id))
# FSM keys are "bot:chat:user:…"; in a private chat chat == user
await s.execute(delete(FsmState).where(FsmState.key.like(f"%:{tg_id}:{tg_id}:%")))
return True
class _Rejected(Exception):
pass
def _aware(value: datetime) -> datetime:
# SQLite hands back naive datetimes even for timezone=True columns; everything we store is UTC.
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
+6 -4
View File
@@ -1,8 +1,10 @@
"""FSM storage factory. In-memory for now; Postgres-backed storage that survives restarts arrives with the DB (#5)."""
"""FSM storage factory: dialog state lives in the DB and survives restarts (SPEC M-4)."""
from aiogram.fsm.storage.base import BaseStorage
from aiogram.fsm.storage.memory import MemoryStorage
from app.bot.fsm_storage import SqlStorage
from app.db.session import SessionMaker
def make_storage() -> BaseStorage:
return MemoryStorage()
def make_storage(sessions: SessionMaker) -> BaseStorage:
return SqlStorage(sessions)
+54
View File
@@ -0,0 +1,54 @@
"""aiogram FSM storage on top of the `fsm_state` table — dialog state survives bot restarts (SPEC M-4)."""
from collections.abc import Mapping
from typing import Any
from aiogram.fsm.state import State
from aiogram.fsm.storage.base import BaseStorage, StateType, StorageKey
from app.db.models import FsmState
from app.db.session import SessionMaker
def key_to_str(key: StorageKey) -> str:
parts = (key.bot_id, key.chat_id, key.user_id, key.thread_id, key.business_connection_id, key.destiny)
return ":".join("" if p is None else str(p) for p in parts)
class SqlStorage(BaseStorage):
def __init__(self, sessions: SessionMaker) -> None:
self._sessions = sessions
async def set_state(self, key: StorageKey, state: StateType = None) -> None:
value = state.state if isinstance(state, State) else state
await self._write(key, state=value)
async def get_state(self, key: StorageKey) -> str | None:
async with self._sessions() as session:
row = await session.get(FsmState, key_to_str(key))
return row.state if row else None
async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None:
await self._write(key, data=dict(data))
async def get_data(self, key: StorageKey) -> dict[str, Any]:
async with self._sessions() as session:
row = await session.get(FsmState, key_to_str(key))
return dict(row.data) if row else {}
async def close(self) -> None:
pass # the engine is owned and disposed by the app
async def _write(self, key: StorageKey, **fields: Any) -> None:
async with self._sessions.begin() as session:
row = await session.get(FsmState, key_to_str(key))
state = fields.get("state", row.state if row else None)
data = fields.get("data", row.data if row else {})
if state is None and not data:
if row is not None:
await session.delete(row) # empty state: keep the table small
return
if row is None:
session.add(FsmState(key=key_to_str(key), state=state, data=data))
else:
row.state, row.data = state, data
+29 -11
View File
@@ -1,6 +1,7 @@
"""Access gate on every update — messages and button presses alike (SPEC A-5).
"""Access gate on every update — messages and button presses alike (SPEC A-5, ADR-011).
Until invites land (#25) the only members are the bootstrap admins from `ADMIN_IDS`.
Members (active users in the DB) pass with `member` injected into handler data. Outsiders pass only with
`/start <token>` — the invite deep link; everything else gets one "invite only" reply a day, then silence.
"""
import time
@@ -8,18 +9,28 @@ from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message, TelegramObject
from aiogram.types import CallbackQuery, Message, TelegramObject, User
from app.access.service import AccessService, TgUser
from app.texts import Texts
IsMember = Callable[[int], Awaitable[bool]]
NO_ACCESS_REPLY_EVERY = 24 * 3600.0
def tg_user(user: User) -> TgUser:
return TgUser(tg_id=user.id, full_name=user.full_name, username=user.username)
def is_invite_link(event: TelegramObject) -> bool:
if not isinstance(event, Message) or not event.text:
return False
parts = event.text.split(maxsplit=1)
return len(parts) == 2 and parts[0].split("@")[0] == "/start"
class AccessMiddleware(BaseMiddleware):
def __init__(self, is_member: IsMember, texts: Texts, clock: Callable[[], float] = time.monotonic) -> None:
self._is_member = is_member
def __init__(self, access: AccessService, texts: Texts, clock: Callable[[], float] = time.monotonic) -> None:
self._access = access
self._texts = texts
self._clock = clock
# tg_id -> when we last told this outsider "invite only"; answering every message would let them spam us.
@@ -31,11 +42,18 @@ class AccessMiddleware(BaseMiddleware):
event: TelegramObject,
data: dict[str, Any],
) -> Any:
user = data.get("event_from_user")
if user is not None and await self._is_member(user.id):
user: User | None = data.get("event_from_user")
if user is None:
return None
member = await self._access.get_member(user.id)
if member is not None:
await self._access.touch(member, tg_user(user))
data["member"] = member
return await handler(event, data)
if user is not None:
await self._refuse(event, user.id)
if is_invite_link(event):
data["member"] = None
return await handler(event, data)
await self._refuse(event, user.id)
return None
async def _refuse(self, event: TelegramObject, tg_id: int) -> None:
+611
View File
@@ -0,0 +1,611 @@
"""«📝 Коммит»: browse the uploads repo tree and collect changes into a draft (SPEC C-1…C-16). Push — #9."""
import base64
import html
import io
import logging
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from aiogram import Bot, F, Router
from aiogram.enums import ParseMode
from aiogram.filters.callback_data import CallbackData
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from app.bot.menu import Nav, NavAction
from app.db.models import ChangeOp, User
from app.repo.drafts import DraftFull, DraftService, DraftView
from app.repo.git import Entry, GitError, UploadsRepo
from app.repo.publisher import Publisher, PushResult, PushStatus
from app.repo.validate import (
MAX_FILE_BYTES,
ValidationError,
check_content,
check_message,
check_name,
diff_stat,
is_protected,
join,
python_syntax,
strip_fence,
)
from app.texts import CommitTexts, Texts
log = logging.getLogger(__name__)
PAGE_SIZE = 20
INLINE_VIEW_LIMIT = 3500 # chars; Telegram caps a message at 4096 and <pre> markup takes some
class Act(StrEnum):
OPEN = "o"
UP = "u"
PAGE = "p"
VIEW = "v"
TO_DIR = "b"
REPLACE = "r"
DELETE = "d"
NEW_FILE = "f"
NEW_DIR = "n"
DRAFT = "dr"
UNSTAGE = "x"
FORCE = "y"
FIX = "z"
MESSAGE = "m"
PUSH = "ps"
PUSH_GO = "pg"
PUSH_FORCE = "pf"
COMMIT = "cm"
class CommitCb(CallbackData, prefix="c"):
# Only an action and a small index: paths never go into callback_data (64-byte limit, ARCHITECTURE §2.1)
a: Act
i: int = 0
class Flow(StatesGroup):
file_name = State()
dir_name = State()
content = State()
syntax = State()
message = State()
class Mark(StrEnum):
NONE = ""
NEW = "new"
MODIFIED = "mod"
DELETED = "del"
@dataclass(frozen=True)
class Row:
"""A line of the directory screen: a repo entry with the draft laid over it."""
path: str
kind: str
size: int
mark: Mark
@property
def name(self) -> str:
return self.path.rsplit("/", 1)[-1]
def human_size(n: int) -> str:
size = float(n)
for unit in ("Б", "КБ", "МБ"):
if size < 1024 or unit == "МБ":
return f"{size:.0f} {unit}" if unit == "Б" else f"{size:.1f} {unit}"
size /= 1024
raise AssertionError("unreachable")
def parent(path: str) -> str:
return path.rsplit("/", 1)[0] if "/" in path else ""
def overlay(path: str, entries: list[Entry], draft: DraftView | None, virtual_dirs: list[str]) -> list[Row]:
"""Repo listing + what the draft adds, changes or deletes in this directory."""
changes = {c.path: c for c in draft.changes} if draft else {}
rows: dict[str, Row] = {}
for e in entries:
change = changes.get(e.path)
mark, size = Mark.NONE, e.size
if change is not None:
mark = Mark.DELETED if change.op is ChangeOp.DELETE else Mark.MODIFIED
if change.content is not None:
size = len(change.content) # show the size the file will have after the push
rows[e.path] = Row(e.path, e.kind, size, mark)
prefix = f"{path}/" if path else ""
for change in changes.values():
if change.op is not ChangeOp.ADD or not change.path.startswith(prefix):
continue
rest = change.path[len(prefix) :]
if "/" in rest: # a file deeper down: show its top folder here
sub = prefix + rest.split("/", 1)[0]
rows.setdefault(sub, Row(sub, "dir", 0, Mark.NEW))
else:
rows[change.path] = Row(change.path, "file", len(change.content or b""), Mark.NEW)
for vd in virtual_dirs:
if parent(vd) == path:
rows.setdefault(vd, Row(vd, "dir", 0, Mark.NEW))
return sorted(rows.values(), key=lambda r: (r.kind != "dir", r.name.lower()))
def mark_prefix(t: CommitTexts, mark: Mark) -> str:
return {Mark.NONE: "", Mark.NEW: t.mark_new, Mark.MODIFIED: t.mark_modified, Mark.DELETED: t.mark_deleted}[mark]
def render_dir(
texts: Texts, path: str, rows: list[Row], page: int, draft_count: int
) -> tuple[str, InlineKeyboardMarkup]:
t = texts.commit
pages = max(1, -(-len(rows) // PAGE_SIZE))
page = min(max(page, 0), pages - 1)
lines = [t.title.format(path=path)]
if not rows:
lines.append(t.empty)
elif pages > 1:
lines.append(t.page.format(page=page + 1, pages=pages))
kb = InlineKeyboardBuilder()
start = page * PAGE_SIZE
shown = rows[start : start + PAGE_SIZE]
for index, r in enumerate(shown, start=start):
base = (
t.dir_button.format(name=r.name)
if r.kind == "dir"
else t.file_button.format(name=r.name, size=human_size(r.size))
)
kb.button(text=mark_prefix(t, r.mark) + base, callback_data=CommitCb(a=Act.OPEN, i=index))
sizes = [1] * len(shown)
paging: list[tuple[str, CommitCb]] = []
if page > 0:
paging.append((t.prev, CommitCb(a=Act.PAGE, i=page - 1)))
if page < pages - 1:
paging.append((t.next, CommitCb(a=Act.PAGE, i=page + 1)))
for text, cb in paging:
kb.button(text=text, callback_data=cb)
if paging:
sizes.append(len(paging))
kb.button(text=t.new_file, callback_data=CommitCb(a=Act.NEW_FILE))
kb.button(text=t.new_dir, callback_data=CommitCb(a=Act.NEW_DIR))
sizes.append(2)
if draft_count:
# the main action once something is staged — right in the folder, not two screens deep
kb.button(text=t.commit_button.format(count=draft_count), callback_data=CommitCb(a=Act.COMMIT))
kb.button(text=t.draft_button.format(count=draft_count), callback_data=CommitCb(a=Act.DRAFT))
sizes += [1, 1]
if path:
kb.button(text=t.up, callback_data=CommitCb(a=Act.UP))
sizes.append(1)
kb.button(text=texts.menu.back, callback_data=Nav(action=NavAction.BACK))
kb.button(text=texts.menu.cancel, callback_data=Nav(action=NavAction.CANCEL))
sizes.append(2)
kb.adjust(*sizes)
return "\n".join(lines), kb.as_markup()
def file_keyboard(texts: Texts) -> InlineKeyboardMarkup:
t = texts.commit
kb = InlineKeyboardBuilder()
kb.button(text=t.view, callback_data=CommitCb(a=Act.VIEW))
kb.button(text=t.replace, callback_data=CommitCb(a=Act.REPLACE))
kb.button(text=t.delete, callback_data=CommitCb(a=Act.DELETE))
kb.button(text=t.to_dir, callback_data=CommitCb(a=Act.TO_DIR))
kb.adjust(1, 2, 1)
return kb.as_markup()
def to_dir_keyboard(texts: Texts) -> InlineKeyboardMarkup:
kb = InlineKeyboardBuilder()
kb.button(text=texts.commit.to_dir, callback_data=CommitCb(a=Act.TO_DIR))
return kb.as_markup()
def build_router(
texts: Texts, repo: UploadsRepo, drafts: DraftService, publisher: Publisher, commit_url_template: str = ""
) -> Router:
router = Router(name="commit")
t = texts.commit
async def dir_screen(state: FSMContext, user_id: int, path: str, page: int) -> tuple[str, InlineKeyboardMarkup]:
data = await state.get_data()
virtual: list[str] = data.get("commit_virtual", [])
draft = await drafts.get(user_id)
try:
entries = await repo.list_dir(path)
except GitError:
# a folder that exists only in the draft (or a virtual one) has nothing in the repo yet
if path not in virtual and not (draft and any(c.path.startswith(f"{path}/") for c in draft.changes)):
raise
entries = []
rows = overlay(path, entries, draft, virtual)
await state.update_data(
commit_path=path,
commit_page=page,
commit_rows=[[r.path, r.kind, r.size, r.mark.value] for r in rows],
commit_file=None,
)
return render_dir(texts, path, rows, page, len(draft.changes) if draft else 0)
async def draft_screen(state: FSMContext, user_id: int) -> tuple[str, InlineKeyboardMarkup]:
draft = await drafts.get(user_id)
changes = draft.changes if draft else []
await state.update_data(commit_draft=[c.path for c in changes])
kb = InlineKeyboardBuilder()
if not changes:
text = t.draft_empty
else:
ops = {ChangeOp.ADD: t.op_add, ChangeOp.MODIFY: t.op_modify, ChangeOp.DELETE: t.op_delete}
lines = [t.draft_title.format(count=len(changes)), ""]
lines += [f"{ops[c.op]} /{c.path}" for c in changes]
message = draft.message if draft else None
lines += ["", t.message_current.format(message=message) if message else t.message_missing]
text = "\n".join(lines)
for i, c in enumerate(changes):
kb.button(
text=t.unstage.format(name=c.path.rsplit("/", 1)[-1]), callback_data=CommitCb(a=Act.UNSTAGE, i=i)
)
if changes:
kb.button(text=t.message_button, callback_data=CommitCb(a=Act.MESSAGE))
if draft and draft.message:
kb.button(text=t.push_button, callback_data=CommitCb(a=Act.PUSH))
kb.button(text=t.to_dir, callback_data=CommitCb(a=Act.TO_DIR))
kb.adjust(1)
return text, kb.as_markup()
def confirm_screen(draft: DraftView) -> tuple[str, InlineKeyboardMarkup]:
builder = InlineKeyboardBuilder()
builder.button(text=t.push_go, callback_data=CommitCb(a=Act.PUSH_GO))
builder.button(text=t.edit_message, callback_data=CommitCb(a=Act.MESSAGE))
builder.button(text=texts.menu.back, callback_data=CommitCb(a=Act.TO_DIR))
builder.adjust(1)
return t.push_confirm.format(summary=summary(draft), message=draft.message), builder.as_markup()
def summary(draft: DraftView) -> str:
ops = {ChangeOp.ADD: t.op_add, ChangeOp.MODIFY: t.op_modify, ChangeOp.DELETE: t.op_delete}
return "\n".join(f"{ops[c.op]} /{c.path}" for c in draft.changes)
async def do_push(msg: Message, state: FSMContext, member: User, *, overwrite: bool) -> None:
draft = await drafts.get(member.tg_id)
if draft is None or not draft.changes or not draft.message:
text, kb = await draft_screen(state, member.tg_id)
await msg.edit_text(text, reply_markup=kb)
return
await msg.edit_text(t.pushing)
result: PushResult = await publisher.push(member, draft, draft.message, overwrite=overwrite)
match result.status:
case PushStatus.OK:
assert result.sha is not None
first_line = draft.message.splitlines()[0]
lines = [t.pushed.format(sha=result.sha[:7], message=first_line)]
if commit_url_template:
lines.append(commit_url_template.format(sha=result.sha))
lines.append(t.ci_pending)
await msg.edit_text("\n".join(lines))
await publisher.record(member, result, draft.message, draft, msg.chat.id, msg.message_id)
await drafts.clear(member.tg_id)
await state.update_data(commit_virtual=[])
case PushStatus.CONFLICT:
builder = InlineKeyboardBuilder()
builder.button(text=t.overwrite, callback_data=CommitCb(a=Act.PUSH_FORCE))
builder.button(text=texts.menu.cancel, callback_data=CommitCb(a=Act.DRAFT))
paths = "\n".join(f"/{p}" for p in result.conflicts)
await msg.edit_text(t.conflict.format(paths=paths), reply_markup=builder.as_markup())
case PushStatus.EMPTY:
await drafts.clear(member.tg_id)
await msg.edit_text(t.push_empty)
case PushStatus.LIMIT:
await msg.edit_text(t.push_limit, reply_markup=to_dir_keyboard(texts))
case PushStatus.FAILED:
await msg.edit_text(t.push_failed, reply_markup=to_dir_keyboard(texts))
async def file_card(user_id: int, path: str, size: int, mark: Mark) -> str:
card = t.file_card.format(path=path, size=human_size(size))
if mark is Mark.NEW:
return f"{card}\n{t.in_draft_new}"
info = await repo.last_commit(path)
tail = (
t.last_commit.format(
sha=info.sha[:7], author=info.author, date=info.date.strftime("%d.%m.%Y %H:%M"), subject=info.subject
)
if info
else t.no_commits
)
note = {Mark.MODIFIED: f"\n{t.in_draft_modified}", Mark.DELETED: f"\n{t.in_draft_deleted}"}.get(mark, "")
return f"{card}\n{tail}{note}"
async def current_content(user_id: int, path: str) -> bytes | None:
"""What the member sees as the file now: the draft version if there is one."""
draft = await drafts.get(user_id)
change = draft.change_for(path) if draft else None
if change is not None:
return change.content # None for a deleted file
return await repo.read(path)
async def send_file(msg: Message, user_id: int, path: str) -> None:
content = await current_content(user_id, path)
if content is None:
return
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
text = None
if text is not None and "\0" not in text and len(text) <= INLINE_VIEW_LIMIT:
await msg.answer(f"<b>/{html.escape(path)}</b>\n<pre>{html.escape(text)}</pre>", parse_mode=ParseMode.HTML)
else:
await msg.answer_document(BufferedInputFile(content, path.rsplit("/", 1)[-1]), caption=t.sent_as_file)
async def stage(message: Message, state: FSMContext, member: User, path: str, op: ChangeOp, content: bytes) -> None:
old = await current_content(member.tg_id, path) if op is ChangeOp.MODIFY else None
try:
await drafts.put(member.tg_id, await repo.head(), path, op, content)
except DraftFull:
await message.answer(t.draft_full)
return
if op is ChangeOp.ADD:
note = t.added.format(path=path)
else:
added, removed = diff_stat((old or b"").decode("utf-8", errors="replace"), content.decode("utf-8"))
note = t.modified.format(path=path, added=added, removed=removed)
data = await state.get_data()
# a virtual folder becomes real as soon as a file lands in it
virtual = [v for v in data.get("commit_virtual", []) if not path.startswith(f"{v}/")]
await state.set_state(None)
await state.update_data(commit_virtual=virtual, commit_pending=None)
text, kb = await dir_screen(state, member.tg_id, data.get("commit_path", ""), 0)
await message.answer(f"{note}\n\n{text}", reply_markup=kb)
# ---- entry point -------------------------------------------------------------------------------------------
@router.message(F.text == texts.menu.commit)
async def open_commit(message: Message, state: FSMContext, member: User) -> None:
await state.clear()
try:
await repo.refresh()
text, kb = await dir_screen(state, member.tg_id, "", 0)
except GitError:
log.exception("uploads repo unavailable")
await message.answer(t.unavailable)
return
await message.answer(text, reply_markup=kb)
# ---- buttons -----------------------------------------------------------------------------------------------
@router.callback_query(CommitCb.filter())
async def on_action(query: CallbackQuery, callback_data: CommitCb, state: FSMContext, member: User) -> None:
if not isinstance(query.message, Message):
await query.answer()
return
msg = query.message
data: dict[str, Any] = await state.get_data()
path: str = data.get("commit_path", "")
rows: list[list[Any]] = data.get("commit_rows", [])
uid = member.tg_id
try:
match callback_data.a:
case Act.OPEN:
if not 0 <= callback_data.i < len(rows):
await query.answer() # stale button from an older screen
return
target, kind, size, mark = rows[callback_data.i]
if kind == "dir":
await repo.refresh()
text, kb = await dir_screen(state, uid, target, 0)
await msg.edit_text(text, reply_markup=kb)
else:
await state.update_data(commit_file=target)
await msg.edit_text(
await file_card(uid, target, size, Mark(mark)), reply_markup=file_keyboard(texts)
)
case Act.UP:
text, kb = await dir_screen(state, uid, parent(path), 0)
await msg.edit_text(text, reply_markup=kb)
case Act.PAGE:
real = [Row(p, k, s, Mark(m)) for p, k, s, m in rows]
draft = await drafts.get(uid)
await state.update_data(commit_page=callback_data.i)
text, kb = render_dir(texts, path, real, callback_data.i, len(draft.changes) if draft else 0)
await msg.edit_text(text, reply_markup=kb)
case Act.TO_DIR:
await state.set_state(None)
text, kb = await dir_screen(state, uid, path, data.get("commit_page", 0))
await msg.edit_text(text, reply_markup=kb)
case Act.VIEW:
if data.get("commit_file"):
await send_file(msg, uid, data["commit_file"])
case Act.NEW_FILE:
await state.set_state(Flow.file_name)
await msg.edit_text(t.ask_file_name.format(dir=path), reply_markup=to_dir_keyboard(texts))
case Act.NEW_DIR:
await state.set_state(Flow.dir_name)
await msg.edit_text(t.ask_dir_name.format(dir=path), reply_markup=to_dir_keyboard(texts))
case Act.REPLACE:
target = data.get("commit_file")
if not target:
await query.answer()
return
if is_protected(target):
await query.answer(t.protected, show_alert=True)
return
await state.set_state(Flow.content)
await state.update_data(commit_target=target, commit_op=ChangeOp.MODIFY.value)
await msg.edit_text(t.ask_content.format(path=target), reply_markup=to_dir_keyboard(texts))
case Act.DELETE:
target = data.get("commit_file")
if not target:
await query.answer()
return
if is_protected(target):
await query.answer(t.protected, show_alert=True)
return
await drafts.put(uid, await repo.head(), target, ChangeOp.DELETE, None)
text, kb = await dir_screen(state, uid, path, data.get("commit_page", 0))
await msg.edit_text(f"{t.deleted.format(path=target)}\n\n{text}", reply_markup=kb)
case Act.DRAFT:
text, kb = await draft_screen(state, uid)
await msg.edit_text(text, reply_markup=kb)
case Act.UNSTAGE:
paths: list[str] = data.get("commit_draft", [])
if 0 <= callback_data.i < len(paths):
await drafts.drop(uid, paths[callback_data.i])
text, kb = await draft_screen(state, uid)
await msg.edit_text(text, reply_markup=kb)
case Act.FORCE:
pending = data.get("commit_pending")
if pending is None or await state.get_state() != Flow.syntax.state:
await query.answer()
return
await query.answer()
await stage(
msg,
state,
member,
data["commit_target"],
ChangeOp(data["commit_op"]),
base64.b64decode(pending),
)
return
case Act.MESSAGE:
await state.set_state(Flow.message)
await state.update_data(commit_then_push=False)
await msg.edit_text(t.ask_message, reply_markup=to_dir_keyboard(texts))
case Act.PUSH:
draft = await drafts.get(uid)
if draft is None or not draft.changes or not draft.message:
await query.answer()
return
confirm, markup = confirm_screen(draft)
await msg.edit_text(confirm, reply_markup=markup)
case Act.COMMIT:
draft = await drafts.get(uid)
if draft is None or not draft.changes:
await query.answer()
return
if draft.message:
confirm, markup = confirm_screen(draft)
await msg.edit_text(confirm, reply_markup=markup)
else:
# ask for the message, then go straight to the push confirmation
await state.set_state(Flow.message)
await state.update_data(commit_then_push=True)
await msg.edit_text(t.ask_message, reply_markup=to_dir_keyboard(texts))
case Act.PUSH_GO | Act.PUSH_FORCE:
await query.answer()
await do_push(msg, state, member, overwrite=callback_data.a is Act.PUSH_FORCE)
return
case Act.FIX:
await state.set_state(Flow.content)
await state.update_data(commit_pending=None)
await msg.edit_text(
t.ask_content.format(path=data.get("commit_target", "")), reply_markup=to_dir_keyboard(texts)
)
except GitError:
log.exception("uploads repo unavailable")
await query.answer(t.unavailable, show_alert=True)
return
except DraftFull:
await query.answer(t.draft_full, show_alert=True)
return
await query.answer()
# ---- text input --------------------------------------------------------------------------------------------
@router.message(Flow.file_name, F.text)
async def got_file_name(message: Message, state: FSMContext, member: User) -> None:
data = await state.get_data()
directory = data.get("commit_path", "")
try:
name = check_name(message.text or "")
except ValidationError:
await message.answer(t.bad_name)
return
path = join(directory, name)
if is_protected(path):
await message.answer(t.protected)
return
taken = {row[0] for row in data.get("commit_rows", [])}
if path in taken:
await message.answer(t.exists)
return
await state.set_state(Flow.content)
await state.update_data(commit_target=path, commit_op=ChangeOp.ADD.value)
await message.answer(t.ask_content.format(path=path), reply_markup=to_dir_keyboard(texts))
@router.message(Flow.dir_name, F.text)
async def got_dir_name(message: Message, state: FSMContext, member: User) -> None:
data = await state.get_data()
directory = data.get("commit_path", "")
try:
name = check_name(message.text or "")
except ValidationError:
await message.answer(t.bad_name)
return
path = join(directory, name)
if is_protected(path + "/x"):
await message.answer(t.protected)
return
if path in {row[0] for row in data.get("commit_rows", [])}:
await message.answer(t.exists)
return
await state.set_state(None)
await state.update_data(commit_virtual=[*data.get("commit_virtual", []), path])
text, kb = await dir_screen(state, member.tg_id, path, 0)
await message.answer(text, reply_markup=kb)
@router.message(Flow.message, F.text)
async def got_message(message: Message, state: FSMContext, member: User) -> None:
try:
text = check_message(message.text or "")
except ValidationError:
await message.answer(t.bad_message)
return
await drafts.set_message(member.tg_id, text)
await state.set_state(None)
draft = await drafts.get(member.tg_id)
if (await state.get_data()).get("commit_then_push") and draft is not None and draft.changes:
await state.update_data(commit_then_push=False)
screen, kb = confirm_screen(draft)
else:
screen, kb = await draft_screen(state, member.tg_id)
await message.answer(screen, reply_markup=kb)
@router.message(Flow.content, F.text | F.document)
async def got_content(message: Message, state: FSMContext, member: User, bot: Bot) -> None:
data = await state.get_data()
target: str = data["commit_target"]
if message.document is not None:
if (message.document.file_size or 0) > MAX_FILE_BYTES:
await message.answer(t.too_big)
return
buf = io.BytesIO()
await bot.download(message.document, destination=buf)
raw = buf.getvalue()
else:
raw = strip_fence(message.text or "").encode("utf-8")
try:
text = check_content(raw)
except ValidationError as e:
await message.answer({"too_big": t.too_big, "binary": t.binary, "encoding": t.encoding}[str(e)])
return
issue = python_syntax(target, text)
if issue is not None:
await state.set_state(Flow.syntax)
await state.update_data(commit_pending=base64.b64encode(raw).decode())
kb = InlineKeyboardBuilder()
kb.button(text=t.fix, callback_data=CommitCb(a=Act.FIX))
kb.button(text=t.force, callback_data=CommitCb(a=Act.FORCE))
await message.answer(t.syntax.format(line=issue.line, message=issue.message), reply_markup=kb.as_markup())
return
await stage(message, state, member, target, ChangeOp(data["commit_op"]), raw)
return router
+26 -17
View File
@@ -1,49 +1,58 @@
"""/start, /help, /menu, main menu buttons and in-section navigation."""
"""/start (incl. invite deep links), /help, /menu, main menu buttons and in-section navigation."""
from aiogram import F, Router
from aiogram.filters import Command, CommandStart
from aiogram.filters import Command, CommandObject, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from app.access.service import AccessService, RedeemStatus
from app.bot.menu import Nav, NavAction, Section, main_menu, section_by_button, section_nav
from app.bot.middlewares.access import tg_user
from app.db.models import Role, User
from app.texts import Texts
def build_router(texts: Texts, admin_ids: frozenset[int]) -> Router:
def build_router(texts: Texts, access: AccessService) -> Router:
router = Router(name="common")
sections = section_by_button(texts)
section_text = {
Section.COMMIT: texts.section.commit,
Section.STATUS: texts.section.status,
Section.LOGS: texts.section.logs,
Section.ALERTS: texts.section.alerts,
Section.MEMBERS: texts.section.members,
}
def is_admin(message: Message) -> bool:
return message.from_user is not None and message.from_user.id in admin_ids
@router.message(CommandStart())
async def start(message: Message, state: FSMContext) -> None:
async def start(message: Message, command: CommandObject, state: FSMContext, member: User | None) -> None:
if member is None:
# Only an outsider with an invite token gets here (AccessMiddleware); a member's /start <x> is a plain start
if message.from_user is None or not command.args:
return
status = await access.redeem(command.args, tg_user(message.from_user))
if status is RedeemStatus.THROTTLED:
return # brute force: stay silent (SPEC A-6)
if status is RedeemStatus.REJECTED:
await message.answer(texts.start.invite_rejected)
return
member = await access.get_member(message.from_user.id)
if member is None:
return
await state.clear()
await message.answer(texts.start.welcome, reply_markup=main_menu(texts, is_admin=is_admin(message)))
await message.answer(texts.start.welcome, reply_markup=main_menu(texts, is_admin=member.role is Role.ADMIN))
@router.message(Command("menu"))
async def menu(message: Message, state: FSMContext) -> None:
async def menu(message: Message, state: FSMContext, member: User) -> None:
await state.clear()
await message.answer(texts.menu.placeholder, reply_markup=main_menu(texts, is_admin=is_admin(message)))
await message.answer(texts.menu.placeholder, reply_markup=main_menu(texts, is_admin=member.role is Role.ADMIN))
@router.message(Command("help"))
async def help_(message: Message) -> None:
await message.answer(texts.help.text)
@router.message(F.text.in_(sections))
# Sections with their own routers (members, commit) are matched there first
@router.message(F.text.in_({k for k, v in sections.items() if v in section_text}))
async def open_section(message: Message, state: FSMContext) -> None:
section = sections[message.text or ""]
if section is Section.MEMBERS and not is_admin(message):
return
await state.clear()
await message.answer(section_text[section], reply_markup=section_nav(texts))
await message.answer(section_text[sections[message.text or ""]], reply_markup=section_nav(texts))
@router.callback_query(Nav.filter())
async def navigate(query: CallbackQuery, callback_data: Nav, state: FSMContext) -> None:
+111
View File
@@ -0,0 +1,111 @@
"""«👥 Участники» for admins: list, invite by QR, revoke access (SPEC A-1, A-7)."""
from datetime import datetime
from enum import StrEnum
from aiogram import Bot, F, Router
from aiogram.filters.callback_data import CallbackData
from aiogram.fsm.context import FSMContext
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from app.access.qr import invite_link, qr_png
from app.access.service import AccessService
from app.bot.menu import Nav, NavAction
from app.db.models import Role, User
from app.texts import Texts
class MembersAction(StrEnum):
INVITE = "inv"
ASK_REVOKE = "ask"
REVOKE = "rev"
LIST = "list"
class MembersCb(CallbackData, prefix="mem"):
action: MembersAction
tg_id: int = 0
def _fmt(dt: datetime | None, never: str) -> str:
return dt.strftime("%d.%m %H:%M") if dt else never
def render_members(texts: Texts, members: list[User]) -> tuple[str, InlineKeyboardMarkup]:
t = texts.members
lines = [t.title.format(count=len(members)), ""]
for m in members:
lines.append(
t.line.format(
icon="👑" if m.role is Role.ADMIN else "👤",
name=m.full_name,
username=f" @{m.username}" if m.username else "",
joined=_fmt(m.joined_at, t.never),
seen=_fmt(m.last_seen_at, t.never),
)
)
kb = InlineKeyboardBuilder()
kb.button(text=t.invite, callback_data=MembersCb(action=MembersAction.INVITE))
for m in members:
if m.role is not Role.ADMIN:
kb.button(
text=t.revoke.format(name=m.full_name),
callback_data=MembersCb(action=MembersAction.ASK_REVOKE, tg_id=m.tg_id),
)
kb.button(text=texts.menu.back, callback_data=Nav(action=NavAction.BACK))
kb.adjust(1)
return "\n".join(lines), kb.as_markup()
def build_router(texts: Texts, access: AccessService) -> Router:
router = Router(name="members")
t = texts.members
# Every handler here is admin-only; a member who somehow has the button (e.g. was demoted) gets nothing.
def is_admin(member: User | None) -> bool:
return member is not None and member.role is Role.ADMIN
@router.message(F.text == texts.menu.members)
async def open_members(message: Message, state: FSMContext, member: User) -> None:
if not is_admin(member):
return
await state.clear()
text, kb = render_members(texts, await access.list_members())
await message.answer(text, reply_markup=kb)
@router.callback_query(MembersCb.filter())
async def on_action(query: CallbackQuery, callback_data: MembersCb, bot: Bot, member: User) -> None:
if not is_admin(member) or not isinstance(query.message, Message):
await query.answer()
return
msg = query.message
match callback_data.action:
case MembersAction.INVITE:
invite = await access.create_invite(member.tg_id)
me = await bot.me()
link = invite_link(me.username or "", invite.token)
caption = t.invite_caption.format(link=link, expires=invite.expires_at.strftime("%d.%m %H:%M"))
await msg.answer_photo(BufferedInputFile(qr_png(link), "invite.png"), caption=caption)
case MembersAction.ASK_REVOKE:
target = await access.get_member(callback_data.tg_id)
name = target.full_name if target else str(callback_data.tg_id)
kb = InlineKeyboardBuilder()
kb.button(
text=t.confirm_yes, callback_data=MembersCb(action=MembersAction.REVOKE, tg_id=callback_data.tg_id)
)
kb.button(text=t.confirm_no, callback_data=MembersCb(action=MembersAction.LIST))
await msg.edit_text(t.confirm.format(name=name), reply_markup=kb.as_markup())
case MembersAction.REVOKE:
target = await access.get_member(callback_data.tg_id)
name = target.full_name if target else str(callback_data.tg_id)
ok = await access.revoke(callback_data.tg_id)
text, kb_markup = render_members(texts, await access.list_members())
note = t.revoked.format(name=name) if ok else t.not_revoked
await msg.edit_text(f"{note}\n\n{text}", reply_markup=kb_markup)
case MembersAction.LIST:
text, kb_markup = render_members(texts, await access.list_members())
await msg.edit_text(text, reply_markup=kb_markup)
await query.answer()
return router
+47 -1
View File
@@ -1,7 +1,7 @@
"""Typed configuration from the environment / `.env` — one settings class per subsystem (ARCHITECTURE §2.11)."""
from pathlib import Path
from typing import Annotated
from typing import Annotated, Literal
from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
@@ -13,6 +13,8 @@ class TelegramSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="TELEGRAM_")
bot_token: SecretStr
# http://user:pass@host:port or socks5://… — the RF server has no route to api.telegram.org (same as meditron)
proxy_url: SecretStr | None = None
class AccessSettings(BaseSettings):
@@ -20,6 +22,9 @@ class AccessSettings(BaseSettings):
# Bootstrap admins (SPEC A-8). Comma-separated in the env: ADMIN_IDS=1,2
admin_ids: Annotated[frozenset[int], NoDecode] = frozenset()
invite_ttl_hours: float = Field(default=24.0, gt=0)
# More failed /start <token> per hour than this — silence (SPEC A-6)
max_failed_attempts: int = Field(default=5, ge=1)
@field_validator("admin_ids", mode="before")
@classmethod
@@ -29,6 +34,26 @@ class AccessSettings(BaseSettings):
return value
class LLMSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="LLM_")
# mock: deterministic offline answers (tests, demo without network); openai_compat: any OpenAI-style API (ADR-008)
provider: Literal["mock", "openai_compat"] = "mock"
base_url: str = ""
model: str = ""
api_key: SecretStr = SecretStr("")
timeout: float = Field(default=30.0, gt=0)
max_attempts: int = Field(default=3, ge=1)
temperature: float = Field(default=0.0, ge=0, le=2)
class DatabaseSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="DATABASE_")
# Compose sets a PostgreSQL URL; the default lets the bot run locally without Docker.
url: str = "sqlite+aiosqlite:///./tgit.db"
class SourceCraftSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="SOURCECRAFT_")
@@ -41,6 +66,24 @@ class SourceCraftSettings(BaseSettings):
max_attempts: int = Field(default=5, ge=1)
class GitSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="GIT_")
# The bot's own deploy key with write access to tgit-uploads only (#7)
uploads_url: str = "git@ssh.sourcecraft.dev:rogachevegorushka/tgit-uploads.git"
ssh_key_path: Path | None = None
# Alternative for containers: the private key itself, base64 — a mounted host file is unreadable for the
# container user (uid 10001 vs the owner's 0600). Written to a private temp file at startup.
ssh_key_b64: SecretStr | None = None
branch: str = "main"
# Web link to a commit in the uploads repo, e.g. https://sourcecraft.dev/{repo}/commit/{sha}; empty — no link
commit_url_template: str = ""
clone_dir: Path = Path("data/uploads")
# Browsing re-fetches at most this often; a push always fetches first (#9)
fetch_interval: float = Field(default=10.0, ge=0)
timeout: float = Field(default=60.0, gt=0)
class RuntimeSettings(BaseSettings):
model_config = _ENV
@@ -58,5 +101,8 @@ class Settings(BaseSettings):
telegram: TelegramSettings = Field(default_factory=TelegramSettings)
access: AccessSettings = Field(default_factory=AccessSettings)
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
llm: LLMSettings = Field(default_factory=LLMSettings)
sourcecraft: SourceCraftSettings = Field(default_factory=SourceCraftSettings)
git: GitSettings = Field(default_factory=GitSettings)
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
View File
+46
View File
@@ -0,0 +1,46 @@
"""Periodic cleanup of TTL data: expired drafts, old container snapshots and access attempts."""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Any, cast
from sqlalchemy import CursorResult, Delete, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import AccessAttempt, ContainerSnapshot, Draft, utcnow
from app.db.session import SessionMaker
log = logging.getLogger(__name__)
SNAPSHOT_TTL = timedelta(days=1)
ATTEMPT_TTL = timedelta(days=1)
async def _delete(session: AsyncSession, stmt: Delete) -> int:
# DML executes into a CursorResult; the generic annotation of execute() just doesn't say so
return cast(CursorResult[Any], await session.execute(stmt)).rowcount
async def cleanup_expired(sessions: SessionMaker, now: datetime) -> dict[str, int]:
async with sessions.begin() as session:
return {
"drafts": await _delete(session, delete(Draft).where(Draft.expires_at < now)),
"snapshots": await _delete(
session, delete(ContainerSnapshot).where(ContainerSnapshot.at < now - SNAPSHOT_TTL)
),
"access_attempts": await _delete(
session, delete(AccessAttempt).where(AccessAttempt.at < now - ATTEMPT_TTL)
),
}
async def cleanup_loop(sessions: SessionMaker, interval: float = 3600.0) -> None:
while True:
try:
removed = await cleanup_expired(sessions, utcnow())
if any(removed.values()):
log.info("cleanup: %s", removed)
except Exception: # a failed cleanup must not kill the bot; retry next round
log.exception("cleanup failed")
await asyncio.sleep(interval)
+52
View File
@@ -0,0 +1,52 @@
"""Apply Alembic migrations programmatically at startup — no separate step in the container.
`python -m app.db.migrate [revision]` upgrades the DB from DATABASE_URL (default: head).
"""
import asyncio
import sys
from alembic import command
from alembic.config import Config
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncEngine
from app.config import DatabaseSettings
from app.db.session import make_engine
SCRIPT_LOCATION = "app.db:migrations"
def alembic_config(connection: Connection | None = None) -> Config:
cfg = Config()
cfg.set_main_option("script_location", SCRIPT_LOCATION)
# env.py reuses this connection instead of opening its own (async engine, one transaction)
cfg.attributes["connection"] = connection
return cfg
async def upgrade(engine: AsyncEngine, revision: str = "head") -> None:
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: command.upgrade(alembic_config(sync_conn), revision))
async def downgrade(engine: AsyncEngine, revision: str) -> None:
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: command.downgrade(alembic_config(sync_conn), revision))
def main() -> None:
revision = sys.argv[1] if len(sys.argv) > 1 else "head"
async def _run() -> None:
engine = make_engine(DatabaseSettings().url)
try:
await upgrade(engine, revision)
finally:
await engine.dispose()
asyncio.run(_run())
if __name__ == "__main__":
main()
View File
+39
View File
@@ -0,0 +1,39 @@
"""Alembic environment. Runs on a connection handed over by app.db.migrate (or the CLI via alembic.ini)."""
import asyncio
from alembic import context
from sqlalchemy.engine import Connection
from app.config import DatabaseSettings
from app.db.models import Base
from app.db.session import make_engine
target_metadata = Base.metadata
def _run(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=connection.dialect.name == "sqlite", # SQLite can't ALTER most things in place
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def _run_cli() -> None:
engine = make_engine(DatabaseSettings().url)
try:
async with engine.begin() as conn:
await conn.run_sync(_run)
finally:
await engine.dispose()
connection = context.config.attributes.get("connection")
if connection is not None:
_run(connection)
else:
asyncio.run(_run_cli())
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,311 @@
"""initial schema
Revision ID: 0001
Revises:
Create Date: 2026-10-14 17:41:05.672912
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"access_attempts",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("tg_id", sa.BigInteger(), nullable=False),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ok", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_attempts")),
)
with op.batch_alter_table("access_attempts", schema=None) as batch_op:
batch_op.create_index("ix_access_attempts_tg_id_at", ["tg_id", "at"], unique=False)
op.create_table(
"alerts",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column(
"kind",
sa.Enum(
"crash",
"oom",
"restart_loop",
"unhealthy",
"overload",
"deploy_refused",
"cd_failed",
"disk_low",
"dependency_down",
"agent_attack",
name="alert_kind",
native_enum=False,
create_constraint=True,
length=20,
),
nullable=False,
),
sa.Column("service", sa.String(length=100), nullable=True),
sa.Column(
"payload", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False
),
sa.Column("count", sa.Integer(), nullable=False),
sa.Column("opened_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_alerts")),
)
with op.batch_alter_table("alerts", schema=None) as batch_op:
batch_op.create_index("ix_alerts_kind_service_resolved_at", ["kind", "service", "resolved_at"], unique=False)
op.create_table(
"container_snapshots",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("container", sa.String(length=100), nullable=False),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
sa.Column("restart_count", sa.Integer(), nullable=False),
sa.Column("cpu_pct", sa.Float(), nullable=False),
sa.Column("mem_pct", sa.Float(), nullable=False),
sa.Column("health", sa.String(length=20), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_container_snapshots")),
)
with op.batch_alter_table("container_snapshots", schema=None) as batch_op:
batch_op.create_index("ix_container_snapshots_container_at", ["container", "at"], unique=False)
op.create_table(
"fsm_state",
sa.Column("key", sa.String(length=255), nullable=False),
sa.Column("state", sa.String(length=255), nullable=True),
sa.Column(
"data", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False
),
sa.PrimaryKeyConstraint("key", name=op.f("pk_fsm_state")),
)
op.create_table(
"users",
sa.Column("tg_id", sa.BigInteger(), autoincrement=False, nullable=False),
sa.Column("username", sa.String(length=64), nullable=True),
sa.Column("full_name", sa.String(length=256), nullable=False),
sa.Column(
"role",
sa.Enum("member", "admin", name="role", native_enum=False, create_constraint=True, length=20),
nullable=False,
),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("invited_by", sa.BigInteger(), nullable=True),
sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["invited_by"], ["users.tg_id"], name=op.f("fk_users_invited_by_users")),
sa.PrimaryKeyConstraint("tg_id", name=op.f("pk_users")),
)
op.create_table(
"agent_requests",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("prompt", sa.Text(), nullable=False),
sa.Column(
"verdict",
sa.Enum("safe", "attack", "error", name="verdict", native_enum=False, create_constraint=True, length=20),
nullable=False,
),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column(
"log_query", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=True
),
sa.Column("tokens", sa.Integer(), nullable=False),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.tg_id"], name=op.f("fk_agent_requests_user_id_users")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_agent_requests")),
)
op.create_table(
"alert_deliveries",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("alert_id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("chat_id", sa.BigInteger(), nullable=False),
sa.Column("message_id", sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(
["alert_id"], ["alerts.id"], name=op.f("fk_alert_deliveries_alert_id_alerts"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_alert_deliveries")),
sa.UniqueConstraint("alert_id", "chat_id", name=op.f("uq_alert_deliveries_alert_id")),
)
op.create_table(
"drafts",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("base_sha", sa.String(length=40), nullable=False),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.tg_id"], name=op.f("fk_drafts_user_id_users"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drafts")),
sa.UniqueConstraint("user_id", name=op.f("uq_drafts_user_id")),
)
op.create_table(
"invites",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("created_by", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_by", sa.BigInteger(), nullable=True),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["created_by"], ["users.tg_id"], name=op.f("fk_invites_created_by_users")),
sa.ForeignKeyConstraint(["used_by"], ["users.tg_id"], name=op.f("fk_invites_used_by_users")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_invites")),
sa.UniqueConstraint("token_hash", name=op.f("uq_invites_token_hash")),
)
op.create_table(
"log_queries",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("container", sa.String(length=100), nullable=False),
sa.Column(
"mode",
sa.Enum("tail", "grep", "agent", name="log_mode", native_enum=False, create_constraint=True, length=20),
nullable=False,
),
sa.Column(
"query", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False
),
sa.Column("lines", sa.Integer(), nullable=False),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.tg_id"], name=op.f("fk_log_queries_user_id_users")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_log_queries")),
)
op.create_table(
"pushes",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("sha", sa.String(length=40), nullable=False),
sa.Column("message", sa.Text(), nullable=False),
sa.Column(
"paths", sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), nullable=False
),
sa.Column("chat_id", sa.BigInteger(), nullable=False),
sa.Column("message_id", sa.BigInteger(), nullable=True),
sa.Column(
"ci_status",
sa.Enum(
"pending",
"running",
"success",
"failed",
"timeout",
"skipped",
name="ci_status",
native_enum=False,
create_constraint=True,
length=20,
),
nullable=False,
),
sa.Column(
"cd_status",
sa.Enum(
"pending",
"running",
"success",
"failed",
"timeout",
"skipped",
name="cd_status",
native_enum=False,
create_constraint=True,
length=20,
),
nullable=False,
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.tg_id"], name=op.f("fk_pushes_user_id_users")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_pushes")),
sa.UniqueConstraint("sha", name=op.f("uq_pushes_sha")),
)
op.create_table(
"draft_changes",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("draft_id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("path", sa.String(length=500), nullable=False),
sa.Column(
"op",
sa.Enum("add", "modify", "delete", name="change_op", native_enum=False, create_constraint=True, length=20),
nullable=False,
),
sa.Column("content", sa.LargeBinary(), nullable=True),
sa.ForeignKeyConstraint(
["draft_id"], ["drafts.id"], name=op.f("fk_draft_changes_draft_id_drafts"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_draft_changes")),
sa.UniqueConstraint("draft_id", "path", name=op.f("uq_draft_changes_draft_id")),
)
op.create_table(
"pipeline_runs",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("push_id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column(
"stage",
sa.Enum("ci", "cd", name="stage", native_enum=False, create_constraint=True, length=20),
nullable=False,
),
sa.Column("run_slug", sa.String(length=50), nullable=False),
sa.Column(
"status",
sa.Enum(
"pending",
"running",
"success",
"failed",
"timeout",
"skipped",
name="run_status",
native_enum=False,
create_constraint=True,
length=20,
),
nullable=False,
),
sa.Column("failed_cube", sa.String(length=100), nullable=True),
sa.Column("url", sa.String(length=500), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["push_id"], ["pushes.id"], name=op.f("fk_pipeline_runs_push_id_pushes"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_pipeline_runs")),
sa.UniqueConstraint("push_id", "stage", name=op.f("uq_pipeline_runs_push_id")),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("pipeline_runs")
op.drop_table("draft_changes")
op.drop_table("pushes")
op.drop_table("log_queries")
op.drop_table("invites")
op.drop_table("drafts")
op.drop_table("alert_deliveries")
op.drop_table("agent_requests")
op.drop_table("users")
op.drop_table("fsm_state")
with op.batch_alter_table("container_snapshots", schema=None) as batch_op:
batch_op.drop_index("ix_container_snapshots_container_at")
op.drop_table("container_snapshots")
with op.batch_alter_table("alerts", schema=None) as batch_op:
batch_op.drop_index("ix_alerts_kind_service_resolved_at")
op.drop_table("alerts")
with op.batch_alter_table("access_attempts", schema=None) as batch_op:
batch_op.drop_index("ix_access_attempts_tg_id_at")
op.drop_table("access_attempts")
# ### end Alembic commands ###
+283
View File
@@ -0,0 +1,283 @@
"""Data model (ARCHITECTURE §2.10). One schema for PostgreSQL (prod) and SQLite (tests).
Enums are stored as VARCHAR + CHECK rather than native PG enums: portable and painless to migrate.
"""
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any, ClassVar
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
DateTime,
Enum,
Float,
ForeignKey,
Index,
Integer,
LargeBinary,
MetaData,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
# Stable constraint names so Alembic migrations stay deterministic across databases.
NAMING = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
# Surrogate keys: BIGINT on Postgres, INTEGER on SQLite (only INTEGER PRIMARY KEY autoincrements there).
Id = BigInteger().with_variant(Integer(), "sqlite")
Json = JSON().with_variant(JSONB(), "postgresql")
TgId = BigInteger
def utcnow() -> datetime:
return datetime.now(UTC)
def enum(cls: type[StrEnum], name: str) -> Enum:
return Enum(
cls,
name=name,
native_enum=False,
create_constraint=True, # SQLAlchemy 2 skips the CHECK for non-native enums unless asked
length=20,
values_callable=lambda e: [m.value for m in e],
)
class Role(StrEnum):
MEMBER = "member"
ADMIN = "admin"
class ChangeOp(StrEnum):
ADD = "add"
MODIFY = "modify"
DELETE = "delete"
class Stage(StrEnum):
CI = "ci"
CD = "cd"
class PipelineStatus(StrEnum):
PENDING = "pending"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
TIMEOUT = "timeout"
SKIPPED = "skipped"
class AlertKind(StrEnum):
CRASH = "crash"
OOM = "oom"
RESTART_LOOP = "restart_loop"
UNHEALTHY = "unhealthy"
OVERLOAD = "overload"
DEPLOY_REFUSED = "deploy_refused"
CD_FAILED = "cd_failed"
DISK_LOW = "disk_low"
DEPENDENCY_DOWN = "dependency_down"
AGENT_ATTACK = "agent_attack"
class LogMode(StrEnum):
TAIL = "tail"
GREP = "grep"
AGENT = "agent"
class Verdict(StrEnum):
SAFE = "safe"
ATTACK = "attack"
ERROR = "error"
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING)
type_annotation_map: ClassVar[dict[Any, Any]] = {
datetime: DateTime(timezone=True),
dict[str, Any]: Json,
list[str]: Json,
}
class User(Base):
__tablename__ = "users"
tg_id: Mapped[int] = mapped_column(TgId, primary_key=True, autoincrement=False)
username: Mapped[str | None] = mapped_column(String(64))
full_name: Mapped[str] = mapped_column(String(256))
role: Mapped[Role] = mapped_column(enum(Role, "role"), default=Role.MEMBER)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
invited_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id"))
joined_at: Mapped[datetime] = mapped_column(default=utcnow)
last_seen_at: Mapped[datetime | None]
class Invite(Base):
__tablename__ = "invites"
id: Mapped[int] = mapped_column(Id, primary_key=True)
# sha256 hex of the token; the token itself is never stored (SPEC A-3)
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
created_by: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id"))
created_at: Mapped[datetime] = mapped_column(default=utcnow)
expires_at: Mapped[datetime]
used_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id"))
used_at: Mapped[datetime | None]
class AccessAttempt(Base):
__tablename__ = "access_attempts"
__table_args__ = (Index("ix_access_attempts_tg_id_at", "tg_id", "at"),)
id: Mapped[int] = mapped_column(Id, primary_key=True)
tg_id: Mapped[int] = mapped_column(TgId)
at: Mapped[datetime] = mapped_column(default=utcnow)
ok: Mapped[bool] = mapped_column(Boolean)
class FsmState(Base):
__tablename__ = "fsm_state"
key: Mapped[str] = mapped_column(String(255), primary_key=True)
state: Mapped[str | None] = mapped_column(String(255))
data: Mapped[dict[str, Any]] = mapped_column(default=dict)
class Draft(Base):
__tablename__ = "drafts"
id: Mapped[int] = mapped_column(Id, primary_key=True)
user_id: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id", ondelete="CASCADE"), unique=True)
base_sha: Mapped[str] = mapped_column(String(40))
message: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(default=utcnow)
expires_at: Mapped[datetime]
changes: Mapped[list["DraftChange"]] = relationship(
back_populates="draft", cascade="all, delete-orphan", order_by="DraftChange.id"
)
class DraftChange(Base):
__tablename__ = "draft_changes"
__table_args__ = (UniqueConstraint("draft_id", "path"),)
id: Mapped[int] = mapped_column(Id, primary_key=True)
draft_id: Mapped[int] = mapped_column(Id, ForeignKey("drafts.id", ondelete="CASCADE"))
path: Mapped[str] = mapped_column(String(500))
op: Mapped[ChangeOp] = mapped_column(enum(ChangeOp, "change_op"))
content: Mapped[bytes | None] = mapped_column(LargeBinary)
draft: Mapped[Draft] = relationship(back_populates="changes")
class Push(Base):
__tablename__ = "pushes"
id: Mapped[int] = mapped_column(Id, primary_key=True)
user_id: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id"))
sha: Mapped[str] = mapped_column(String(40), unique=True)
message: Mapped[str] = mapped_column(Text)
paths: Mapped[list[str]] = mapped_column(default=list)
chat_id: Mapped[int] = mapped_column(TgId)
message_id: Mapped[int | None] = mapped_column(BigInteger)
ci_status: Mapped[PipelineStatus] = mapped_column(enum(PipelineStatus, "ci_status"), default=PipelineStatus.PENDING)
cd_status: Mapped[PipelineStatus] = mapped_column(enum(PipelineStatus, "cd_status"), default=PipelineStatus.PENDING)
created_at: Mapped[datetime] = mapped_column(default=utcnow)
class PipelineRun(Base):
__tablename__ = "pipeline_runs"
__table_args__ = (UniqueConstraint("push_id", "stage"),)
id: Mapped[int] = mapped_column(Id, primary_key=True)
push_id: Mapped[int] = mapped_column(Id, ForeignKey("pushes.id", ondelete="CASCADE"))
# CI and CD are tasks of one SourceCraft workflow (check-and-deploy), hence "stage", not "workflow"
stage: Mapped[Stage] = mapped_column(enum(Stage, "stage"))
run_slug: Mapped[str] = mapped_column(String(50))
status: Mapped[PipelineStatus] = mapped_column(enum(PipelineStatus, "run_status"))
failed_cube: Mapped[str | None] = mapped_column(String(100))
url: Mapped[str | None] = mapped_column(String(500))
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
class ContainerSnapshot(Base):
__tablename__ = "container_snapshots"
__table_args__ = (Index("ix_container_snapshots_container_at", "container", "at"),)
id: Mapped[int] = mapped_column(Id, primary_key=True)
container: Mapped[str] = mapped_column(String(100))
at: Mapped[datetime] = mapped_column(default=utcnow)
restart_count: Mapped[int] = mapped_column(Integer)
cpu_pct: Mapped[float] = mapped_column(Float)
mem_pct: Mapped[float] = mapped_column(Float)
health: Mapped[str | None] = mapped_column(String(20))
class Alert(Base):
__tablename__ = "alerts"
__table_args__ = (Index("ix_alerts_kind_service_resolved_at", "kind", "service", "resolved_at"),)
id: Mapped[int] = mapped_column(Id, primary_key=True)
kind: Mapped[AlertKind] = mapped_column(enum(AlertKind, "alert_kind"))
service: Mapped[str | None] = mapped_column(String(100))
payload: Mapped[dict[str, Any]] = mapped_column(default=dict)
count: Mapped[int] = mapped_column(Integer, default=1)
opened_at: Mapped[datetime] = mapped_column(default=utcnow)
last_at: Mapped[datetime] = mapped_column(default=utcnow)
resolved_at: Mapped[datetime | None]
deliveries: Mapped[list["AlertDelivery"]] = relationship(back_populates="alert", cascade="all, delete-orphan")
class AlertDelivery(Base):
__tablename__ = "alert_deliveries"
__table_args__ = (UniqueConstraint("alert_id", "chat_id"),)
id: Mapped[int] = mapped_column(Id, primary_key=True)
alert_id: Mapped[int] = mapped_column(Id, ForeignKey("alerts.id", ondelete="CASCADE"))
chat_id: Mapped[int] = mapped_column(TgId)
message_id: Mapped[int] = mapped_column(BigInteger)
alert: Mapped[Alert] = relationship(back_populates="deliveries")
class LogQuery(Base):
__tablename__ = "log_queries"
id: Mapped[int] = mapped_column(Id, primary_key=True)
user_id: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id"))
container: Mapped[str] = mapped_column(String(100))
mode: Mapped[LogMode] = mapped_column(enum(LogMode, "log_mode"))
query: Mapped[dict[str, Any]] = mapped_column(default=dict)
lines: Mapped[int] = mapped_column(Integer, default=0)
at: Mapped[datetime] = mapped_column(default=utcnow)
class AgentRequest(Base):
__tablename__ = "agent_requests"
id: Mapped[int] = mapped_column(Id, primary_key=True)
user_id: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id"))
prompt: Mapped[str] = mapped_column(Text)
verdict: Mapped[Verdict] = mapped_column(enum(Verdict, "verdict"))
reason: Mapped[str | None] = mapped_column(Text)
log_query: Mapped[dict[str, Any] | None]
tokens: Mapped[int] = mapped_column(Integer, default=0)
at: Mapped[datetime] = mapped_column(default=utcnow)
+35
View File
@@ -0,0 +1,35 @@
"""Async engine and session factory."""
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
SessionMaker = async_sessionmaker[AsyncSession]
def make_engine(url: str) -> AsyncEngine:
if url.startswith("sqlite"):
# In-memory SQLite lives per connection: share one connection so every session sees the same DB.
kwargs = (
{"poolclass": StaticPool, "connect_args": {"check_same_thread": False}}
if ":memory:" in url or url.endswith("://")
else {}
)
engine = create_async_engine(url, **kwargs)
event.listen(engine.sync_engine, "connect", _sqlite_foreign_keys)
return engine
return create_async_engine(url, pool_pre_ping=True)
def _sqlite_foreign_keys(dbapi_conn: object, _record: object) -> None:
# SQLite ignores FOREIGN KEY / ON DELETE CASCADE unless asked per connection
cursor = dbapi_conn.cursor() # type: ignore[attr-defined]
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
def make_sessionmaker(engine: AsyncEngine) -> SessionMaker:
return async_sessionmaker(engine, expire_on_commit=False)
__all__ = ["SessionMaker", "make_engine", "make_sessionmaker"]
View File
+28
View File
@@ -0,0 +1,28 @@
"""Debug CLI: `python -m app.llm.cli 'вопрос'` — one completion with the configured provider."""
import asyncio
import sys
from app.config import LLMSettings
from app.llm.factory import make_provider
from app.llm.provider import ChatMessage
async def ask(question: str) -> None:
settings = LLMSettings()
provider = make_provider(settings)
try:
result = await provider.complete([ChatMessage("user", question)])
finally:
await provider.aclose()
print(f"[{settings.provider} {settings.model or '-'}] tokens={result.tokens}\n{result.text}")
def main() -> None:
if len(sys.argv) < 2:
sys.exit("usage: python -m app.llm.cli 'question'")
asyncio.run(ask(" ".join(sys.argv[1:])))
if __name__ == "__main__":
main()
+10
View File
@@ -0,0 +1,10 @@
from app.config import LLMSettings
from app.llm.mock import MockProvider
from app.llm.openai_compat import OpenAICompatProvider
from app.llm.provider import LLMProvider
def make_provider(settings: LLMSettings) -> LLMProvider:
if settings.provider == "openai_compat":
return OpenAICompatProvider(settings)
return MockProvider()
+36
View File
@@ -0,0 +1,36 @@
"""Deterministic offline provider for tests and demos without network or tokens.
Rules are checked in order: the first whose `contains` is found in the last user message wins.
Without a match it echoes the message (or returns `{}` when JSON was requested).
"""
import json
from dataclasses import dataclass
from typing import Any
from app.llm.provider import ChatMessage, JsonSchema, LLMResult
@dataclass(frozen=True)
class MockRule:
contains: str
reply: str | dict[str, Any]
class MockProvider:
def __init__(self, rules: list[MockRule] | None = None) -> None:
self.rules = list(rules or [])
self.calls: list[tuple[list[ChatMessage], JsonSchema | None]] = []
async def aclose(self) -> None:
pass
async def complete(self, messages: list[ChatMessage], schema: JsonSchema | None = None) -> LLMResult:
self.calls.append((messages, schema))
last = next((m.content for m in reversed(messages) if m.role == "user"), "")
reply: str | dict[str, Any] | None = next((r.reply for r in self.rules if r.contains in last), None)
if reply is None:
reply = {} if schema is not None else f"mock: {last}"
text = json.dumps(reply, ensure_ascii=False) if isinstance(reply, dict) else reply
data = reply if isinstance(reply, dict) and schema is not None else None
return LLMResult(text=text, data=data, prompt_tokens=len(last.split()), completion_tokens=len(text.split()))
+84
View File
@@ -0,0 +1,84 @@
"""Any OpenAI-style `/chat/completions` endpoint: OpenAI, YandexGPT compatibility API, a local model server."""
import asyncio
import logging
from collections.abc import Awaitable, Callable
from typing import Any
import httpx
from app.config import LLMSettings
from app.llm.parsing import parse_json_object
from app.llm.provider import ChatMessage, JsonSchema, LLMError, LLMResult
log = logging.getLogger(__name__)
Sleep = Callable[[float], Awaitable[None]]
RETRY_STATUSES = {408, 409, 429, 500, 502, 503, 504}
class OpenAICompatProvider:
def __init__(
self,
settings: LLMSettings,
transport: httpx.AsyncBaseTransport | None = None,
sleep: Sleep = asyncio.sleep,
) -> None:
if not settings.base_url or not settings.model:
raise LLMError("LLM_BASE_URL and LLM_MODEL are required for the openai_compat provider")
self._settings = settings
self._sleep = sleep
self._http = httpx.AsyncClient(
base_url=settings.base_url.rstrip("/"),
headers={"Authorization": f"Bearer {settings.api_key.get_secret_value()}"},
timeout=settings.timeout,
transport=transport,
)
async def aclose(self) -> None:
await self._http.aclose()
async def complete(self, messages: list[ChatMessage], schema: JsonSchema | None = None) -> LLMResult:
body: dict[str, Any] = {
"model": self._settings.model,
"messages": [{"role": m.role, "content": m.content} for m in messages],
"temperature": self._settings.temperature,
}
if schema is not None:
body["response_format"] = {
"type": "json_schema",
"json_schema": {"name": schema.name, "schema": schema.schema, "strict": True},
}
data = await self._post(body)
try:
text = data["choices"][0]["message"]["content"] or ""
except (KeyError, IndexError, TypeError) as e:
raise LLMError(f"unexpected completion shape: {list(data)}") from e
usage = data.get("usage") or {}
return LLMResult(
text=text,
data=parse_json_object(text) if schema is not None else None,
prompt_tokens=int(usage.get("prompt_tokens", 0)),
completion_tokens=int(usage.get("completion_tokens", 0)),
raw=data,
)
async def _post(self, body: dict[str, Any]) -> dict[str, Any]:
attempts = self._settings.max_attempts
for attempt in range(1, attempts + 1):
try:
response = await self._http.post("/chat/completions", json=body)
except httpx.TransportError as e:
if attempt == attempts:
raise LLMError(f"LLM unreachable: {type(e).__name__}") from e
else:
if response.status_code < 400:
result: dict[str, Any] = response.json()
return result
if response.status_code not in RETRY_STATUSES or attempt == attempts:
# never echo the request: it may contain user text; the response body is the provider's error
raise LLMError(f"LLM HTTP {response.status_code}: {response.text[:200]}")
log.warning("llm request failed, retry %d/%d", attempt, attempts - 1)
await self._sleep(min(1.0 * 2 ** (attempt - 1), 8.0))
raise AssertionError("unreachable")
+18
View File
@@ -0,0 +1,18 @@
import json
import re
from typing import Any
_FENCE = re.compile(r"^```(?:json)?\s*(.*?)\s*```$", re.DOTALL)
def parse_json_object(text: str) -> dict[str, Any] | None:
"""Best effort: models sometimes wrap JSON in ``` fences even when asked not to."""
candidate = text.strip()
fenced = _FENCE.match(candidate)
if fenced:
candidate = fenced.group(1)
try:
value = json.loads(candidate)
except ValueError:
return None
return value if isinstance(value, dict) else None
+45
View File
@@ -0,0 +1,45 @@
"""LLM provider contract (ADR-008): the rest of the bot depends on this, never on a vendor SDK."""
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol
Role = Literal["system", "user", "assistant"]
@dataclass(frozen=True)
class ChatMessage:
role: Role
content: str
@dataclass(frozen=True)
class JsonSchema:
"""Ask the model for a JSON object of this shape (structured output)."""
name: str
schema: dict[str, Any]
@dataclass(frozen=True)
class LLMResult:
text: str
# Parsed object when a JSON schema was requested and the reply was valid JSON; None otherwise.
# The caller still validates it — a model can return well-formed JSON of the wrong shape.
data: dict[str, Any] | None = None
prompt_tokens: int = 0
completion_tokens: int = 0
raw: dict[str, Any] = field(default_factory=dict, repr=False)
@property
def tokens(self) -> int:
return self.prompt_tokens + self.completion_tokens
class LLMError(Exception):
pass
class LLMProvider(Protocol):
async def complete(self, messages: list[ChatMessage], schema: JsonSchema | None = None) -> LLMResult: ...
async def aclose(self) -> None: ...
+46 -19
View File
@@ -5,48 +5,75 @@ import contextlib
import logging
from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
from app import __version__
from app.access.service import AccessService
from app.bot.fsm import make_storage
from app.bot.middlewares.access import AccessMiddleware
from app.bot.routers.common import build_router
from app.bot.routers import commit, common, members
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.logs_setup import setup_logging
from app.repo.drafts import DraftService
from app.repo.git import UploadsRepo
from app.repo.publisher import Publisher
from app.texts import Texts, load_texts
log = logging.getLogger("tgit")
def build_dispatcher(settings: Settings, texts: Texts) -> Dispatcher:
admin_ids = settings.access.admin_ids
async def is_member(tg_id: int) -> bool:
return tg_id in admin_ids
dp = Dispatcher(storage=make_storage())
access = AccessMiddleware(is_member, texts)
def build_dispatcher(
texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo, commit_url_template: str = ""
) -> 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(access)
dp.callback_query.outer_middleware(access)
dp.include_router(build_router(texts, admin_ids))
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)
)
dp.include_router(common.build_router(texts, access))
return dp
async def run(settings: Settings) -> None:
texts = load_texts(settings.runtime.config_dir)
dp = build_dispatcher(settings, texts)
engine = make_engine(settings.database.url)
await upgrade(engine)
sessions = make_sessionmaker(engine)
access = AccessService(sessions, settings.access)
await access.bootstrap_admins()
dp = build_dispatcher(texts, sessions, access, UploadsRepo(settings.git), settings.git.commit_url_template)
rt = settings.runtime
beat = asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval))
bot = Bot(settings.telegram.bot_token.get_secret_value())
log.info("tgit %s starting, %d bootstrap admin(s)", __version__, len(settings.access.admin_ids))
background = [
asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)),
asyncio.create_task(cleanup_loop(sessions)),
]
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)
log.info(
"tgit %s starting, %d bootstrap admin(s), telegram %s",
__version__,
len(settings.access.admin_ids),
"via proxy" if proxy else "direct",
)
try:
await dp.start_polling(bot, handle_signals=True)
finally:
beat.cancel()
with contextlib.suppress(asyncio.CancelledError):
await beat
for task in background:
task.cancel()
for task in background:
with contextlib.suppress(asyncio.CancelledError):
await task
await bot.session.close()
await engine.dispose()
log.info("tgit stopped")
View File
+107
View File
@@ -0,0 +1,107 @@
"""A member's draft: pending add/modify/delete changes until Push (SPEC C-14, C-15). One draft per member."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db.models import ChangeOp, Draft, DraftChange, utcnow
from app.db.session import SessionMaker
MAX_CHANGES = 50
class DraftFull(Exception):
pass
@dataclass(frozen=True)
class Change:
path: str
op: ChangeOp
content: bytes | None
@dataclass(frozen=True)
class DraftView:
base_sha: str
message: str | None
changes: list[Change]
def change_for(self, path: str) -> Change | None:
return next((c for c in self.changes if c.path == path), None)
class DraftService:
def __init__(
self, sessions: SessionMaker, ttl: timedelta = timedelta(hours=24), clock: Callable[[], datetime] = utcnow
):
self._sessions = sessions
self._ttl = ttl
self._clock = clock
async def get(self, user_id: int) -> DraftView | None:
async with self._sessions() as s:
draft = await self._load(s, user_id)
if draft is None:
return None
return DraftView(
base_sha=draft.base_sha,
message=draft.message,
changes=[Change(c.path, c.op, c.content) for c in draft.changes],
)
async def put(self, user_id: int, base_sha: str, path: str, op: ChangeOp, content: bytes | None) -> None:
"""Record a change. Deleting a file that only exists in the draft just drops it from the draft."""
async with self._sessions.begin() as s:
draft = await self._load(s, user_id)
if draft is None:
draft = Draft(
user_id=user_id, base_sha=base_sha, created_at=self._clock(), expires_at=self._clock() + self._ttl
)
s.add(draft)
existing = next((c for c in draft.changes if c.path == path), None)
if existing is not None:
if op is ChangeOp.DELETE and existing.op is ChangeOp.ADD:
draft.changes.remove(existing)
return
# editing a file added in this draft keeps it an "add"
existing.op = ChangeOp.ADD if existing.op is ChangeOp.ADD and op is ChangeOp.MODIFY else op
existing.content = content
else:
if len(draft.changes) >= MAX_CHANGES:
raise DraftFull
draft.changes.append(DraftChange(path=path, op=op, content=content))
draft.expires_at = self._clock() + self._ttl
async def drop(self, user_id: int, path: str) -> None:
async with self._sessions.begin() as s:
draft = await self._load(s, user_id)
if draft is None:
return
draft.changes = [c for c in draft.changes if c.path != path]
async def set_message(self, user_id: int, message: str) -> None:
async with self._sessions.begin() as s:
draft = await self._load(s, user_id)
if draft is not None:
draft.message = message
async def clear(self, user_id: int) -> None:
async with self._sessions.begin() as s:
await s.execute(delete(Draft).where(Draft.user_id == user_id))
async def _load(self, s: AsyncSession, user_id: int) -> Draft | None:
draft = await s.scalar(select(Draft).where(Draft.user_id == user_id).options(selectinload(Draft.changes)))
if draft is not None and _aware(draft.expires_at) <= self._clock():
await s.delete(draft)
await s.flush()
return None
return draft
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
+168
View File
@@ -0,0 +1,168 @@
"""Local clone of the uploads repo and read-only queries against `origin/<branch>` (ARCHITECTURE §2.3).
git runs as a subprocess with an argument list — never through a shell — and with the bot's own deploy key.
"""
import asyncio
import base64
import logging
import os
import tempfile
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Literal
from app.config import GitSettings
log = logging.getLogger(__name__)
EntryKind = Literal["dir", "file"]
class GitError(Exception):
pass
@dataclass(frozen=True)
class Entry:
path: str # repo-relative, "/"-separated
kind: EntryKind
size: int # bytes; 0 for directories
@property
def name(self) -> str:
return self.path.rsplit("/", 1)[-1]
@dataclass(frozen=True)
class CommitInfo:
sha: str
author: str
date: datetime
subject: str
class UploadsRepo:
def __init__(self, settings: GitSettings) -> None:
self._s = settings
# absolute: git clone runs from the parent dir, a relative path would land one level deeper
self._dir = settings.clone_dir.resolve()
self._lock = asyncio.Lock()
self._fetched_at: float | None = None
self._key_path = self._materialize_key()
@property
def branch(self) -> str:
return self._s.branch
@property
def ref(self) -> str:
return f"origin/{self._s.branch}"
@property
def path(self) -> Path:
return self._dir
def _materialize_key(self) -> Path | None:
if self._s.ssh_key_b64 is None:
return self._s.ssh_key_path
key = base64.b64decode(self._s.ssh_key_b64.get_secret_value())
fd, name = tempfile.mkstemp(prefix="tgit-ssh-") # created 0600, owned by the bot's user
with os.fdopen(fd, "wb") as f:
f.write(key if key.endswith(b"\n") else key + b"\n")
return Path(name)
def _env(self) -> dict[str, str]:
env = {k: v for k, v in os.environ.items() if k in ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR")}
env["GIT_TERMINAL_PROMPT"] = "0"
if self._key_path is not None:
known_hosts = self._dir.parent / "known_hosts"
env["GIT_SSH_COMMAND"] = (
# -F /dev/null: ignore the host's ~/.ssh/config — otherwise a personal key configured there for the
# same host gets offered too and silently masks a broken deploy key (happened in #7).
f"ssh -F /dev/null -i {self._key_path} -o IdentitiesOnly=yes -o BatchMode=yes "
f"-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile={known_hosts}"
)
return env
async def git(self, *args: str, cwd: Path | None = None) -> bytes:
proc = await asyncio.create_subprocess_exec(
"git",
*args,
cwd=cwd or self._dir,
env=self._env(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=self._s.timeout)
except TimeoutError:
proc.kill()
await proc.wait()
raise GitError(f"git {args[0]}: timeout") from None
if proc.returncode != 0:
raise GitError(f"git {args[0]}: {err.decode(errors='replace').strip()[-500:]}")
return out
async def ensure(self) -> None:
"""Clone on first use; afterwards just make sure the clone is there."""
async with self._lock:
if (self._dir / ".git").is_dir():
return
self._dir.parent.mkdir(parents=True, exist_ok=True)
log.info("cloning uploads repo into %s", self._dir)
await self.git(
"clone", "--branch", self._s.branch, self._s.uploads_url, str(self._dir), cwd=self._dir.parent
)
self._fetched_at = time.monotonic()
async def refresh(self, force: bool = False) -> None:
"""git fetch, but not more often than fetch_interval unless forced."""
await self.ensure()
async with self._lock:
now = time.monotonic()
if not force and self._fetched_at is not None and now - self._fetched_at < self._s.fetch_interval:
return
await self.git("fetch", "--prune", "origin")
self._fetched_at = now
async def head(self) -> str:
return (await self.git("rev-parse", self.ref)).decode().strip()
async def list_dir(self, path: str = "") -> list[Entry]:
"""Directories first, then files, each alphabetically."""
spec = f"{self.ref}:{path}" if path else self.ref
try:
out = await self.git("ls-tree", "-z", "-l", spec)
except GitError as e:
raise GitError(f"no such directory: /{path}") from e
entries: list[Entry] = []
for record in out.split(b"\0"):
if not record:
continue
meta, name = record.split(b"\t", 1)
_mode, kind, _obj, size = meta.split()
full = f"{path}/{name.decode()}" if path else name.decode()
if kind == b"tree":
entries.append(Entry(full, "dir", 0))
elif kind == b"blob":
entries.append(Entry(full, "file", int(size)))
return sorted(entries, key=lambda e: (e.kind != "dir", e.name.lower()))
async def read(self, path: str) -> bytes:
return await self.git("cat-file", "blob", f"{self.ref}:{path}")
async def size(self, path: str) -> int | None:
try:
return int((await self.git("cat-file", "-s", f"{self.ref}:{path}")).decode().strip())
except GitError:
return None
async def last_commit(self, path: str) -> CommitInfo | None:
out = await self.git("log", "-1", "--format=%H%x00%an%x00%aI%x00%s", self.ref, "--", path)
if not out.strip():
return None
sha, author, date, subject = out.decode().rstrip("\n").split("\0", 3)
return CommitInfo(sha=sha, author=author, date=datetime.fromisoformat(date), subject=subject)
+171
View File
@@ -0,0 +1,171 @@
"""Turns a member's draft into one commit on the uploads repo's main branch (SPEC C-20…C-25, ARCHITECTURE §2.3).
Pushes are serialized with a lock: two members pushing at once never fight over the shared working tree.
"""
import asyncio
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import StrEnum
from pathlib import Path
from sqlalchemy import func, select
from app.db.models import ChangeOp, Push, User, utcnow
from app.db.session import SessionMaker
from app.repo.drafts import DraftView
from app.repo.git import GitError, UploadsRepo
log = logging.getLogger(__name__)
BOT_NAME = "tgit-bot"
BOT_EMAIL = "tgit-bot@users.noreply.tgit"
PUSHES_PER_HOUR = 20
PUSH_ATTEMPTS = 3
class PushStatus(StrEnum):
OK = "ok"
CONFLICT = "conflict" # someone changed the same files in main since the draft began
EMPTY = "empty" # the draft matches main already
LIMIT = "limit"
FAILED = "failed"
@dataclass(frozen=True)
class PushResult:
status: PushStatus
sha: str | None = None
conflicts: list[str] = field(default_factory=list)
class Publisher:
def __init__(
self,
repo: UploadsRepo,
sessions: SessionMaker,
clock: Callable[[], datetime] = utcnow,
before_push: Callable[[], Awaitable[None]] | None = None, # test hook: simulate a concurrent push
) -> None:
self._repo = repo
self._sessions = sessions
self._clock = clock
self._before_push = before_push
self._lock = asyncio.Lock()
async def push(self, member: User, draft: DraftView, message: str, *, overwrite: bool = False) -> PushResult:
if await self._pushes_last_hour(member.tg_id) >= PUSHES_PER_HOUR:
return PushResult(PushStatus.LIMIT)
async with self._lock:
try:
return await self._push(member, draft, message, overwrite)
except GitError:
log.exception("push failed")
await self._reset()
return PushResult(PushStatus.FAILED)
async def record(
self, member: User, result: PushResult, message: str, draft: DraftView, chat_id: int, message_id: int
) -> None:
assert result.sha is not None
async with self._sessions.begin() as s:
s.add(
Push(
user_id=member.tg_id,
sha=result.sha,
message=message,
paths=[c.path for c in draft.changes],
chat_id=chat_id,
message_id=message_id,
created_at=self._clock(),
)
)
async def _push(self, member: User, draft: DraftView, message: str, overwrite: bool) -> PushResult:
repo = self._repo
await repo.refresh(force=True)
if not overwrite:
conflicts = await self._conflicts(draft)
if conflicts:
return PushResult(PushStatus.CONFLICT, conflicts=conflicts)
await self._reset()
self._apply(draft)
await repo.git("add", "-A")
try:
await repo.git("diff", "--cached", "--quiet")
return PushResult(PushStatus.EMPTY) # exit 0: nothing staged
except GitError:
pass # exit 1: there are changes
handle = f"@{member.username}" if member.username else member.full_name
full_message = f"{message.strip()}\n\nTelegram-User: {handle} ({member.tg_id})"
await repo.git(
"-c", f"user.name={BOT_NAME}", "-c", f"user.email={BOT_EMAIL}", "commit", "-q", "-m", full_message
)
branch = repo.branch
for attempt in range(1, PUSH_ATTEMPTS + 1):
if self._before_push is not None:
await self._before_push()
try:
await repo.git("push", "-q", "origin", f"HEAD:{branch}")
break
except GitError:
if attempt == PUSH_ATTEMPTS:
raise
log.info("push rejected, rebasing (attempt %d)", attempt)
await repo.git("fetch", "-q", "origin")
try:
await repo.git(
"-c", f"user.name={BOT_NAME}", "-c", f"user.email={BOT_EMAIL}", "rebase", "-q", repo.ref
)
except GitError:
await repo.git("rebase", "--abort")
return PushResult(PushStatus.CONFLICT, conflicts=await self._conflicts(draft))
sha = (await repo.git("rev-parse", "HEAD")).decode().strip()
return PushResult(PushStatus.OK, sha=sha)
async def _conflicts(self, draft: DraftView) -> list[str]:
try:
out = await self._repo.git("diff", "--name-only", draft.base_sha, self._repo.ref)
except GitError:
return [c.path for c in draft.changes] # base unknown (e.g. history rewritten): be safe
changed = set(out.decode().splitlines())
return [c.path for c in draft.changes if c.path in changed]
async def _reset(self) -> None:
repo = self._repo
await repo.git("checkout", "-q", "-B", repo.branch, repo.ref)
await repo.git("reset", "-q", "--hard", repo.ref)
await repo.git("clean", "-q", "-fdx")
def _apply(self, draft: DraftView) -> None:
root = self._repo.path.resolve()
for change in draft.changes:
target = (root / change.path).resolve()
if root not in target.parents: # validated earlier; this is the last line of defence
raise GitError(f"path escapes the repo: {change.path}")
if change.op is ChangeOp.DELETE:
target.unlink(missing_ok=True)
_prune_empty_dirs(target.parent, root)
else:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(change.content or b"")
async def _pushes_last_hour(self, user_id: int) -> int:
async with self._sessions() as s:
count = await s.scalar(
select(func.count())
.select_from(Push)
.where(Push.user_id == user_id, Push.created_at > self._clock() - timedelta(hours=1))
)
return count or 0
def _prune_empty_dirs(directory: Path, root: Path) -> None:
while directory != root and directory.is_dir() and not any(directory.iterdir()):
directory.rmdir()
directory = directory.parent
+83
View File
@@ -0,0 +1,83 @@
"""Checks for names and file contents before anything goes into a draft (SPEC C-10, C-12, C-16). No code runs here."""
import ast
import difflib
import re
from dataclasses import dataclass
MAX_FILE_BYTES = 100 * 1024
NAME = re.compile(r"^[A-Za-z0-9._-]{1,100}$")
# Bot users can't touch the CI of the uploads repo or its helper scripts (SPEC C-16, ADR-014)
PROTECTED_PREFIXES = (".sourcecraft/", ".tgit/", ".git/")
PROTECTED_FILES = {"ruff.toml"}
_FENCE = re.compile(r"^\s*```[\w+-]*[ \t]*\n(.*?)\n?```\s*$", re.DOTALL)
class ValidationError(ValueError):
pass
@dataclass(frozen=True)
class SyntaxIssue:
line: int
message: str
def check_name(name: str) -> str:
name = name.strip()
if not NAME.match(name) or name in {".", ".."}:
raise ValidationError("name")
return name
def is_protected(path: str) -> bool:
return path in PROTECTED_FILES or any(path.startswith(p) or path + "/" == p for p in PROTECTED_PREFIXES)
def join(directory: str, name: str) -> str:
return f"{directory}/{name}" if directory else name
def strip_fence(text: str) -> str:
"""Code pasted as a Telegram message often comes wrapped in ``` — keep only what's inside."""
match = _FENCE.match(text)
return match.group(1) if match else text
def check_content(data: bytes) -> str:
"""Size, encoding, NUL bytes. Returns the decoded text."""
if len(data) > MAX_FILE_BYTES:
raise ValidationError("too_big")
if b"\0" in data:
raise ValidationError("binary")
try:
return data.decode("utf-8")
except UnicodeDecodeError:
raise ValidationError("encoding") from None
def python_syntax(path: str, text: str) -> SyntaxIssue | None:
if not path.endswith(".py"):
return None
try:
ast.parse(text, filename=path)
except SyntaxError as e:
return SyntaxIssue(line=e.lineno or 0, message=e.msg)
return None
def diff_stat(old: str, new: str) -> tuple[int, int]:
added = removed = 0
for line in difflib.ndiff(old.splitlines(), new.splitlines()):
if line.startswith("+ "):
added += 1
elif line.startswith("- "):
removed += 1
return added, removed
def check_message(message: str) -> str:
message = message.strip()
if not 1 <= len(message) <= 200 or len(message.splitlines()[0]) > 72:
raise ValidationError("message")
return message
+87
View File
@@ -24,6 +24,7 @@ class MenuTexts(_Strict):
class StartTexts(_Strict):
welcome: str
no_access: str
invite_rejected: str
class HelpTexts(_Strict):
@@ -39,11 +40,97 @@ class SectionTexts(_Strict):
cancelled: str
class MembersTexts(_Strict):
title: str
line: str
never: str
invite: str
revoke: str
confirm: str
confirm_yes: str
confirm_no: str
revoked: str
not_revoked: str
invite_caption: str
class CommitTexts(_Strict):
title: str
empty: str
page: str
up: str
prev: str
next: str
dir_button: str
file_button: str
new_file: str
new_dir: str
file_card: str
last_commit: str
no_commits: str
view: str
replace: str
delete: str
to_dir: str
soon: str
unavailable: str
sent_as_file: str
ask_file_name: str
ask_dir_name: str
bad_name: str
exists: str
protected: str
ask_content: str
too_big: str
binary: str
encoding: str
syntax: str
fix: str
force: str
added: str
modified: str
deleted: str
draft_button: str
draft_title: str
draft_empty: str
unstage: str
draft_full: str
in_draft_new: str
in_draft_modified: str
in_draft_deleted: str
mark_new: str
mark_modified: str
mark_deleted: str
op_add: str
op_modify: str
op_delete: str
message_button: str
message_current: str
message_missing: str
ask_message: str
bad_message: str
push_button: str
commit_button: str
edit_message: str
push_confirm: str
push_go: str
pushing: str
pushed: str
ci_pending: str
conflict: str
overwrite: str
push_empty: str
push_limit: str
push_failed: str
class Texts(_Strict):
menu: MenuTexts
start: StartTexts
help: HelpTexts
section: SectionTexts
members: MembersTexts
commit: CommitTexts
def load_texts(config_dir: Path) -> Texts:
+100
View File
@@ -19,6 +19,7 @@ start:
📜 Логи — хвост, свой grep или вопрос агенту
🚨 Алерты — падения, перегрузка, список
no_access: "Доступ к боту — только по приглашению. Попросите QR-код у администратора."
invite_rejected: "Приглашение недействительно: истекло, уже использовано или ссылка неверная. Попросите новое у администратора."
help:
text: |-
@@ -34,3 +35,102 @@ section:
alerts: "🚨 Алерты\n\nРаздел в разработке."
members: "👥 Участники\n\nРаздел в разработке."
cancelled: "Отменено. Главное меню — кнопками внизу."
members:
title: "👥 Участники: {count}"
line: "{icon} {name}{username} · с {joined} · был(а) {seen}"
never: "—"
invite: "➕ Пригласить"
revoke: "🚫 {name}"
confirm: "Отозвать доступ у {name}? Его черновик удалится, кнопки перестанут работать."
confirm_yes: "Да, отозвать"
confirm_no: "Нет"
revoked: "Доступ отозван: {name}."
not_revoked: "Нельзя отозвать: администратор или уже без доступа."
invite_caption: |-
Приглашение в tgit — отсканируйте QR или откройте ссылку:
{link}
Одноразовое, действует до {expires} UTC.
commit:
title: "📝 Коммит · /{path}"
empty: "Папка пуста."
page: "Стр. {page} из {pages}"
up: "⬆️ Вверх"
prev: "◀️"
next: "▶️"
dir_button: "📁 {name}"
file_button: "📄 {name} · {size}"
new_file: "➕ Новый файл"
new_dir: "📁 Новая папка"
file_card: |-
📄 /{path}
Размер: {size}
last_commit: |-
Последний коммит: {sha} · {author} · {date}
{subject}
no_commits: "Коммитов по файлу нет."
view: "👁 Показать"
replace: "✏️ Заменить"
delete: "🗑 Удалить"
to_dir: "⬅️ К папке"
soon: "Будет в следующей версии."
unavailable: "Репозиторий загрузок недоступен, попробуйте через минуту."
sent_as_file: "Файл большой или бинарный — отправил документом."
ask_file_name: "Имя нового файла в /{dir} (латиница, цифры, «.», «_», «-»):"
ask_dir_name: "Имя новой папки в /{dir}:"
bad_name: "Недопустимое имя: латиница, цифры, «.», «_», «-», до 100 символов. Пришлите другое."
exists: "Такое имя уже есть в этой папке. Пришлите другое."
protected: "Это служебный путь репозитория — из бота его менять нельзя."
ask_content: "Пришлите содержимое /{path} — текстом сообщения или файлом (до 100 КБ)."
too_big: "Больше 100 КБ — не принимаю."
binary: "Похоже на бинарный файл — принимаю только текст."
encoding: "Файл не в кодировке UTF-8."
syntax: "⚠️ Синтаксическая ошибка, строка {line}: {message}"
fix: "✏️ Исправить"
force: "Всё равно добавить"
added: "🆕 /{path} — добавлен в черновик."
modified: "✏️ /{path} — в черновике: +{added} −{removed}."
deleted: "🗑 /{path} — в черновике на удаление."
draft_button: "🧾 Черновик · {count}"
draft_title: "🧾 Черновик · изменений: {count}"
draft_empty: "Черновик пуст."
unstage: "↩️ убрать {name}"
draft_full: "В черновике уже 50 изменений — запушьте или уберите лишнее."
in_draft_new: "В черновике: новый файл."
in_draft_modified: "В черновике: изменён."
in_draft_deleted: "В черновике: удалён."
mark_new: "🆕 "
mark_modified: "✏️ "
mark_deleted: "🗑 "
op_add: "A"
op_modify: "M"
op_delete: "D"
message_button: "💬 Сообщение коммита"
message_current: "💬 {message}"
message_missing: "💬 Сообщение коммита не задано."
ask_message: "Сообщение коммита: до 200 символов, первая строка — до 72."
bad_message: "Сообщение: 1–200 символов, первая строка — до 72. Пришлите другое."
push_button: "🚀 Push"
commit_button: "🚀 Закоммитить · {count}"
edit_message: "✏️ Изменить сообщение"
push_confirm: |-
Запушить в main?
{summary}
💬 {message}
push_go: "✅ Запушить"
pushing: "⏳ Пушу…"
pushed: "✅ Запушено: {sha} — {message}"
ci_pending: "⏳ CI…"
conflict: |-
⚠️ Пока вы готовили черновик, в main изменились те же файлы:
{paths}
Перезаписать их вашей версией?
overwrite: "Перезаписать"
push_empty: "В main уже всё так же — коммитить нечего. Черновик очищен."
push_limit: "Лимит — 20 пушей в час. Попробуйте позже."
push_failed: "Не удалось запушить: репозиторий недоступен. Черновик сохранён — попробуйте позже."
+5 -1
View File
@@ -5,11 +5,14 @@ services:
env_file: .env
environment:
DATABASE_URL: postgresql+asyncpg://tgit:${POSTGRES_PASSWORD:-tgit}@postgres:5432/tgit
GIT_CLONE_DIR: /data/uploads
volumes:
- uploads:/data
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
mem_limit: 256m
mem_limit: 384m # ~190 MB idle; git clone/push and aiogram spikes need headroom
labels:
tgit.managed: "true"
logging:
@@ -37,3 +40,4 @@ services:
volumes:
pgdata:
uploads:
+1 -1
View File
@@ -138,7 +138,7 @@ SQLAlchemy 2 (async) + Alembic. PostgreSQL в Compose; в тестах — SQLit
| `drafts` | `id`, `user_id` UNIQUE, `base_sha`, `message`, `created_at`, `expires_at` |
| `draft_changes` | `id`, `draft_id`, `path`, `op` (`add`/`modify`/`delete`), `content` BYTEA |
| `pushes` | `id`, `user_id`, `sha`, `message`, `paths` JSON, `chat_id`, `message_id`, `ci_status`, `cd_status`, `created_at` |
| `pipeline_runs` | `id`, `push_id`, `workflow` (`check`/`deploy`), `run_slug`, `status`, `failed_cube`, `url`, `updated_at` |
| `pipeline_runs` | `id`, `push_id`, `stage` (`ci`/`cd` — задачи одного workflow `check-and-deploy`), `run_slug`, `status`, `failed_cube`, `url`, `updated_at` |
| `container_snapshots` | `container`, `at`, `restart_count`, `cpu_pct`, `mem_pct`, `health` — для проверок «за 5 мин» (TTL 1 сутки) |
| `alerts` | `id`, `kind`, `service`, `payload` JSON, `count`, `opened_at`, `last_at`, `resolved_at` |
| `alert_deliveries` | `alert_id`, `chat_id`, `message_id` |
+8
View File
@@ -129,3 +129,11 @@
**Решение:** первый куб задачи `check` в `tgit-uploads` — `meta`: `echo "TGIT_COMMIT_SHA=$SOURCECRAFT_COMMIT_SHA"`. Бот читает его лог (`SourceCraftClient.run_commit_sha`) и сопоставляет прогон с пушем.
**Альтернативы:** запускать CI из бота вручную (`POST /cicd/runs` с `head.commit`) — номер прогона известен сразу, но push-триггер пришлось бы убрать, и ручные пуши в репозиторий остались бы без CI; сопоставление по времени — ломается при параллельных пушах.
**Последствия:** +1 короткий куб на прогон; куб `meta` нельзя удалять или переименовывать (он в `.sourcecraft/`, который бот защищает от правок); прогоны старше этого решения sha не имеют.
## ADR-015: CI/CD бота — на Gitea Actions (как в Vobot)
**Дата:** 2026-10-16 · **Статус:** принято (меняет ADR-010 в части CI/CD бота)
**Контекст:** SourceCraft отложен: ключ бота там не принимается, триггеры зависят от основной ветки, раннеры с очередью до минут. Гейт и деплой нужны сейчас.
**Решение:** CI (`.gitea/workflows/ci.yml`: ruff, mypy, миграции на Postgres, pytest) и CD (`.gitea/workflows/deploy.yml` → `infra/deploy/deploy.sh`) — на Gitea Actions, как у Vobot и meditron, на общих раннерах Gitea. Деплой — на хост Vobot, `dev`/`prod` по веткам, через пользователя `tgit-deploy`. Репозиторий загрузок на сервере — временно `HSE/tgit-uploads` в Gitea.
**Альтернативы:** ждать SourceCraft — блокирует проверку и демо.
**Последствия:** `.sourcecraft/ci.yaml` бота остаётся для будущего возврата; при возврате к SourceCraft меняются `GIT_UPLOADS_URL` и ключ, код не трогаем. На RF-сервере Telegram — только через прокси (`TELEGRAM_PROXY_URL`).
+45
View File
@@ -0,0 +1,45 @@
# Деплой бота
CD — `.gitea/workflows/deploy.yml` (Gitea Actions, ADR-015): push в `dev` → окружение **dev**, push в `main` → **prod**.
Под капотом — `infra/deploy/deploy.sh`, тот же скрипт работает руками и для любого Docker-хоста.
## Сервер (хост Vobot, 72.56.247.166)
| Что | Где |
|---|---|
| Пользователь | `tgit-deploy` (группа `docker`), ключ CI в `~/.ssh/authorized_keys` |
| Окружение | `/srv/tgit/<env>/` — `.env` (только здесь, 600), `app/` (текущий релиз), `app.old/` (предыдущий), `REVISION` |
| Контейнеры | Compose-проект `tgit-<env>`: `tgit-<env>-bot-1`, `tgit-<env>-postgres-1`; volumes `pgdata`, `uploads` |
| Telegram | с сервера напрямую недоступен → `TELEGRAM_PROXY_URL` (тот же прокси, что у meditron) |
| Репозиторий загрузок | временно `ssh://git@gitea.24studbank.ru:2222/HSE/tgit-uploads.git`, deploy key бота с записью |
Секреты репозитория в Gitea (Settings → Actions → Secrets): `DEPLOY_SSH_KEY` (приватный ключ `tgit-cd@gitea`), `DEPLOY_HOST` (`tgit-deploy@72.56.247.166`).
## Руками
```bash
DEPLOY_SSH_OPTS="-F /dev/null -i ~/.ssh/tgit_ci_deploy -o IdentitiesOnly=yes" infra/deploy/deploy.sh dev HEAD
```
Другой сервер: `DEPLOY_HOST=user@host`, на нём — Docker, пользователь в группе `docker`, `/srv/tgit/<env>/.env`.
## Проверить
```bash
ssh tgit-deploy@72.56.247.166 'cat /srv/tgit/dev/REVISION; docker ps --filter name=tgit-dev; docker logs --tail 20 tgit-dev-bot-1'
```
## Откат
Ошибка health при деплое печатает команду; руками:
```bash
ssh tgit-deploy@72.56.247.166
cd /srv/tgit/dev && rm -rf app && mv app.old app && cd app && docker compose -p tgit-dev up -d --build
```
## Завести prod
Нужен **отдельный токен бота** (один токен — один процесс polling): создать бота у @BotFather →
`/srv/tgit/prod/.env` по образцу dev (`TELEGRAM_BOT_TOKEN`, `ADMIN_IDS`, `POSTGRES_PASSWORD`, `GIT_UPLOADS_URL`,
`GIT_SSH_KEY_B64`, `TELEGRAM_PROXY_URL`, `LLM_PROVIDER`). Пока файла нет, деплой `main` пропускается.
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Deploy a git ref of the bot to a server. Used by hand and by CD (.gitea/workflows/deploy.yml).
# infra/deploy/deploy.sh <dev|prod> [git-ref] default ref: HEAD
# Env: DEPLOY_HOST (default tgit-deploy@72.56.247.166 — the vobot host; any Docker host works)
# DEPLOY_SSH_OPTS (extra ssh/scp options, e.g. -i key)
# Server layout: /srv/tgit/<env>/{.env, app/, app.old/, REVISION}; secrets live only in /srv/tgit/<env>/.env.
# The host drops some ssh connections — every remote step is retried, and each one is safe to repeat.
set -euo pipefail
ENV=${1:?usage: deploy.sh <dev|prod> [ref]}
REF=${2:-HEAD}
case "$ENV" in dev | prod) ;; *) echo "env must be dev or prod" >&2; exit 2 ;; esac
HOST=${DEPLOY_HOST:-tgit-deploy@72.56.247.166}
OPTS=${DEPLOY_SSH_OPTS:-}
DIR=/srv/tgit/$ENV
SHA=$(git rev-parse --short "$REF")
TGZ=$(mktemp --suffix=.tgz)
trap 'rm -f "$TGZ"' EXIT
git archive --format=tar.gz -o "$TGZ" "$REF"
retry() { local n=0; until "$@"; do n=$((n + 1)); [ $n -ge 5 ] && return 1; echo "retry $n: $1" >&2; sleep $((n * 5)); done; }
# shellcheck disable=SC2086
if ! retry ssh $OPTS "$HOST" "test -f $DIR/.env"; then
echo "::notice::$HOST:$DIR/.env not found — environment '$ENV' is not configured, skipping deploy"
exit 0
fi
echo "deploy $REF ($SHA) -> $HOST:$DIR"
# shellcheck disable=SC2086
retry scp -q $OPTS "$TGZ" "$HOST:$DIR/release-$SHA.tgz"
# shellcheck disable=SC2086
retry scp -q $OPTS infra/deploy/remote.sh "$HOST:$DIR/remote.sh"
# shellcheck disable=SC2086
retry ssh $OPTS "$HOST" "bash $DIR/remote.sh $ENV $SHA"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Runs on the server: unpack release-<sha>.tgz, swap app/ atomically, compose up, wait for healthy.
# bash /srv/tgit/<env>/remote.sh <env> <sha> (uploaded by deploy.sh; safe to re-run)
set -euo pipefail
ENV=$1
SHA=$2
DIR=/srv/tgit/$ENV
PROJECT=tgit-$ENV
cd "$DIR"
if [ -f "release-$SHA.tgz" ]; then
rm -rf app.new && mkdir app.new && tar -xzf "release-$SHA.tgz" -C app.new && rm -f "release-$SHA.tgz"
cp .env app.new/.env
rm -rf app.old
[ -d app ] && mv app app.old
mv app.new app
fi
cd app
docker compose -p "$PROJECT" up -d --build --remove-orphans </dev/null
status=""
for _ in $(seq 1 40); do
status=$(docker inspect -f '{{.State.Health.Status}}' "$PROJECT-bot-1" 2>/dev/null || true)
[ "$status" = healthy ] && break
sleep 3
done
if [ "$status" != healthy ]; then
echo "bot is '$status' after 2 min — last logs:" >&2
docker logs --tail 50 "$PROJECT-bot-1" >&2 || true
echo "rollback: cd $DIR && rm -rf app && mv app.old app && cd app && docker compose -p $PROJECT up -d --build" >&2
exit 1
fi
echo "$SHA" > ../REVISION
docker image prune -f >/dev/null
echo "bot healthy @ $SHA ($ENV)"
+3
View File
@@ -14,6 +14,7 @@ dependencies = [
"pyyaml>=6.0",
"regex>=2024.7",
"segno>=1.6",
"aiohttp-socks>=0.9",
]
[dependency-groups]
@@ -42,6 +43,8 @@ extend-exclude = [".claude", "docs"]
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM", "ASYNC", "RUF"]
# Russian UI texts, commit messages and comments are normal here, not homoglyph attacks
ignore = ["RUF001", "RUF002", "RUF003"]
[tool.mypy]
python_version = "3.12"
View File
+138
View File
@@ -0,0 +1,138 @@
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import select
from app.access.service import AccessService, RedeemStatus, TgUser, hash_token
from app.config import AccessSettings
from app.db.models import Draft, FsmState, Invite, Role, User
from app.db.session import SessionMaker
ADMIN = 1
NEWBIE = TgUser(tg_id=500, full_name="Новичок", username="newbie")
class Clock:
def __init__(self) -> None:
self.now = datetime(2026, 10, 15, 12, 0, tzinfo=UTC)
def __call__(self) -> datetime:
return self.now
@pytest.fixture
def clock() -> Clock:
return Clock()
@pytest.fixture
async def svc(sessions: SessionMaker, clock: Clock) -> AccessService:
service = AccessService(sessions, AccessSettings(admin_ids=frozenset({ADMIN}), invite_ttl_hours=24), clock=clock)
await service.bootstrap_admins()
return service
async def test_bootstrap_is_idempotent_and_restores_admin(svc: AccessService, sessions: SessionMaker) -> None:
async with sessions.begin() as s:
admin = await s.get(User, ADMIN)
assert admin is not None
admin.is_active, admin.role = False, Role.MEMBER
await svc.bootstrap_admins()
await svc.bootstrap_admins()
member = await svc.get_member(ADMIN)
assert member is not None and member.role is Role.ADMIN
async def test_invite_stores_only_hash(svc: AccessService, sessions: SessionMaker, clock: Clock) -> None:
invite = await svc.create_invite(ADMIN)
assert len(invite.token) == 22 and invite.token.replace("-", "").replace("_", "").isalnum()
assert invite.expires_at == clock.now + timedelta(hours=24)
async with sessions() as s:
stored = await s.scalar(select(Invite))
assert stored is not None and stored.token_hash == hash_token(invite.token) != invite.token
async def test_redeem_once_then_rejected_for_others(svc: AccessService, sessions: SessionMaker) -> None:
invite = await svc.create_invite(ADMIN)
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.OK
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.ALREADY_MEMBER
assert await svc.redeem(invite.token, TgUser(tg_id=501, full_name="x")) is RedeemStatus.REJECTED
member = await svc.get_member(NEWBIE.tg_id)
assert member is not None and member.role is Role.MEMBER and member.invited_by == ADMIN
async with sessions() as s:
stored = await s.scalar(select(Invite))
assert stored is not None and stored.used_by == NEWBIE.tg_id
assert await s.get(User, 501) is None # the loser's user row was rolled back
async def test_expired_invite_rejected(svc: AccessService, clock: Clock) -> None:
invite = await svc.create_invite(ADMIN)
clock.now += timedelta(hours=24, seconds=1)
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.REJECTED
assert await svc.get_member(NEWBIE.tg_id) is None
async def test_brute_force_is_throttled_even_with_a_valid_token(svc: AccessService, clock: Clock) -> None:
for i in range(5):
assert await svc.redeem(f"guess-{i}", NEWBIE) is RedeemStatus.REJECTED
invite = await svc.create_invite(ADMIN)
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.THROTTLED
clock.now += timedelta(hours=1, seconds=1)
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.OK
async def test_revoke_drops_access_draft_and_dialog(svc: AccessService, sessions: SessionMaker, clock: Clock) -> None:
await svc.redeem((await svc.create_invite(ADMIN)).token, NEWBIE)
async with sessions.begin() as s:
s.add(Draft(user_id=NEWBIE.tg_id, base_sha="0" * 40, expires_at=clock.now + timedelta(hours=1)))
s.add(FsmState(key=f"42:{NEWBIE.tg_id}:{NEWBIE.tg_id}:::default", state="x", data={}))
s.add(FsmState(key=f"42:{ADMIN}:{ADMIN}:::default", state="y", data={}))
assert await svc.revoke(NEWBIE.tg_id)
assert await svc.get_member(NEWBIE.tg_id) is None
async with sessions() as s:
assert await s.scalar(select(Draft)) is None
assert [r.key for r in await s.scalars(select(FsmState))] == [f"42:{ADMIN}:{ADMIN}:::default"]
async def test_admin_cannot_be_revoked_and_unknown_is_noop(svc: AccessService) -> None:
assert not await svc.revoke(ADMIN)
assert not await svc.revoke(999)
async def test_revoked_member_can_be_invited_again(svc: AccessService) -> None:
await svc.redeem((await svc.create_invite(ADMIN)).token, NEWBIE)
await svc.revoke(NEWBIE.tg_id)
assert await svc.redeem((await svc.create_invite(ADMIN)).token, NEWBIE) is RedeemStatus.OK
assert await svc.get_member(NEWBIE.tg_id) is not None
async def test_members_listed_admins_first(svc: AccessService) -> None:
await svc.redeem((await svc.create_invite(ADMIN)).token, NEWBIE)
assert [m.tg_id for m in await svc.list_members()] == [ADMIN, NEWBIE.tg_id]
async def test_touch_is_throttled_but_tracks_renames(svc: AccessService, clock: Clock) -> None:
member = await svc.get_member(ADMIN)
assert member is not None
await svc.touch(member, TgUser(ADMIN, "Админ", "boss"))
first = await svc.get_member(ADMIN)
assert first is not None and first.full_name == "Админ" and first.last_seen_at is not None
clock.now += timedelta(minutes=1)
await svc.touch(first, TgUser(ADMIN, "Админ", "boss"))
same = await svc.get_member(ADMIN)
assert same is not None and same.last_seen_at == first.last_seen_at # no write within 5 minutes
await svc.touch(same, TgUser(ADMIN, "Новое имя", "boss"))
renamed = await svc.get_member(ADMIN)
assert renamed is not None and renamed.full_name == "Новое имя"
+101
View File
@@ -0,0 +1,101 @@
import pytest
from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendDocument, SendMessage
from aiogram.types import InlineKeyboardMarkup
from app.access.service import AccessService
from app.bot.routers.commit import Act, CommitCb, human_size
from app.db.session import SessionMaker
from app.main import build_dispatcher
from app.repo.git import UploadsRepo
from app.texts import Texts
from tests.conftest import ADMIN
from tests.fakes import TgClient
@pytest.fixture
def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo) -> TgClient:
return TgClient(build_dispatcher(texts, sessions, access, repo))
def labels(markup: object) -> list[str]:
assert isinstance(markup, InlineKeyboardMarkup)
return [b.text for row in markup.inline_keyboard for b in row]
def cb(action: Act, i: int = 0) -> str:
return CommitCb(a=action, i=i).pack()
async def test_root_listing(tg: TgClient, texts: Texts) -> None:
await tg.send(ADMIN, texts.menu.commit)
[reply] = tg.session.sent(SendMessage)
assert reply.text == texts.commit.title.format(path="")
got = labels(reply.reply_markup)
assert got[:4] == ["📁 docs", "📁 many", "📁 services", "📄 README.md · 10 Б"]
assert texts.commit.up not in got # already at the root
async def test_open_dir_then_file_card_then_back(tg: TgClient, texts: Texts) -> None:
await tg.send(ADMIN, texts.menu.commit)
await tg.press(ADMIN, cb(Act.OPEN, 2)) # services
await tg.press(ADMIN, cb(Act.OPEN, 0)) # services/hello
await tg.press(ADMIN, cb(Act.OPEN, 0)) # services/hello/main.py
hello_dir, hello_sub, card = tg.session.sent(EditMessageText)
assert hello_dir.text == texts.commit.title.format(path="services")
assert texts.commit.up in labels(hello_dir.reply_markup)
assert hello_sub.text == texts.commit.title.format(path="services/hello")
assert card.text.startswith("📄 /services/hello/main.py") and "init" in card.text
assert labels(card.reply_markup) == [
texts.commit.view,
texts.commit.replace,
texts.commit.delete,
texts.commit.to_dir,
]
await tg.press(ADMIN, cb(Act.TO_DIR))
await tg.press(ADMIN, cb(Act.UP))
*_, back_to_dir, up = tg.session.sent(EditMessageText)
assert back_to_dir.text == texts.commit.title.format(path="services/hello")
assert up.text == texts.commit.title.format(path="services")
async def test_view_text_inline_and_binary_as_document(tg: TgClient, texts: Texts) -> None:
await tg.send(ADMIN, texts.menu.commit)
await tg.press(ADMIN, cb(Act.OPEN, 0)) # docs
await tg.press(ADMIN, cb(Act.OPEN, 1)) # мои заметки.md
await tg.press(ADMIN, cb(Act.VIEW))
await tg.press(ADMIN, cb(Act.TO_DIR))
await tg.press(ADMIN, cb(Act.OPEN, 0)) # logo.bin
await tg.press(ADMIN, cb(Act.VIEW))
inline = [m for m in tg.session.sent(SendMessage) if m.parse_mode == "HTML"]
assert len(inline) == 1 and "<pre>привет\n</pre>" in inline[0].text
[doc] = tg.session.sent(SendDocument)
assert doc.caption == texts.commit.sent_as_file
async def test_pagination(tg: TgClient, texts: Texts) -> None:
await tg.send(ADMIN, texts.menu.commit)
await tg.press(ADMIN, cb(Act.OPEN, 1)) # many: 25 files
await tg.press(ADMIN, cb(Act.PAGE, 1))
first, second = tg.session.sent(EditMessageText)
assert texts.commit.page.format(page=1, pages=2) in first.text
assert labels(first.reply_markup)[0].startswith("📄 f00.txt") and texts.commit.next in labels(first.reply_markup)
assert labels(second.reply_markup)[0].startswith("📄 f20.txt") and texts.commit.prev in labels(second.reply_markup)
async def test_stale_button_is_ignored(tg: TgClient, texts: Texts) -> None:
await tg.send(ADMIN, texts.menu.commit)
await tg.press(ADMIN, cb(Act.OPEN, 99))
[stale] = tg.session.sent(AnswerCallbackQuery)
assert stale.text is None
assert tg.session.sent(EditMessageText) == []
@pytest.mark.parametrize(("n", "text"), [(10, "10 Б"), (2048, "2.0 КБ"), (5 * 1024 * 1024, "5.0 МБ")])
def test_human_size(n: int, text: str) -> None:
assert human_size(n) == text
+162
View File
@@ -0,0 +1,162 @@
import pytest
from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendMessage
from aiogram.types import InlineKeyboardMarkup
from app.access.service import AccessService
from app.bot.routers.commit import Act, CommitCb
from app.db.session import SessionMaker
from app.main import build_dispatcher
from app.repo.git import UploadsRepo
from app.texts import Texts
from tests.conftest import ADMIN
from tests.fakes import TgClient
@pytest.fixture
def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo) -> TgClient:
return TgClient(build_dispatcher(texts, sessions, access, repo))
def cb(action: Act, i: int = 0) -> str:
return CommitCb(a=action, i=i).pack()
def labels(markup: object) -> list[str]:
assert isinstance(markup, InlineKeyboardMarkup)
return [b.text for row in markup.inline_keyboard for b in row]
def last_text(tg: TgClient) -> SendMessage:
return tg.session.sent(SendMessage)[-1]
async def to_hello(tg: TgClient, texts: Texts) -> None:
await tg.send(ADMIN, texts.menu.commit)
await tg.press(ADMIN, cb(Act.OPEN, 2)) # services
await tg.press(ADMIN, cb(Act.OPEN, 0)) # services/hello
async def test_new_file_from_fenced_text(tg: TgClient, texts: Texts) -> None:
await to_hello(tg, texts)
await tg.press(ADMIN, cb(Act.NEW_FILE))
await tg.send(ADMIN, "utils.py")
await tg.send(ADMIN, "```python\ndef add(a, b):\n return a + b\n```")
reply = last_text(tg)
assert reply.text.startswith(texts.commit.added.format(path="services/hello/utils.py"))
got = labels(reply.reply_markup)
assert "🆕 📄 utils.py · 31 Б" in got # fence stripped, no trailing newline
assert texts.commit.draft_button.format(count=1) in got
await tg.press(ADMIN, cb(Act.OPEN, 1)) # utils.py (main.py is 0)
await tg.press(ADMIN, cb(Act.VIEW))
view = last_text(tg)
assert "def add(a, b):" in view.text and "```" not in view.text
async def test_replace_with_document_shows_diff(tg: TgClient, texts: Texts) -> None:
await to_hello(tg, texts)
await tg.press(ADMIN, cb(Act.OPEN, 0)) # main.py
await tg.press(ADMIN, cb(Act.REPLACE))
await tg.send_document(ADMIN, "main.py", b"print('hello')\nprint('bye')\n")
reply = last_text(tg)
assert reply.text.startswith(texts.commit.modified.format(path="services/hello/main.py", added=1, removed=0))
assert "✏️ 📄 main.py · 28 Б" in labels(reply.reply_markup)
async def test_delete_marks_and_draft_lists(tg: TgClient, texts: Texts) -> None:
await tg.send(ADMIN, texts.menu.commit)
await tg.press(ADMIN, cb(Act.OPEN, 3)) # README.md
await tg.press(ADMIN, cb(Act.DELETE))
deleted = tg.session.sent(EditMessageText)[-1]
assert deleted.text.startswith(texts.commit.deleted.format(path="README.md"))
assert "🗑 📄 README.md · 10 Б" in labels(deleted.reply_markup)
await tg.press(ADMIN, cb(Act.DRAFT))
draft = tg.session.sent(EditMessageText)[-1]
assert "D /README.md" in draft.text
await tg.press(ADMIN, cb(Act.UNSTAGE, 0))
assert tg.session.sent(EditMessageText)[-1].text == texts.commit.draft_empty
async def test_syntax_error_force_or_fix(tg: TgClient, texts: Texts) -> None:
await to_hello(tg, texts)
await tg.press(ADMIN, cb(Act.NEW_FILE))
await tg.send(ADMIN, "bad.py")
await tg.send(ADMIN, "def f(:\n pass")
warn = last_text(tg)
assert warn.text.startswith("⚠️") and "1" in warn.text
assert labels(warn.reply_markup) == [texts.commit.fix, texts.commit.force]
await tg.press(ADMIN, cb(Act.FIX))
assert tg.session.sent(EditMessageText)[-1].text == texts.commit.ask_content.format(path="services/hello/bad.py")
await tg.send(ADMIN, "def f(:\n pass")
await tg.press(ADMIN, cb(Act.FORCE))
assert last_text(tg).text.startswith(texts.commit.added.format(path="services/hello/bad.py"))
async def test_virtual_folder_becomes_real_with_a_file(tg: TgClient, texts: Texts) -> None:
await tg.send(ADMIN, texts.menu.commit)
await tg.press(ADMIN, cb(Act.OPEN, 2)) # services
await tg.press(ADMIN, cb(Act.NEW_DIR))
await tg.send(ADMIN, "api")
inside = last_text(tg)
assert inside.text == f"{texts.commit.title.format(path='services/api')}\n{texts.commit.empty}"
await tg.press(ADMIN, cb(Act.NEW_FILE))
await tg.send(ADMIN, "main.py")
await tg.send(ADMIN, "print('api')")
await tg.press(ADMIN, cb(Act.UP))
services = tg.session.sent(EditMessageText)[-1]
assert "🆕 📁 api" in labels(services.reply_markup)
async def test_names_rejected(tg: TgClient, texts: Texts) -> None:
await tg.send(ADMIN, texts.menu.commit)
await tg.press(ADMIN, cb(Act.NEW_FILE))
for bad in ["../etc", "README.md", "ruff.toml"]:
await tg.send(ADMIN, bad)
assert [m.text for m in tg.session.sent(SendMessage)][-3:] == [
texts.commit.bad_name,
texts.commit.exists,
texts.commit.protected,
]
async def test_binary_document_rejected(tg: TgClient, texts: Texts) -> None:
await to_hello(tg, texts)
await tg.press(ADMIN, cb(Act.NEW_FILE))
await tg.send(ADMIN, "img.png")
await tg.send_document(ADMIN, "img.png", b"\x89PNG\x00\x00")
assert last_text(tg).text == texts.commit.binary
async def test_draft_survives_a_restart(
texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo
) -> None:
first = TgClient(build_dispatcher(texts, sessions, access, repo))
await first.send(ADMIN, texts.menu.commit)
await first.press(ADMIN, cb(Act.OPEN, 3))
await first.press(ADMIN, cb(Act.DELETE))
second = TgClient(build_dispatcher(texts, sessions, access, repo)) # same DB, new bot process
await second.send(ADMIN, texts.menu.commit)
assert texts.commit.draft_button.format(count=1) in labels(second.session.sent(SendMessage)[-1].reply_markup)
async def test_protected_file_cannot_be_replaced(tg: TgClient, texts: Texts, repo: UploadsRepo) -> None:
await tg.send(ADMIN, texts.menu.commit)
# fake a card for a protected path via FSM: open README then rewrite the selection
await tg.press(ADMIN, cb(Act.OPEN, 3))
state = tg.dp.fsm.get_context(tg.bot, chat_id=ADMIN, user_id=ADMIN)
await state.update_data(commit_file=".sourcecraft/ci.yaml")
await tg.press(ADMIN, cb(Act.REPLACE))
answer = tg.session.sent(AnswerCallbackQuery)[-1]
assert answer.text == texts.commit.protected and answer.show_alert
+92
View File
@@ -0,0 +1,92 @@
from pathlib import Path
import pytest
from aiogram.methods import EditMessageText, SendMessage
from aiogram.types import InlineKeyboardMarkup
from app.access.service import AccessService
from app.bot.routers.commit import Act, CommitCb
from app.db.session import SessionMaker
from app.main import build_dispatcher
from app.repo.git import UploadsRepo
from app.texts import Texts
from tests.conftest import ADMIN, git
from tests.fakes import TgClient
@pytest.fixture
def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo) -> TgClient:
return TgClient(build_dispatcher(texts, sessions, access, repo, "https://example.test/commit/{sha}"))
def cb(action: Act, i: int = 0) -> str:
return CommitCb(a=action, i=i).pack()
def labels(markup: object) -> list[str]:
assert isinstance(markup, InlineKeyboardMarkup)
return [b.text for row in markup.inline_keyboard for b in row]
async def test_message_then_push_lands_in_remote(tg: TgClient, texts: Texts, remote: Path, tmp_path: Path) -> None:
t = texts.commit
await tg.send(ADMIN, texts.menu.commit)
await tg.press(ADMIN, cb(Act.NEW_FILE))
await tg.send(ADMIN, "notes.txt")
await tg.send(ADMIN, "привет из телеграма")
await tg.press(ADMIN, cb(Act.DRAFT))
draft = tg.session.sent(EditMessageText)[-1]
assert t.message_missing in draft.text and t.push_button not in labels(draft.reply_markup)
await tg.press(ADMIN, cb(Act.MESSAGE))
await tg.send(ADMIN, "x" * 80) # first line too long
assert tg.session.sent(SendMessage)[-1].text == t.bad_message
await tg.send(ADMIN, "docs: заметки")
with_message = tg.session.sent(SendMessage)[-1]
assert t.message_current.format(message="docs: заметки") in with_message.text
assert t.push_button in labels(with_message.reply_markup)
await tg.press(ADMIN, cb(Act.PUSH))
assert tg.session.sent(EditMessageText)[-1].text.startswith("Запушить в main?")
await tg.press(ADMIN, cb(Act.PUSH_GO))
pushing, done = tg.session.sent(EditMessageText)[-2:]
assert pushing.text == t.pushing
assert done.text.startswith("✅ Запушено: ") and "docs: заметки" in done.text
assert "https://example.test/commit/" in done.text and done.text.endswith(t.ci_pending)
clone = tmp_path / "verify"
git(tmp_path, "clone", "-q", str(remote), str(clone))
assert (clone / "notes.txt").read_text(encoding="utf-8") == "привет из телеграма"
# the draft is gone after a push
tg.session.requests.clear()
await tg.send(ADMIN, texts.menu.commit)
assert not any(label.startswith("🧾") for label in labels(tg.session.sent(SendMessage)[-1].reply_markup))
async def test_commit_button_in_folder_asks_message_then_confirms(
tg: TgClient, texts: Texts, remote: Path, tmp_path: Path
) -> None:
t = texts.commit
await tg.send(ADMIN, texts.menu.commit)
await tg.press(ADMIN, cb(Act.NEW_FILE))
await tg.send(ADMIN, "quick.txt")
await tg.send(ADMIN, "быстро")
folder = tg.session.sent(SendMessage)[-1]
assert t.commit_button.format(count=1) in labels(folder.reply_markup)
await tg.press(ADMIN, cb(Act.COMMIT))
assert tg.session.sent(EditMessageText)[-1].text == t.ask_message
await tg.send(ADMIN, "feat: быстрый коммит")
confirm = tg.session.sent(SendMessage)[-1]
assert confirm.text.startswith("Запушить в main?") and "feat: быстрый коммит" in confirm.text
assert labels(confirm.reply_markup) == [t.push_go, t.edit_message, texts.menu.back]
await tg.press(ADMIN, cb(Act.PUSH_GO))
assert tg.session.sent(EditMessageText)[-1].text.startswith("✅ Запушено: ")
clone = tmp_path / "verify2"
git(tmp_path, "clone", "-q", str(remote), str(clone))
assert (clone / "quick.txt").read_text(encoding="utf-8") == "быстро"
+6 -4
View File
@@ -2,17 +2,19 @@ import pytest
from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendMessage
from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup
from app.access.service import AccessService
from app.bot.menu import Nav, NavAction
from app.config import Settings
from app.db.session import SessionMaker
from app.main import build_dispatcher
from app.repo.git import UploadsRepo
from app.texts import Texts
from tests.conftest import ADMIN, OUTSIDER
from tests.fakes import TgClient
@pytest.fixture
def tg(settings: Settings, texts: Texts) -> TgClient:
return TgClient(build_dispatcher(settings, texts))
def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo) -> TgClient:
return TgClient(build_dispatcher(texts, sessions, access, repo))
def buttons(markup: ReplyKeyboardMarkup) -> list[str]:
@@ -44,7 +46,7 @@ async def test_outsider_stale_button_is_swallowed(tg: TgClient) -> None:
assert len(tg.session.sent(AnswerCallbackQuery)) == 1
@pytest.mark.parametrize("section", ["commit", "status", "logs", "alerts", "members"])
@pytest.mark.parametrize("section", ["status", "logs", "alerts"])
async def test_menu_button_opens_section_with_nav(tg: TgClient, texts: Texts, section: str) -> None:
await tg.send(ADMIN, getattr(texts.menu, section))
+48
View File
@@ -0,0 +1,48 @@
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.storage.base import StorageKey
from sqlalchemy import func, select
from app.bot.fsm_storage import SqlStorage, key_to_str
from app.db.models import FsmState
from app.db.session import SessionMaker
KEY = StorageKey(bot_id=42, chat_id=7, user_id=7)
class Flow(StatesGroup):
name = State()
async def test_state_and_data_survive_a_new_storage(sessions: SessionMaker) -> None:
first = SqlStorage(sessions)
await first.set_state(KEY, Flow.name)
await first.set_data(KEY, {"path": "services/a", "page": 2})
# A fresh storage on the same DB = the bot after a restart
second = SqlStorage(sessions)
assert await second.get_state(KEY) == Flow.name.state
assert await second.get_data(KEY) == {"path": "services/a", "page": 2}
async def test_unknown_key_is_empty(sessions: SessionMaker) -> None:
storage = SqlStorage(sessions)
assert await storage.get_state(KEY) is None
assert await storage.get_data(KEY) == {}
async def test_cleared_state_removes_row(sessions: SessionMaker) -> None:
storage = SqlStorage(sessions)
await storage.set_state(KEY, "x")
await storage.set_data(KEY, {"a": 1})
await storage.set_state(KEY, None)
await storage.set_data(KEY, {})
await storage.close()
async with sessions() as s:
assert await s.scalar(select(func.count()).select_from(FsmState)) == 0
def test_key_includes_every_part() -> None:
key = StorageKey(bot_id=1, chat_id=2, user_id=3, thread_id=4, business_connection_id="b", destiny="d")
assert key_to_str(key) == "1:2:3:4:b:d"
assert key_to_str(KEY) == "42:7:7:::default"
+95
View File
@@ -0,0 +1,95 @@
import re
import pytest
from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendMessage, SendPhoto
from aiogram.types import ReplyKeyboardMarkup
from app.access.service import AccessService
from app.bot.routers.members import MembersAction, MembersCb
from app.db.session import SessionMaker
from app.main import build_dispatcher
from app.repo.git import UploadsRepo
from app.texts import Texts
from tests.conftest import ADMIN
from tests.fakes import TgClient
NEWBIE = 3003
@pytest.fixture
def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo) -> TgClient:
return TgClient(build_dispatcher(texts, sessions, access, repo))
async def invite_token(tg: TgClient) -> str:
await tg.press(ADMIN, MembersCb(action=MembersAction.INVITE).pack())
[photo] = tg.session.sent(SendPhoto)
match = re.search(r"https://t\.me/tgit_test_bot\?start=([A-Za-z0-9_-]+)", photo.caption or "")
assert match, photo.caption
tg.session.requests.clear()
return match.group(1)
async def test_admin_sees_members_list(tg: TgClient, texts: Texts) -> None:
await tg.send(ADMIN, texts.menu.members)
[reply] = tg.session.sent(SendMessage)
assert reply.text.startswith(texts.members.title.format(count=1))
async def test_full_invite_flow(tg: TgClient, texts: Texts) -> None:
token = await invite_token(tg)
await tg.send(NEWBIE, f"/start {token}")
[welcome] = tg.session.sent(SendMessage)
assert welcome.text == texts.start.welcome
assert isinstance(welcome.reply_markup, ReplyKeyboardMarkup)
labels = [b.text for row in welcome.reply_markup.keyboard for b in row]
assert texts.menu.members not in labels # a member, not an admin
# the same QR again, from someone else
tg.session.requests.clear()
await tg.send(NEWBIE + 1, f"/start {token}")
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.invite_rejected]
async def test_bad_token_rejected_and_bare_start_refused(tg: TgClient, texts: Texts) -> None:
await tg.send(NEWBIE, "/start nope")
await tg.send(NEWBIE + 1, "/start")
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.invite_rejected, texts.start.no_access]
async def test_revoke_with_confirmation_cuts_access(tg: TgClient, texts: Texts) -> None:
await tg.send(NEWBIE, f"/start {await invite_token(tg)}")
tg.session.requests.clear()
await tg.press(ADMIN, MembersCb(action=MembersAction.ASK_REVOKE, tg_id=NEWBIE).pack())
await tg.press(ADMIN, MembersCb(action=MembersAction.REVOKE, tg_id=NEWBIE).pack())
confirm, done = tg.session.sent(EditMessageText)
assert confirm.text == texts.members.confirm.format(name=f"u{NEWBIE}")
assert done.text.startswith(texts.members.revoked.format(name=f"u{NEWBIE}"))
tg.session.requests.clear()
await tg.send(NEWBIE, texts.menu.commit)
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.no_access]
async def test_member_cannot_use_admin_actions(tg: TgClient, texts: Texts) -> None:
await tg.send(NEWBIE, f"/start {await invite_token(tg)}")
tg.session.requests.clear()
await tg.send(NEWBIE, texts.menu.members)
await tg.press(NEWBIE, MembersCb(action=MembersAction.INVITE).pack())
assert tg.session.sent(SendMessage) == [] and tg.session.sent(SendPhoto) == []
assert len(tg.session.sent(AnswerCallbackQuery)) == 1
async def test_cancel_revoke_returns_to_list(tg: TgClient, texts: Texts) -> None:
await tg.press(ADMIN, MembersCb(action=MembersAction.LIST).pack())
[edit] = tg.session.sent(EditMessageText)
assert edit.text.startswith(texts.members.title.format(count=1))
+70 -1
View File
@@ -1,8 +1,15 @@
import subprocess
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine
from app.config import AccessSettings, RuntimeSettings, Settings, TelegramSettings
from app.access.service import AccessService
from app.config import AccessSettings, GitSettings, RuntimeSettings, Settings, TelegramSettings
from app.db.migrate import upgrade
from app.db.session import SessionMaker, make_engine, make_sessionmaker
from app.repo.git import UploadsRepo
from app.texts import Texts, load_texts
ROOT = Path(__file__).resolve().parent.parent
@@ -22,3 +29,65 @@ def settings(tmp_path: Path) -> Settings:
access=AccessSettings(admin_ids=frozenset({ADMIN})),
runtime=RuntimeSettings(config_dir=ROOT / "config", heartbeat_file=tmp_path / "hb"),
)
@pytest.fixture
async def engine() -> AsyncIterator[AsyncEngine]:
"""Fresh in-memory SQLite with all migrations applied — the same path the bot takes at startup."""
eng = make_engine("sqlite+aiosqlite://")
await upgrade(eng)
yield eng
await eng.dispose()
@pytest.fixture
def sessions(engine: AsyncEngine) -> SessionMaker:
return make_sessionmaker(engine)
@pytest.fixture
async def access(sessions: SessionMaker, settings: Settings) -> AccessService:
service = AccessService(sessions, settings.access)
await service.bootstrap_admins()
return service
def git(cwd: Path, *args: str) -> str:
env = {
"GIT_AUTHOR_NAME": "Тест",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "Тест",
"GIT_COMMITTER_EMAIL": "t@t",
"PATH": "/usr/bin:/bin",
"HOME": str(cwd),
}
return subprocess.run(["git", *args], cwd=cwd, env=env, check=True, capture_output=True, text=True).stdout
@pytest.fixture
def remote(tmp_path: Path) -> Path:
"""A bare 'SourceCraft' repo: services/hello, a file with a space and Cyrillic, a binary, 25 files in one dir."""
work = tmp_path / "seed"
work.mkdir()
git(work, "init", "-q", "-b", "main")
(work / "services" / "hello").mkdir(parents=True)
(work / "services" / "hello" / "main.py").write_text("print('hello')\n", encoding="utf-8")
(work / "README.md").write_text("# uploads\n", encoding="utf-8")
git(work, "add", "-A")
git(work, "commit", "-q", "-m", "init")
(work / "docs").mkdir()
(work / "docs" / "мои заметки.md").write_text("привет\n", encoding="utf-8")
(work / "docs" / "logo.bin").write_bytes(b"\x00\x01\x02binary")
(work / "many").mkdir()
for i in range(25):
(work / "many" / f"f{i:02}.txt").write_text(str(i), encoding="utf-8")
git(work, "add", "-A")
git(work, "commit", "-q", "-m", "docs and many")
bare = tmp_path / "remote.git"
git(tmp_path, "clone", "-q", "--bare", str(work), str(bare))
return bare
@pytest.fixture
def repo(remote: Path, tmp_path: Path) -> UploadsRepo:
return UploadsRepo(GitSettings(uploads_url=str(remote), clone_dir=tmp_path / "clone", fetch_interval=0))
View File
+30
View File
@@ -0,0 +1,30 @@
from datetime import timedelta
from sqlalchemy import func, select
from app.db.maintenance import cleanup_expired
from app.db.models import AccessAttempt, ContainerSnapshot, Draft, User, utcnow
from app.db.session import SessionMaker
async def test_cleanup_removes_only_expired(sessions: SessionMaker) -> None:
now = utcnow()
async with sessions.begin() as s:
s.add_all([User(tg_id=1, full_name="a"), User(tg_id=2, full_name="b")])
await s.flush() # no relationship() to order inserts: users first for the FK
s.add_all(
[
Draft(user_id=1, base_sha="a" * 40, expires_at=now - timedelta(minutes=1)),
Draft(user_id=2, base_sha="b" * 40, expires_at=now + timedelta(hours=1)),
ContainerSnapshot(container="c", at=now - timedelta(days=2), restart_count=0, cpu_pct=0, mem_pct=0),
ContainerSnapshot(container="c", at=now, restart_count=0, cpu_pct=0, mem_pct=0),
AccessAttempt(tg_id=9, at=now - timedelta(days=2), ok=False),
]
)
removed = await cleanup_expired(sessions, now)
assert removed == {"drafts": 1, "snapshots": 1, "access_attempts": 1}
async with sessions() as s:
assert await s.scalar(select(func.count()).select_from(Draft)) == 1
assert await s.scalar(select(func.count()).select_from(ContainerSnapshot)) == 1
+82
View File
@@ -0,0 +1,82 @@
from datetime import timedelta
import pytest
from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
from sqlalchemy import inspect, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncEngine
from app.db.migrate import downgrade, upgrade
from app.db.models import Base, ChangeOp, Draft, DraftChange, User, utcnow
from app.db.session import SessionMaker, make_engine
TABLES = {
"users",
"invites",
"access_attempts",
"fsm_state",
"drafts",
"draft_changes",
"pushes",
"pipeline_runs",
"container_snapshots",
"alerts",
"alert_deliveries",
"log_queries",
"agent_requests",
}
async def table_names(engine: AsyncEngine) -> set[str]:
async with engine.connect() as conn:
return set(await conn.run_sync(lambda c: inspect(c).get_table_names())) - {"alembic_version"}
async def test_upgrade_creates_all_tables(engine: AsyncEngine) -> None:
assert await table_names(engine) == TABLES
async def test_models_match_migrations(engine: AsyncEngine) -> None:
# A model change without a migration shows up here as a non-empty diff.
async with engine.connect() as conn:
diff = await conn.run_sync(lambda c: compare_metadata(MigrationContext.configure(c), Base.metadata))
assert diff == []
async def test_downgrade_to_base_and_back() -> None:
eng = make_engine("sqlite+aiosqlite://")
try:
await upgrade(eng)
await downgrade(eng, "base")
assert await table_names(eng) == set()
await upgrade(eng)
assert await table_names(eng) == TABLES
finally:
await eng.dispose()
INSERT_USER = text(
"INSERT INTO users (tg_id, full_name, role, is_active, joined_at) VALUES (:id, 'u', :role, 1, '2026-01-01')"
)
async def test_enum_check_rejects_garbage(engine: AsyncEngine) -> None:
async with engine.begin() as conn:
await conn.execute(INSERT_USER, {"id": 1, "role": "admin"})
with pytest.raises(IntegrityError):
async with engine.begin() as conn:
await conn.execute(INSERT_USER, {"id": 2, "role": "root"})
async def test_deleting_draft_cascades_to_changes(sessions: SessionMaker) -> None:
async with sessions.begin() as s:
s.add(User(tg_id=1, full_name="a"))
await s.flush()
draft = Draft(user_id=1, base_sha="0" * 40, expires_at=utcnow() + timedelta(hours=1))
draft.changes.append(DraftChange(path="services/a/main.py", op=ChangeOp.ADD, content=b"print(1)"))
s.add(draft)
async with sessions.begin() as s:
await s.execute(text("DELETE FROM drafts"))
async with sessions() as s:
assert (await s.execute(text("SELECT count(*) FROM draft_changes"))).scalar_one() == 0
+32 -5
View File
@@ -7,9 +7,9 @@ from typing import Any
from aiogram import Bot, Dispatcher
from aiogram.client.session.base import BaseSession
from aiogram.methods import EditMessageText, SendMessage, TelegramMethod
from aiogram.methods import EditMessageText, GetFile, GetMe, SendMessage, SendPhoto, TelegramMethod
from aiogram.methods.base import TelegramType
from aiogram.types import Chat, Message, Update, User
from aiogram.types import Chat, File, Message, Update, User
BOT_TOKEN = "42:TEST-TOKEN"
@@ -19,6 +19,7 @@ class FakeSession(BaseSession):
super().__init__()
self.requests: list[TelegramMethod[Any]] = []
self._ids = count(1000)
self.files: dict[str, bytes] = {} # file_id -> content, for documents users "upload"
async def make_request(
self,
@@ -27,6 +28,20 @@ class FakeSession(BaseSession):
timeout: int | None = None, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
) -> TelegramType:
self.requests.append(method)
if isinstance(method, GetFile):
return File( # type: ignore[return-value]
file_id=method.file_id, file_unique_id=method.file_id, file_path=f"documents/{method.file_id}"
)
if isinstance(method, GetMe):
return User(id=42, is_bot=True, first_name="tgit", username="tgit_test_bot") # type: ignore[return-value]
if isinstance(method, SendPhoto):
chat = method.chat_id if isinstance(method.chat_id, int) else 0
return Message( # type: ignore[return-value]
message_id=next(self._ids),
date=datetime.now(UTC),
chat=Chat(id=chat, type="private"),
caption=method.caption,
)
if isinstance(method, SendMessage | EditMessageText):
chat_id = method.chat_id if isinstance(method.chat_id, int) else 0
return Message( # type: ignore[return-value]
@@ -44,9 +59,9 @@ class FakeSession(BaseSession):
timeout: int = 30, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
chunk_size: int = 65536,
raise_for_status: bool = True,
) -> AsyncGenerator[bytes, None]: # pragma: no cover - not used
raise NotImplementedError
yield b""
) -> AsyncGenerator[bytes, None]:
file_id = url.rsplit("/", 1)[-1]
yield self.files[file_id]
async def close(self) -> None:
pass
@@ -82,6 +97,18 @@ class TgClient:
message["entities"] = [{"type": "bot_command", "offset": 0, "length": len(cmd)}]
await self._feed({"message": message})
async def send_document(self, tg_id: int, name: str, content: bytes) -> None:
file_id = f"doc{next(self._message_ids)}"
self.session.files[file_id] = content
message = {
"message_id": next(self._message_ids),
"date": int(datetime.now(UTC).timestamp()),
"chat": {"id": tg_id, "type": "private"},
"from": self.user(tg_id),
"document": {"file_id": file_id, "file_unique_id": file_id, "file_name": name, "file_size": len(content)},
}
await self._feed({"message": message})
async def press(self, tg_id: int, data: str, message_text: str = "section") -> None:
callback = {
"id": str(next(self._update_ids)),
View File
+21
View File
@@ -0,0 +1,21 @@
import sys
import pytest
from app.llm import cli
def test_cli_asks_configured_provider(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
monkeypatch.setenv("LLM_PROVIDER", "mock")
monkeypatch.setattr(sys, "argv", ["cli", "привет", "мир"])
cli.main()
out = capsys.readouterr().out
assert "[mock -]" in out and "mock: привет мир" in out
def test_cli_usage(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["cli"])
with pytest.raises(SystemExit):
cli.main()
+139
View File
@@ -0,0 +1,139 @@
import json
from typing import Any
import httpx
import pytest
import respx
from app.config import LLMSettings
from app.llm.factory import make_provider
from app.llm.mock import MockProvider, MockRule
from app.llm.openai_compat import OpenAICompatProvider
from app.llm.parsing import parse_json_object
from app.llm.provider import ChatMessage, JsonSchema, LLMError
BASE = "https://llm.example.test/v1"
SCHEMA = JsonSchema("verdict", {"type": "object", "properties": {"verdict": {"type": "string"}}})
def settings(**kw: Any) -> LLMSettings:
base = {"provider": "openai_compat", "base_url": BASE, "model": "m-1", "api_key": "k", "max_attempts": 3}
return LLMSettings(**(base | kw))
def completion(content: str, prompt: int = 5, completion_tokens: int = 3) -> dict[str, Any]:
return {
"choices": [{"message": {"role": "assistant", "content": content}}],
"usage": {"prompt_tokens": prompt, "completion_tokens": completion_tokens},
}
class Sleeps:
def __init__(self) -> None:
self.delays: list[float] = []
async def __call__(self, delay: float) -> None:
self.delays.append(delay)
@respx.mock
async def test_plain_completion_sends_messages_and_counts_tokens() -> None:
route = respx.post(f"{BASE}/chat/completions").respond(json=completion("привет"))
provider = OpenAICompatProvider(settings())
result = await provider.complete([ChatMessage("system", "s"), ChatMessage("user", "u")])
await provider.aclose()
sent = json.loads(route.calls.last.request.content)
assert route.calls.last.request.headers["Authorization"] == "Bearer k"
assert sent["model"] == "m-1" and sent["temperature"] == 0.0
assert sent["messages"] == [{"role": "system", "content": "s"}, {"role": "user", "content": "u"}]
assert "response_format" not in sent
assert (result.text, result.data, result.tokens) == ("привет", None, 8)
@respx.mock
async def test_schema_requests_structured_output_and_parses_it() -> None:
route = respx.post(f"{BASE}/chat/completions").respond(json=completion('```json\n{"verdict": "safe"}\n```'))
provider = OpenAICompatProvider(settings())
result = await provider.complete([ChatMessage("user", "x")], SCHEMA)
fmt = json.loads(route.calls.last.request.content)["response_format"]
assert fmt == {"type": "json_schema", "json_schema": {"name": "verdict", "schema": SCHEMA.schema, "strict": True}}
assert result.data == {"verdict": "safe"}
@respx.mock
async def test_invalid_json_gives_no_data() -> None:
respx.post(f"{BASE}/chat/completions").respond(json=completion("not json"))
result = await OpenAICompatProvider(settings()).complete([ChatMessage("user", "x")], SCHEMA)
assert result.data is None and result.text == "not json"
@respx.mock
async def test_retries_rate_limit_and_network_errors() -> None:
sleeps = Sleeps()
respx.post(f"{BASE}/chat/completions").mock(
side_effect=[httpx.Response(429), httpx.ConnectError("x"), httpx.Response(200, json=completion("ok"))]
)
result = await OpenAICompatProvider(settings(), sleep=sleeps).complete([ChatMessage("user", "x")])
assert result.text == "ok" and sleeps.delays == [1.0, 2.0]
@respx.mock
async def test_client_error_is_not_retried() -> None:
route = respx.post(f"{BASE}/chat/completions").respond(401, text="bad key")
with pytest.raises(LLMError, match="401"):
await OpenAICompatProvider(settings()).complete([ChatMessage("user", "x")])
assert route.call_count == 1
@respx.mock
async def test_unreachable_after_all_attempts() -> None:
respx.post(f"{BASE}/chat/completions").mock(side_effect=httpx.ConnectError("down"))
with pytest.raises(LLMError, match="unreachable"):
await OpenAICompatProvider(settings(), sleep=Sleeps()).complete([ChatMessage("user", "x")])
@respx.mock
async def test_unexpected_shape_is_an_error() -> None:
respx.post(f"{BASE}/chat/completions").respond(json={"choices": []})
with pytest.raises(LLMError, match="shape"):
await OpenAICompatProvider(settings()).complete([ChatMessage("user", "x")])
def test_openai_compat_requires_url_and_model() -> None:
with pytest.raises(LLMError):
OpenAICompatProvider(settings(base_url=""))
async def test_mock_rules_and_fallbacks() -> None:
mock = MockProvider([MockRule("взлом", {"verdict": "attack"}), MockRule("привет", "здравствуй")])
assert (await mock.complete([ChatMessage("user", "попытка взлома")], SCHEMA)).data == {"verdict": "attack"}
assert (await mock.complete([ChatMessage("user", "привет")])).text == "здравствуй"
assert (await mock.complete([ChatMessage("user", "что-то")])).text == "mock: что-то"
assert (await mock.complete([ChatMessage("user", "что-то")], SCHEMA)).data == {}
assert len(mock.calls) == 4
await mock.aclose()
def test_factory_picks_provider() -> None:
assert isinstance(make_provider(LLMSettings(provider="mock")), MockProvider)
assert isinstance(make_provider(settings()), OpenAICompatProvider)
@pytest.mark.parametrize(
("text", "expected"),
[('{"a": 1}', {"a": 1}), ('```json\n{"a": 1}\n```', {"a": 1}), ("[1]", None), ("nope", None)],
)
def test_parse_json_object(text: str, expected: dict[str, Any] | None) -> None:
assert parse_json_object(text) == expected
View File
+72
View File
@@ -0,0 +1,72 @@
from datetime import UTC, datetime, timedelta
import pytest
from app.db.models import ChangeOp, User
from app.db.session import SessionMaker
from app.repo.drafts import MAX_CHANGES, DraftFull, DraftService
UID = 7
SHA = "a" * 40
class Clock:
now = datetime(2026, 10, 15, tzinfo=UTC)
def __call__(self) -> datetime:
return self.now
@pytest.fixture
async def svc(sessions: SessionMaker) -> DraftService:
async with sessions.begin() as s:
s.add(User(tg_id=UID, full_name="u"))
return DraftService(sessions, clock=Clock())
async def test_put_get_and_order(svc: DraftService) -> None:
await svc.put(UID, SHA, "b.py", ChangeOp.ADD, b"b")
await svc.put(UID, "other-sha", "a.py", ChangeOp.MODIFY, b"a")
draft = await svc.get(UID)
assert draft is not None and draft.base_sha == SHA # base fixed when the draft started
assert [(c.path, c.op) for c in draft.changes] == [("b.py", ChangeOp.ADD), ("a.py", ChangeOp.MODIFY)]
async def test_editing_a_new_file_keeps_it_added_and_deleting_it_drops_it(svc: DraftService) -> None:
await svc.put(UID, SHA, "n.py", ChangeOp.ADD, b"1")
await svc.put(UID, SHA, "n.py", ChangeOp.MODIFY, b"2")
draft = await svc.get(UID)
assert draft is not None and draft.changes[0].op is ChangeOp.ADD and draft.changes[0].content == b"2"
await svc.put(UID, SHA, "n.py", ChangeOp.DELETE, None)
draft = await svc.get(UID)
assert draft is not None and draft.changes == []
async def test_drop_message_clear(svc: DraftService) -> None:
await svc.put(UID, SHA, "a.py", ChangeOp.ADD, b"1")
await svc.put(UID, SHA, "b.py", ChangeOp.DELETE, None)
await svc.drop(UID, "a.py")
await svc.set_message(UID, "feat: x")
draft = await svc.get(UID)
assert draft is not None and [c.path for c in draft.changes] == ["b.py"] and draft.message == "feat: x"
await svc.clear(UID)
assert await svc.get(UID) is None
async def test_expired_draft_is_gone(svc: DraftService) -> None:
await svc.put(UID, SHA, "a.py", ChangeOp.ADD, b"1")
Clock.now += timedelta(hours=25)
try:
assert await svc.get(UID) is None
finally:
Clock.now -= timedelta(hours=25)
async def test_draft_size_limit(svc: DraftService) -> None:
for i in range(MAX_CHANGES):
await svc.put(UID, SHA, f"f{i}.py", ChangeOp.ADD, b"")
with pytest.raises(DraftFull):
await svc.put(UID, SHA, "one-more.py", ChangeOp.ADD, b"")
+96
View File
@@ -0,0 +1,96 @@
from pathlib import Path
import pytest
from app.repo.git import GitError, UploadsRepo
from tests.conftest import git
async def test_clone_and_list_root_dirs_first(repo: UploadsRepo) -> None:
await repo.refresh()
entries = await repo.list_dir()
assert [(e.name, e.kind) for e in entries] == [
("docs", "dir"),
("many", "dir"),
("services", "dir"),
("README.md", "file"),
]
assert entries[-1].size == len("# uploads\n")
async def test_unicode_and_spaces_in_names(repo: UploadsRepo) -> None:
await repo.refresh()
names = [e.path for e in await repo.list_dir("docs")]
assert names == ["docs/logo.bin", "docs/мои заметки.md"]
assert (await repo.read("docs/мои заметки.md")).decode() == "привет\n"
async def test_last_commit_per_path(repo: UploadsRepo) -> None:
await repo.refresh()
hello = await repo.last_commit("services/hello/main.py")
docs = await repo.last_commit("docs/logo.bin")
assert hello is not None and hello.subject == "init" and hello.author == "Тест"
assert docs is not None and docs.subject == "docs and many" and len(docs.sha) == 40
async def test_missing_dir_is_an_error(repo: UploadsRepo) -> None:
await repo.refresh()
with pytest.raises(GitError, match="no such directory"):
await repo.list_dir("nope")
async def test_refresh_sees_new_commits_and_respects_interval(repo: UploadsRepo, remote: Path, tmp_path: Path) -> None:
await repo.refresh()
before = await repo.head()
other = tmp_path / "other"
git(tmp_path, "clone", "-q", str(remote), str(other))
(other / "new.txt").write_text("x", encoding="utf-8")
git(other, "add", "-A")
git(other, "commit", "-q", "-m", "new")
git(other, "push", "-q", "origin", "main")
await repo.refresh()
assert await repo.head() != before
assert "new.txt" in [e.name for e in await repo.list_dir()]
async def test_fetch_is_throttled(remote: Path, tmp_path: Path) -> None:
from app.config import GitSettings
throttled = UploadsRepo(GitSettings(uploads_url=str(remote), clone_dir=tmp_path / "c2", fetch_interval=3600))
await throttled.refresh()
head = await throttled.head()
other = tmp_path / "o2"
git(tmp_path, "clone", "-q", str(remote), str(other))
(other / "x.txt").write_text("x", encoding="utf-8")
git(other, "add", "-A")
git(other, "commit", "-q", "-m", "x")
git(other, "push", "-q", "origin", "main")
await throttled.refresh()
assert await throttled.head() == head # within the interval: no fetch
await throttled.refresh(force=True)
assert await throttled.head() != head
async def test_unreachable_remote(tmp_path: Path) -> None:
from app.config import GitSettings
broken = UploadsRepo(GitSettings(uploads_url=str(tmp_path / "missing.git"), clone_dir=tmp_path / "c3"))
with pytest.raises(GitError, match="clone"):
await broken.refresh()
async def test_relative_clone_dir(remote: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from app.config import GitSettings
monkeypatch.chdir(tmp_path)
relative = UploadsRepo(GitSettings(uploads_url=str(remote), clone_dir=Path("data/uploads")))
await relative.refresh()
assert (tmp_path / "data" / "uploads" / ".git").is_dir()
assert "README.md" in [e.name for e in await relative.list_dir()]
+143
View File
@@ -0,0 +1,143 @@
from datetime import timedelta
from pathlib import Path
import pytest
from sqlalchemy import select
from app.access.service import AccessService
from app.db.models import ChangeOp, Push, User, utcnow
from app.db.session import SessionMaker
from app.repo.drafts import Change, DraftView
from app.repo.git import UploadsRepo
from app.repo.publisher import PUSHES_PER_HOUR, Publisher, PushStatus
from tests.conftest import ADMIN, git
@pytest.fixture
async def member(access: AccessService) -> User:
user = await access.get_member(ADMIN)
assert user is not None
return user
def remote_log(remote: Path, tmp_path: Path, name: str = "check") -> tuple[Path, str]:
clone = tmp_path / name
git(tmp_path, "clone", "-q", str(remote), str(clone))
return clone, git(clone, "log", "-1", "--format=%an <%ae>%n%B")
def foreign_commit(remote: Path, tmp_path: Path, path: str, text: str, name: str) -> None:
other = tmp_path / name
git(tmp_path, "clone", "-q", str(remote), str(other))
target = other / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text, encoding="utf-8")
git(other, "add", "-A")
git(other, "commit", "-q", "-m", f"foreign {path}")
git(other, "push", "-q", "origin", "main")
async def draft_on(repo: UploadsRepo, *changes: Change) -> DraftView:
await repo.refresh(force=True)
return DraftView(base_sha=await repo.head(), message=None, changes=list(changes))
async def test_push_applies_changes_with_trailer(
repo: UploadsRepo, sessions: SessionMaker, member: User, remote: Path, tmp_path: Path
) -> None:
draft = await draft_on(
repo,
Change("services/hello/utils.py", ChangeOp.ADD, b"X = 1\n"),
Change("services/hello/main.py", ChangeOp.MODIFY, b"print('v2')\n"),
Change("docs/logo.bin", ChangeOp.DELETE, None),
Change("docs/мои заметки.md", ChangeOp.DELETE, None),
)
publisher = Publisher(repo, sessions)
result = await publisher.push(member, draft, "feat: v2\n\nподробности")
assert result.status is PushStatus.OK and result.sha is not None
clone, log = remote_log(remote, tmp_path)
assert log.startswith("tgit-bot <tgit-bot@users.noreply.tgit>\nfeat: v2\n\nподробности\n\nTelegram-User: ")
assert f"({ADMIN})" in log
assert (clone / "services/hello/utils.py").read_text() == "X = 1\n"
assert (clone / "services/hello/main.py").read_text() == "print('v2')\n"
assert not (clone / "docs").exists() # both files gone → the folder is gone too
assert git(clone, "rev-parse", "HEAD").strip() == result.sha
async def test_nothing_to_commit(repo: UploadsRepo, sessions: SessionMaker, member: User) -> None:
draft = await draft_on(repo, Change("README.md", ChangeOp.MODIFY, b"# uploads\n"))
assert (await Publisher(repo, sessions).push(member, draft, "noop")).status is PushStatus.EMPTY
async def test_conflict_then_overwrite(
repo: UploadsRepo, sessions: SessionMaker, member: User, remote: Path, tmp_path: Path
) -> None:
draft = await draft_on(repo, Change("README.md", ChangeOp.MODIFY, b"mine\n"))
foreign_commit(remote, tmp_path, "README.md", "theirs\n", "other")
publisher = Publisher(repo, sessions)
conflict = await publisher.push(member, draft, "edit readme")
assert conflict.status is PushStatus.CONFLICT and conflict.conflicts == ["README.md"]
forced = await publisher.push(member, draft, "edit readme", overwrite=True)
assert forced.status is PushStatus.OK
clone, _ = remote_log(remote, tmp_path)
assert (clone / "README.md").read_text() == "mine\n"
async def test_unrelated_change_in_main_is_not_a_conflict(
repo: UploadsRepo, sessions: SessionMaker, member: User, remote: Path, tmp_path: Path
) -> None:
draft = await draft_on(repo, Change("a.txt", ChangeOp.ADD, b"a"))
foreign_commit(remote, tmp_path, "b.txt", "b", "other")
assert (await Publisher(repo, sessions).push(member, draft, "add a")).status is PushStatus.OK
clone, _ = remote_log(remote, tmp_path)
assert (clone / "a.txt").exists() and (clone / "b.txt").exists()
async def test_rejected_push_is_rebased(
repo: UploadsRepo, sessions: SessionMaker, member: User, remote: Path, tmp_path: Path
) -> None:
raced = False
async def someone_pushes_first() -> None:
nonlocal raced
if not raced:
raced = True
foreign_commit(remote, tmp_path, "race.txt", "r", "racer")
draft = await draft_on(repo, Change("mine.txt", ChangeOp.ADD, b"m"))
result = await Publisher(repo, sessions, before_push=someone_pushes_first).push(member, draft, "add mine")
assert result.status is PushStatus.OK and raced
clone, log = remote_log(remote, tmp_path)
assert "add mine" in log and (clone / "race.txt").exists() and (clone / "mine.txt").exists()
async def test_path_escape_is_refused(repo: UploadsRepo, sessions: SessionMaker, member: User, tmp_path: Path) -> None:
draft = await draft_on(repo, Change("../evil.txt", ChangeOp.ADD, b"x"))
assert (await Publisher(repo, sessions).push(member, draft, "evil")).status is PushStatus.FAILED
assert not (tmp_path / "evil.txt").exists()
async def test_rate_limit_and_record(repo: UploadsRepo, sessions: SessionMaker, member: User) -> None:
publisher = Publisher(repo, sessions)
draft = await draft_on(repo, Change("x.txt", ChangeOp.ADD, b"x"))
result = await publisher.push(member, draft, "x")
await publisher.record(member, result, "x", draft, chat_id=ADMIN, message_id=77)
async with sessions() as s:
push = await s.scalar(select(Push))
assert push is not None and push.sha == result.sha and push.paths == ["x.txt"] and push.message_id == 77
for i in range(PUSHES_PER_HOUR):
s.add(
Push(user_id=ADMIN, sha=f"{i:040d}", message="m", chat_id=1, created_at=utcnow() - timedelta(minutes=1))
)
await s.commit()
assert (await publisher.push(member, draft, "y")).status is PushStatus.LIMIT
+74
View File
@@ -0,0 +1,74 @@
import pytest
from app.repo.validate import (
ValidationError,
check_content,
check_name,
diff_stat,
is_protected,
join,
python_syntax,
strip_fence,
)
@pytest.mark.parametrize("name", ["main.py", "a-b_c.1", ".gitignore", "X" * 100])
def test_good_names(name: str) -> None:
assert check_name(f" {name} ") == name
@pytest.mark.parametrize("name", ["", ".", "..", "a/b", "привет.py", "a b", "X" * 101, "a;rm"])
def test_bad_names(name: str) -> None:
with pytest.raises(ValidationError):
check_name(name)
@pytest.mark.parametrize(
("path", "protected"),
[
(".sourcecraft/ci.yaml", True),
(".tgit/x.py", True),
("ruff.toml", True),
(".sourcecraft", True),
("services/a/ruff.toml", False),
("README.md", False),
("services/a/main.py", False),
],
)
def test_protected_paths(path: str, protected: bool) -> None:
assert is_protected(path) is protected
def test_join() -> None:
assert join("", "a.py") == "a.py" and join("s/x", "a.py") == "s/x/a.py"
@pytest.mark.parametrize(
("text", "expected"),
[
("```python\nprint(1)\n```", "print(1)"),
("```\na\nb\n```", "a\nb"),
("print(1)", "print(1)"),
("x ``` y", "x ``` y"),
],
)
def test_strip_fence(text: str, expected: str) -> None:
assert strip_fence(text) == expected
def test_content_checks() -> None:
assert check_content("привет".encode()) == "привет"
for data, reason in [(b"a\0b", "binary"), (b"\xff\xfe", "encoding"), (b"x" * (100 * 1024 + 1), "too_big")]:
with pytest.raises(ValidationError, match=reason):
check_content(data)
def test_python_syntax_only_for_py() -> None:
issue = python_syntax("a.py", "def f(:\n pass\n")
assert issue is not None and issue.line == 1
assert python_syntax("a.py", "x = 1\n") is None
assert python_syntax("a.txt", "def f(:") is None
def test_diff_stat() -> None:
assert diff_stat("a\nb\nc", "a\nB\nc\nd") == (2, 1)
+3 -2
View File
@@ -23,8 +23,9 @@ def test_age_decides(tmp_path: Path) -> None:
async def test_heartbeat_touches_file(tmp_path: Path) -> None:
hb = tmp_path / "hb"
task = asyncio.create_task(health.heartbeat(hb, interval=10))
await asyncio.sleep(0)
assert hb.exists()
async with asyncio.timeout(2):
while not hb.exists(): # noqa: ASYNC110 - polling the filesystem; touch runs in a worker thread
await asyncio.sleep(0.01)
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
Generated
+24
View File
@@ -74,6 +74,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" },
]
[[package]]
name = "aiohttp-socks"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "python-socks" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/1d/a306e0111222180e60f17131a3f5d9bc694dd999a8115959a7dd76c2238e/aiohttp_socks-0.12.0.tar.gz", hash = "sha256:3caf9f5a4164611122d412bc11b2f9114fd29c85e1ba27bb38060d3c236bdc8d", size = 12061, upload-time = "2026-08-12T04:43:15.791Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/64/ca6289632020523ea1841f01363f83ada10eeb0f21dd931fc1118dd85668/aiohttp_socks-0.12.0-py3-none-any.whl", hash = "sha256:ba6f95ec775c761d87f8578ab48f137d0457c676da104984202bf75e747d5ee6", size = 10693, upload-time = "2026-08-12T04:43:14.524Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
@@ -642,6 +655,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[package]]
name = "python-socks"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/ad/484ffb79532517b11a90af38647c38652224650b31a7ae1cedd5a418d8ab/python_socks-3.1.1.tar.gz", hash = "sha256:8d3e817cdbe858dc0bb8c8fdc8e79b6ce37acce110d33374c6f57a675cc9029e", size = 232781, upload-time = "2026-09-08T13:03:31.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/23/2c2cef1b4313c55d1713201acd4ee2043fb2cf22e546ca9d36bf4317faea/python_socks-3.1.1-py3-none-any.whl", hash = "sha256:327e0d6378702c73a7790bf732e9f01392f17b48c7348a50b5bd1f710c2df1be", size = 49566, upload-time = "2026-09-08T13:03:29.604Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -763,6 +785,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiogram" },
{ name = "aiohttp-socks" },
{ name = "alembic" },
{ name = "asyncpg" },
{ name = "httpx" },
@@ -789,6 +812,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiogram", specifier = ">=3.13" },
{ name = "aiohttp-socks", specifier = ">=0.9" },
{ name = "alembic", specifier = ">=1.13" },
{ name = "asyncpg", specifier = ">=0.29" },
{ name = "httpx", specifier = ">=0.27" },