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
37 changed files with 2447 additions and 22 deletions
+4 -1
View File
@@ -5,6 +5,7 @@
TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather
ADMIN_IDS= # Telegram ID первых администраторов через запятую ADMIN_IDS= # Telegram ID первых администраторов через запятую
LOG_LEVEL=INFO LOG_LEVEL=INFO
TELEGRAM_PROXY_URL= # (secret) http://user:pass@host:port или socks5://… — на RF-сервере Telegram недоступен напрямую
# --- База данных --- # --- База данных ---
POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postgres в Compose POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postgres в Compose
@@ -19,7 +20,9 @@ SOURCECRAFT_UPLOADS_REPO=rogachevegorushka/tgit-uploads
# --- Git: коммиты в репозиторий загрузок (#7, #9) --- # --- Git: коммиты в репозиторий загрузок (#7, #9) ---
GIT_SSH_KEY_PATH= # путь к приватному deploy-ключу бота (монтируется read-only); GIT_SSH_KEY_PATH= # путь к приватному deploy-ключу бота (монтируется read-only);
# публичная часть — deploy key с правом записи в tgit-uploads # публичная часть — 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 (#17) ---
LLM_PROVIDER=mock # mock | openai_compat 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
+2 -1
View File
@@ -24,5 +24,6 @@ build/
.claude/worktrees/ .claude/worktrees/
.DS_Store .DS_Store
# local SQLite DB (bot without Docker) # local run without Docker: SQLite DB, uploads clone, demo repo
*.db *.db
data/
+1
View File
@@ -49,6 +49,7 @@ Telegram-бот «git в телеграме» для команды (учебн
| [`docs/dev/ISSUE_TEMPLATE.md`](docs/dev/ISSUE_TEMPLATE.md) | Шаблон тела issue | | [`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/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/SOURCECRAFT_API.md`](docs/dev/SOURCECRAFT_API.md) | SourceCraft API: CI-прогоны, логи, секреты |
| [`docs/dev/DEPLOY.md`](docs/dev/DEPLOY.md) | **Деплой:** сервер, окружения dev/prod, руками, откат |
--- ---
+2
View File
@@ -19,6 +19,8 @@ ENV PATH="/opt/venv/bin:$PATH" PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
COPY --from=build /opt/venv /opt/venv COPY --from=build /opt/venv /opt/venv
WORKDIR /srv/tgit WORKDIR /srv/tgit
COPY config ./config COPY config ./config
# /data: the uploads repo clone (named volume inherits this ownership)
RUN install -d -o tgit -g tgit /data
USER tgit USER tgit
# Liveness: the bot touches a heartbeat file while its event loop runs (app/health.py) # 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"] 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: install:
uv sync uv sync
@@ -30,3 +30,18 @@ logs:
ps: ps:
docker compose 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)"
+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
+2 -2
View File
@@ -16,7 +16,6 @@ def build_router(texts: Texts, access: AccessService) -> Router:
router = Router(name="common") router = Router(name="common")
sections = section_by_button(texts) sections = section_by_button(texts)
section_text = { section_text = {
Section.COMMIT: texts.section.commit,
Section.STATUS: texts.section.status, Section.STATUS: texts.section.status,
Section.LOGS: texts.section.logs, Section.LOGS: texts.section.logs,
Section.ALERTS: texts.section.alerts, Section.ALERTS: texts.section.alerts,
@@ -49,7 +48,8 @@ def build_router(texts: Texts, access: AccessService) -> Router:
async def help_(message: Message) -> None: async def help_(message: Message) -> None:
await message.answer(texts.help.text) await message.answer(texts.help.text)
@router.message(F.text.in_({k for k, v in sections.items() if v is not Section.MEMBERS})) # 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: async def open_section(message: Message, state: FSMContext) -> None:
await state.clear() await state.clear()
await message.answer(section_text[sections[message.text or ""]], reply_markup=section_nav(texts)) await message.answer(section_text[sections[message.text or ""]], reply_markup=section_nav(texts))
+21
View File
@@ -13,6 +13,8 @@ class TelegramSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="TELEGRAM_") model_config = SettingsConfigDict(**_ENV, env_prefix="TELEGRAM_")
bot_token: SecretStr 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): class AccessSettings(BaseSettings):
@@ -64,6 +66,24 @@ class SourceCraftSettings(BaseSettings):
max_attempts: int = Field(default=5, ge=1) 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): class RuntimeSettings(BaseSettings):
model_config = _ENV model_config = _ENV
@@ -84,4 +104,5 @@ class Settings(BaseSettings):
database: DatabaseSettings = Field(default_factory=DatabaseSettings) database: DatabaseSettings = Field(default_factory=DatabaseSettings)
llm: LLMSettings = Field(default_factory=LLMSettings) llm: LLMSettings = Field(default_factory=LLMSettings)
sourcecraft: SourceCraftSettings = Field(default_factory=SourceCraftSettings) sourcecraft: SourceCraftSettings = Field(default_factory=SourceCraftSettings)
git: GitSettings = Field(default_factory=GitSettings)
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings) runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
+21 -5
View File
@@ -5,30 +5,39 @@ import contextlib
import logging import logging
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
from app import __version__ from app import __version__
from app.access.service import AccessService from app.access.service import AccessService
from app.bot.fsm import make_storage from app.bot.fsm import make_storage
from app.bot.middlewares.access import AccessMiddleware from app.bot.middlewares.access import AccessMiddleware
from app.bot.routers import common, members from app.bot.routers import commit, common, members
from app.config import Settings from app.config import Settings
from app.db.maintenance import cleanup_loop from app.db.maintenance import cleanup_loop
from app.db.migrate import upgrade from app.db.migrate import upgrade
from app.db.session import SessionMaker, make_engine, make_sessionmaker from app.db.session import SessionMaker, make_engine, make_sessionmaker
from app.health import heartbeat from app.health import heartbeat
from app.logs_setup import setup_logging 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 from app.texts import Texts, load_texts
log = logging.getLogger("tgit") log = logging.getLogger("tgit")
def build_dispatcher(texts: Texts, sessions: SessionMaker, access: AccessService) -> Dispatcher: def build_dispatcher(
texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo, commit_url_template: str = ""
) -> Dispatcher:
dp = Dispatcher(storage=make_storage(sessions)) dp = Dispatcher(storage=make_storage(sessions))
gate = AccessMiddleware(access, texts) gate = AccessMiddleware(access, texts)
# Outer middleware: runs before filters, so nothing — not even a stale button — reaches a handler unchecked. # Outer middleware: runs before filters, so nothing — not even a stale button — reaches a handler unchecked.
dp.message.outer_middleware(gate) dp.message.outer_middleware(gate)
dp.callback_query.outer_middleware(gate) dp.callback_query.outer_middleware(gate)
dp.include_router(members.build_router(texts, access)) 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)) dp.include_router(common.build_router(texts, access))
return dp return dp
@@ -40,14 +49,21 @@ async def run(settings: Settings) -> None:
sessions = make_sessionmaker(engine) sessions = make_sessionmaker(engine)
access = AccessService(sessions, settings.access) access = AccessService(sessions, settings.access)
await access.bootstrap_admins() await access.bootstrap_admins()
dp = build_dispatcher(texts, sessions, access) dp = build_dispatcher(texts, sessions, access, UploadsRepo(settings.git), settings.git.commit_url_template)
rt = settings.runtime rt = settings.runtime
background = [ background = [
asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)), asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)),
asyncio.create_task(cleanup_loop(sessions)), asyncio.create_task(cleanup_loop(sessions)),
] ]
bot = Bot(settings.telegram.bot_token.get_secret_value()) proxy = settings.telegram.proxy_url
log.info("tgit %s starting, %d bootstrap admin(s)", __version__, len(settings.access.admin_ids)) 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: try:
await dp.start_polling(bot, handle_signals=True) await dp.start_polling(bot, handle_signals=True)
finally: finally:
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
+71
View File
@@ -54,12 +54,83 @@ class MembersTexts(_Strict):
invite_caption: 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): class Texts(_Strict):
menu: MenuTexts menu: MenuTexts
start: StartTexts start: StartTexts
help: HelpTexts help: HelpTexts
section: SectionTexts section: SectionTexts
members: MembersTexts members: MembersTexts
commit: CommitTexts
def load_texts(config_dir: Path) -> Texts: def load_texts(config_dir: Path) -> Texts:
+82
View File
@@ -52,3 +52,85 @@ members:
{link} {link}
Одноразовое, действует до {expires} UTC. Одноразовое, действует до {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 env_file: .env
environment: environment:
DATABASE_URL: postgresql+asyncpg://tgit:${POSTGRES_PASSWORD:-tgit}@postgres:5432/tgit DATABASE_URL: postgresql+asyncpg://tgit:${POSTGRES_PASSWORD:-tgit}@postgres:5432/tgit
GIT_CLONE_DIR: /data/uploads
volumes:
- uploads:/data
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
mem_limit: 256m mem_limit: 384m # ~190 MB idle; git clone/push and aiogram spikes need headroom
labels: labels:
tgit.managed: "true" tgit.managed: "true"
logging: logging:
@@ -37,3 +40,4 @@ services:
volumes: volumes:
pgdata: pgdata:
uploads:
+8
View File
@@ -129,3 +129,11 @@
**Решение:** первый куб задачи `check` в `tgit-uploads` — `meta`: `echo "TGIT_COMMIT_SHA=$SOURCECRAFT_COMMIT_SHA"`. Бот читает его лог (`SourceCraftClient.run_commit_sha`) и сопоставляет прогон с пушем. **Решение:** первый куб задачи `check` в `tgit-uploads` — `meta`: `echo "TGIT_COMMIT_SHA=$SOURCECRAFT_COMMIT_SHA"`. Бот читает его лог (`SourceCraftClient.run_commit_sha`) и сопоставляет прогон с пушем.
**Альтернативы:** запускать CI из бота вручную (`POST /cicd/runs` с `head.commit`) — номер прогона известен сразу, но push-триггер пришлось бы убрать, и ручные пуши в репозиторий остались бы без CI; сопоставление по времени — ломается при параллельных пушах. **Альтернативы:** запускать CI из бота вручную (`POST /cicd/runs` с `head.commit`) — номер прогона известен сразу, но push-триггер пришлось бы убрать, и ручные пуши в репозиторий остались бы без CI; сопоставление по времени — ломается при параллельных пушах.
**Последствия:** +1 короткий куб на прогон; куб `meta` нельзя удалять или переименовывать (он в `.sourcecraft/`, который бот защищает от правок); прогоны старше этого решения sha не имеют. **Последствия:** +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", "pyyaml>=6.0",
"regex>=2024.7", "regex>=2024.7",
"segno>=1.6", "segno>=1.6",
"aiohttp-socks>=0.9",
] ]
[dependency-groups] [dependency-groups]
@@ -42,6 +43,8 @@ extend-exclude = [".claude", "docs"]
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM", "ASYNC", "RUF"] 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] [tool.mypy]
python_version = "3.12" python_version = "3.12"
+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") == "быстро"
+4 -3
View File
@@ -6,14 +6,15 @@ from app.access.service import AccessService
from app.bot.menu import Nav, NavAction from app.bot.menu import Nav, NavAction
from app.db.session import SessionMaker from app.db.session import SessionMaker
from app.main import build_dispatcher from app.main import build_dispatcher
from app.repo.git import UploadsRepo
from app.texts import Texts from app.texts import Texts
from tests.conftest import ADMIN, OUTSIDER from tests.conftest import ADMIN, OUTSIDER
from tests.fakes import TgClient from tests.fakes import TgClient
@pytest.fixture @pytest.fixture
def tg(texts: Texts, sessions: SessionMaker, access: AccessService) -> TgClient: def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo) -> TgClient:
return TgClient(build_dispatcher(texts, sessions, access)) return TgClient(build_dispatcher(texts, sessions, access, repo))
def buttons(markup: ReplyKeyboardMarkup) -> list[str]: def buttons(markup: ReplyKeyboardMarkup) -> list[str]:
@@ -45,7 +46,7 @@ async def test_outsider_stale_button_is_swallowed(tg: TgClient) -> None:
assert len(tg.session.sent(AnswerCallbackQuery)) == 1 assert len(tg.session.sent(AnswerCallbackQuery)) == 1
@pytest.mark.parametrize("section", ["commit", "status", "logs", "alerts"]) @pytest.mark.parametrize("section", ["status", "logs", "alerts"])
async def test_menu_button_opens_section_with_nav(tg: TgClient, texts: Texts, section: str) -> None: async def test_menu_button_opens_section_with_nav(tg: TgClient, texts: Texts, section: str) -> None:
await tg.send(ADMIN, getattr(texts.menu, section)) await tg.send(ADMIN, getattr(texts.menu, section))
+3 -2
View File
@@ -8,6 +8,7 @@ from app.access.service import AccessService
from app.bot.routers.members import MembersAction, MembersCb from app.bot.routers.members import MembersAction, MembersCb
from app.db.session import SessionMaker from app.db.session import SessionMaker
from app.main import build_dispatcher from app.main import build_dispatcher
from app.repo.git import UploadsRepo
from app.texts import Texts from app.texts import Texts
from tests.conftest import ADMIN from tests.conftest import ADMIN
from tests.fakes import TgClient from tests.fakes import TgClient
@@ -16,8 +17,8 @@ NEWBIE = 3003
@pytest.fixture @pytest.fixture
def tg(texts: Texts, sessions: SessionMaker, access: AccessService) -> TgClient: def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo) -> TgClient:
return TgClient(build_dispatcher(texts, sessions, access)) return TgClient(build_dispatcher(texts, sessions, access, repo))
async def invite_token(tg: TgClient) -> str: async def invite_token(tg: TgClient) -> str:
+44 -1
View File
@@ -1,3 +1,4 @@
import subprocess
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from pathlib import Path from pathlib import Path
@@ -5,9 +6,10 @@ import pytest
from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy.ext.asyncio import AsyncEngine
from app.access.service import AccessService from app.access.service import AccessService
from app.config import AccessSettings, RuntimeSettings, Settings, TelegramSettings from app.config import AccessSettings, GitSettings, RuntimeSettings, Settings, TelegramSettings
from app.db.migrate import upgrade from app.db.migrate import upgrade
from app.db.session import SessionMaker, make_engine, make_sessionmaker from app.db.session import SessionMaker, make_engine, make_sessionmaker
from app.repo.git import UploadsRepo
from app.texts import Texts, load_texts from app.texts import Texts, load_texts
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
@@ -48,3 +50,44 @@ async def access(sessions: SessionMaker, settings: Settings) -> AccessService:
service = AccessService(sessions, settings.access) service = AccessService(sessions, settings.access)
await service.bootstrap_admins() await service.bootstrap_admins()
return service 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))
+22 -5
View File
@@ -7,9 +7,9 @@ from typing import Any
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.session.base import BaseSession from aiogram.client.session.base import BaseSession
from aiogram.methods import EditMessageText, GetMe, SendMessage, SendPhoto, TelegramMethod from aiogram.methods import EditMessageText, GetFile, GetMe, SendMessage, SendPhoto, TelegramMethod
from aiogram.methods.base import TelegramType 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" BOT_TOKEN = "42:TEST-TOKEN"
@@ -19,6 +19,7 @@ class FakeSession(BaseSession):
super().__init__() super().__init__()
self.requests: list[TelegramMethod[Any]] = [] self.requests: list[TelegramMethod[Any]] = []
self._ids = count(1000) self._ids = count(1000)
self.files: dict[str, bytes] = {} # file_id -> content, for documents users "upload"
async def make_request( async def make_request(
self, self,
@@ -27,6 +28,10 @@ class FakeSession(BaseSession):
timeout: int | None = None, # noqa: ASYNC109 - signature fixed by aiogram BaseSession timeout: int | None = None, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
) -> TelegramType: ) -> TelegramType:
self.requests.append(method) 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): if isinstance(method, GetMe):
return User(id=42, is_bot=True, first_name="tgit", username="tgit_test_bot") # type: ignore[return-value] return User(id=42, is_bot=True, first_name="tgit", username="tgit_test_bot") # type: ignore[return-value]
if isinstance(method, SendPhoto): if isinstance(method, SendPhoto):
@@ -54,9 +59,9 @@ class FakeSession(BaseSession):
timeout: int = 30, # noqa: ASYNC109 - signature fixed by aiogram BaseSession timeout: int = 30, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
chunk_size: int = 65536, chunk_size: int = 65536,
raise_for_status: bool = True, raise_for_status: bool = True,
) -> AsyncGenerator[bytes, None]: # pragma: no cover - not used ) -> AsyncGenerator[bytes, None]:
raise NotImplementedError file_id = url.rsplit("/", 1)[-1]
yield b"" yield self.files[file_id]
async def close(self) -> None: async def close(self) -> None:
pass pass
@@ -92,6 +97,18 @@ class TgClient:
message["entities"] = [{"type": "bot_command", "offset": 0, "length": len(cmd)}] message["entities"] = [{"type": "bot_command", "offset": 0, "length": len(cmd)}]
await self._feed({"message": message}) 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: async def press(self, tg_id: int, data: str, message_text: str = "section") -> None:
callback = { callback = {
"id": str(next(self._update_ids)), "id": str(next(self._update_ids)),
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)
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" }, { 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]] [[package]]
name = "aiosignal" name = "aiosignal"
version = "1.4.0" 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" }, { 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]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"
@@ -763,6 +785,7 @@ version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiogram" }, { name = "aiogram" },
{ name = "aiohttp-socks" },
{ name = "alembic" }, { name = "alembic" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "httpx" }, { name = "httpx" },
@@ -789,6 +812,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiogram", specifier = ">=3.13" }, { name = "aiogram", specifier = ">=3.13" },
{ name = "aiohttp-socks", specifier = ">=0.9" },
{ name = "alembic", specifier = ">=1.13" }, { name = "alembic", specifier = ">=1.13" },
{ name = "asyncpg", specifier = ">=0.29" }, { name = "asyncpg", specifier = ">=0.29" },
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },