5 Commits
Author SHA1 Message Date
Eleboba 873111aade Merge branch 'dev' into feat/25-qr-invites 2026-10-15 19:30:54 +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
27 changed files with 2079 additions and 17 deletions
+3 -1
View File
@@ -19,7 +19,9 @@ SOURCECRAFT_UPLOADS_REPO=rogachevegorushka/tgit-uploads
# --- Git: коммиты в репозиторий загрузок (#7, #9) ---
GIT_SSH_KEY_PATH= # путь к приватному deploy-ключу бота (монтируется read-only);
# публичная часть — deploy key с правом записи в tgit-uploads
UPLOADS_GIT_URL=git@ssh.sourcecraft.dev:rogachevegorushka/tgit-uploads.git
GIT_SSH_KEY_B64= # (secret) для Docker: тот же ключ в base64 — base64 -w0 ~/.ssh/tgit_bot_ed25519
GIT_UPLOADS_URL=git@ssh.sourcecraft.dev:rogachevegorushka/tgit-uploads.git
# GIT_CLONE_DIR=data/uploads # локальный клон репозитория загрузок (в Docker — volume /data/uploads)
# --- LLM (#17) ---
LLM_PROVIDER=mock # mock | openai_compat
+2
View File
@@ -19,6 +19,8 @@ ENV PATH="/opt/venv/bin:$PATH" PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
COPY --from=build /opt/venv /opt/venv
WORKDIR /srv/tgit
COPY config ./config
# /data: the uploads repo clone (named volume inherits this ownership)
RUN install -d -o tgit -g tgit /data
USER tgit
# Liveness: the bot touches a heartbeat file while its event loop runs (app/health.py)
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 CMD ["python", "-m", "app.health"]
+584
View File
@@ -0,0 +1,584 @@
"""«📝 Коммит»: 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"
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:
kb.button(text=t.draft_button.format(count=draft_count), callback_data=CommitCb(a=Act.DRAFT))
sizes.append(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 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 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
builder = InlineKeyboardBuilder()
builder.button(text=t.push_go, callback_data=CommitCb(a=Act.PUSH_GO))
builder.button(text=texts.menu.back, callback_data=CommitCb(a=Act.DRAFT))
confirm = t.push_confirm.format(summary=summary(draft), message=draft.message)
await msg.edit_text(confirm, reply_markup=builder.as_markup())
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)
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")
sections = section_by_button(texts)
section_text = {
Section.COMMIT: texts.section.commit,
Section.STATUS: texts.section.status,
Section.LOGS: texts.section.logs,
Section.ALERTS: texts.section.alerts,
@@ -49,7 +48,8 @@ def build_router(texts: Texts, access: AccessService) -> Router:
async def help_(message: Message) -> None:
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:
await state.clear()
await message.answer(section_text[sections[message.text or ""]], reply_markup=section_nav(texts))
+19
View File
@@ -64,6 +64,24 @@ class SourceCraftSettings(BaseSettings):
max_attempts: int = Field(default=5, ge=1)
class GitSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="GIT_")
# The bot's own deploy key with write access to tgit-uploads only (#7)
uploads_url: str = "git@ssh.sourcecraft.dev:rogachevegorushka/tgit-uploads.git"
ssh_key_path: Path | None = None
# Alternative for containers: the private key itself, base64 — a mounted host file is unreadable for the
# container user (uid 10001 vs the owner's 0600). Written to a private temp file at startup.
ssh_key_b64: SecretStr | None = None
branch: str = "main"
# Web link to a commit in the uploads repo, e.g. https://sourcecraft.dev/{repo}/commit/{sha}; empty — no link
commit_url_template: str = ""
clone_dir: Path = Path("data/uploads")
# Browsing re-fetches at most this often; a push always fetches first (#9)
fetch_interval: float = Field(default=10.0, ge=0)
timeout: float = Field(default=60.0, gt=0)
class RuntimeSettings(BaseSettings):
model_config = _ENV
@@ -84,4 +102,5 @@ class Settings(BaseSettings):
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
llm: LLMSettings = Field(default_factory=LLMSettings)
sourcecraft: SourceCraftSettings = Field(default_factory=SourceCraftSettings)
git: GitSettings = Field(default_factory=GitSettings)
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
+11 -3
View File
@@ -10,25 +10,33 @@ from app import __version__
from app.access.service import AccessService
from app.bot.fsm import make_storage
from app.bot.middlewares.access import AccessMiddleware
from app.bot.routers import common, members
from app.bot.routers import commit, common, members
from app.config import Settings
from app.db.maintenance import cleanup_loop
from app.db.migrate import upgrade
from app.db.session import SessionMaker, make_engine, make_sessionmaker
from app.health import heartbeat
from app.logs_setup import setup_logging
from app.repo.drafts import DraftService
from app.repo.git import UploadsRepo
from app.repo.publisher import Publisher
from app.texts import Texts, load_texts
log = logging.getLogger("tgit")
def build_dispatcher(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))
gate = AccessMiddleware(access, texts)
# Outer middleware: runs before filters, so nothing — not even a stale button — reaches a handler unchecked.
dp.message.outer_middleware(gate)
dp.callback_query.outer_middleware(gate)
dp.include_router(members.build_router(texts, access))
dp.include_router(
commit.build_router(texts, repo, DraftService(sessions), Publisher(repo, sessions), commit_url_template)
)
dp.include_router(common.build_router(texts, access))
return dp
@@ -40,7 +48,7 @@ async def run(settings: Settings) -> None:
sessions = make_sessionmaker(engine)
access = AccessService(sessions, settings.access)
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
background = [
asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)),
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)
+167
View File
@@ -0,0 +1,167 @@
"""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
self._dir = settings.clone_dir
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
+69
View File
@@ -54,12 +54,81 @@ class MembersTexts(_Strict):
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
push_confirm: str
push_go: str
pushing: str
pushed: str
ci_pending: str
conflict: str
overwrite: str
push_empty: str
push_limit: str
push_failed: str
class Texts(_Strict):
menu: MenuTexts
start: StartTexts
help: HelpTexts
section: SectionTexts
members: MembersTexts
commit: CommitTexts
def load_texts(config_dir: Path) -> Texts:
+80
View File
@@ -52,3 +52,83 @@ members:
{link}
Одноразовое, действует до {expires} UTC.
commit:
title: "📝 Коммит · /{path}"
empty: "Папка пуста."
page: "Стр. {page} из {pages}"
up: "⬆️ Вверх"
prev: "◀️"
next: "▶️"
dir_button: "📁 {name}"
file_button: "📄 {name} · {size}"
new_file: "➕ Новый файл"
new_dir: "📁 Новая папка"
file_card: |-
📄 /{path}
Размер: {size}
last_commit: |-
Последний коммит: {sha} · {author} · {date}
{subject}
no_commits: "Коммитов по файлу нет."
view: "👁 Показать"
replace: "✏️ Заменить"
delete: "🗑 Удалить"
to_dir: "⬅️ К папке"
soon: "Будет в следующей версии."
unavailable: "Репозиторий загрузок недоступен, попробуйте через минуту."
sent_as_file: "Файл большой или бинарный — отправил документом."
ask_file_name: "Имя нового файла в /{dir} (латиница, цифры, «.», «_», «-»):"
ask_dir_name: "Имя новой папки в /{dir}:"
bad_name: "Недопустимое имя: латиница, цифры, «.», «_», «-», до 100 символов. Пришлите другое."
exists: "Такое имя уже есть в этой папке. Пришлите другое."
protected: "Это служебный путь репозитория — из бота его менять нельзя."
ask_content: "Пришлите содержимое /{path} — текстом сообщения или файлом (до 100 КБ)."
too_big: "Больше 100 КБ — не принимаю."
binary: "Похоже на бинарный файл — принимаю только текст."
encoding: "Файл не в кодировке UTF-8."
syntax: "⚠️ Синтаксическая ошибка, строка {line}: {message}"
fix: "✏️ Исправить"
force: "Всё равно добавить"
added: "🆕 /{path} — добавлен в черновик."
modified: "✏️ /{path} — в черновике: +{added} −{removed}."
deleted: "🗑 /{path} — в черновике на удаление."
draft_button: "🧾 Черновик · {count}"
draft_title: "🧾 Черновик · изменений: {count}"
draft_empty: "Черновик пуст."
unstage: "↩️ убрать {name}"
draft_full: "В черновике уже 50 изменений — запушьте или уберите лишнее."
in_draft_new: "В черновике: новый файл."
in_draft_modified: "В черновике: изменён."
in_draft_deleted: "В черновике: удалён."
mark_new: "🆕 "
mark_modified: "✏️ "
mark_deleted: "🗑 "
op_add: "A"
op_modify: "M"
op_delete: "D"
message_button: "💬 Сообщение коммита"
message_current: "💬 {message}"
message_missing: "💬 Сообщение коммита не задано."
ask_message: "Сообщение коммита: до 200 символов, первая строка — до 72."
bad_message: "Сообщение: 1–200 символов, первая строка — до 72. Пришлите другое."
push_button: "🚀 Push"
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: "Не удалось запушить: репозиторий недоступен. Черновик сохранён — попробуйте позже."
+4
View File
@@ -5,6 +5,9 @@ services:
env_file: .env
environment:
DATABASE_URL: postgresql+asyncpg://tgit:${POSTGRES_PASSWORD:-tgit}@postgres:5432/tgit
GIT_CLONE_DIR: /data/uploads
volumes:
- uploads:/data
depends_on:
postgres:
condition: service_healthy
@@ -37,3 +40,4 @@ services:
volumes:
pgdata:
uploads:
+2
View File
@@ -42,6 +42,8 @@ extend-exclude = [".claude", "docs"]
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM", "ASYNC", "RUF"]
# Russian UI texts, commit messages and comments are normal here, not homoglyph attacks
ignore = ["RUF001", "RUF002", "RUF003"]
[tool.mypy]
python_version = "3.12"
+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
+65
View File
@@ -0,0 +1,65 @@
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))
+4 -3
View File
@@ -6,14 +6,15 @@ from app.access.service import AccessService
from app.bot.menu import Nav, NavAction
from app.db.session import SessionMaker
from app.main import build_dispatcher
from app.repo.git import UploadsRepo
from app.texts import Texts
from tests.conftest import ADMIN, OUTSIDER
from tests.fakes import TgClient
@pytest.fixture
def tg(texts: Texts, sessions: SessionMaker, access: AccessService) -> TgClient:
return TgClient(build_dispatcher(texts, sessions, access))
def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo) -> TgClient:
return TgClient(build_dispatcher(texts, sessions, access, repo))
def buttons(markup: ReplyKeyboardMarkup) -> list[str]:
@@ -45,7 +46,7 @@ async def test_outsider_stale_button_is_swallowed(tg: TgClient) -> None:
assert len(tg.session.sent(AnswerCallbackQuery)) == 1
@pytest.mark.parametrize("section", ["commit", "status", "logs", "alerts"])
@pytest.mark.parametrize("section", ["status", "logs", "alerts"])
async def test_menu_button_opens_section_with_nav(tg: TgClient, texts: Texts, section: str) -> None:
await tg.send(ADMIN, getattr(texts.menu, section))
+3 -2
View File
@@ -8,6 +8,7 @@ from app.access.service import AccessService
from app.bot.routers.members import MembersAction, MembersCb
from app.db.session import SessionMaker
from app.main import build_dispatcher
from app.repo.git import UploadsRepo
from app.texts import Texts
from tests.conftest import ADMIN
from tests.fakes import TgClient
@@ -16,8 +17,8 @@ NEWBIE = 3003
@pytest.fixture
def tg(texts: Texts, sessions: SessionMaker, access: AccessService) -> TgClient:
return TgClient(build_dispatcher(texts, sessions, access))
def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: UploadsRepo) -> TgClient:
return TgClient(build_dispatcher(texts, sessions, access, repo))
async def invite_token(tg: TgClient) -> str:
+44 -1
View File
@@ -1,3 +1,4 @@
import subprocess
from collections.abc import AsyncIterator
from pathlib import Path
@@ -5,9 +6,10 @@ import pytest
from sqlalchemy.ext.asyncio import AsyncEngine
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.session import SessionMaker, make_engine, make_sessionmaker
from app.repo.git import UploadsRepo
from app.texts import Texts, load_texts
ROOT = Path(__file__).resolve().parent.parent
@@ -48,3 +50,44 @@ async def access(sessions: SessionMaker, settings: Settings) -> AccessService:
service = AccessService(sessions, settings.access)
await service.bootstrap_admins()
return service
def git(cwd: Path, *args: str) -> str:
env = {
"GIT_AUTHOR_NAME": "Тест",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "Тест",
"GIT_COMMITTER_EMAIL": "t@t",
"PATH": "/usr/bin:/bin",
"HOME": str(cwd),
}
return subprocess.run(["git", *args], cwd=cwd, env=env, check=True, capture_output=True, text=True).stdout
@pytest.fixture
def remote(tmp_path: Path) -> Path:
"""A bare 'SourceCraft' repo: services/hello, a file with a space and Cyrillic, a binary, 25 files in one dir."""
work = tmp_path / "seed"
work.mkdir()
git(work, "init", "-q", "-b", "main")
(work / "services" / "hello").mkdir(parents=True)
(work / "services" / "hello" / "main.py").write_text("print('hello')\n", encoding="utf-8")
(work / "README.md").write_text("# uploads\n", encoding="utf-8")
git(work, "add", "-A")
git(work, "commit", "-q", "-m", "init")
(work / "docs").mkdir()
(work / "docs" / "мои заметки.md").write_text("привет\n", encoding="utf-8")
(work / "docs" / "logo.bin").write_bytes(b"\x00\x01\x02binary")
(work / "many").mkdir()
for i in range(25):
(work / "many" / f"f{i:02}.txt").write_text(str(i), encoding="utf-8")
git(work, "add", "-A")
git(work, "commit", "-q", "-m", "docs and many")
bare = tmp_path / "remote.git"
git(tmp_path, "clone", "-q", "--bare", str(work), str(bare))
return bare
@pytest.fixture
def repo(remote: Path, tmp_path: Path) -> UploadsRepo:
return UploadsRepo(GitSettings(uploads_url=str(remote), clone_dir=tmp_path / "clone", fetch_interval=0))
+22 -5
View File
@@ -7,9 +7,9 @@ from typing import Any
from aiogram import Bot, Dispatcher
from aiogram.client.session.base import BaseSession
from aiogram.methods import EditMessageText, GetMe, SendMessage, SendPhoto, TelegramMethod
from aiogram.methods import EditMessageText, GetFile, GetMe, SendMessage, SendPhoto, TelegramMethod
from aiogram.methods.base import TelegramType
from aiogram.types import Chat, Message, Update, User
from aiogram.types import Chat, File, Message, Update, User
BOT_TOKEN = "42:TEST-TOKEN"
@@ -19,6 +19,7 @@ class FakeSession(BaseSession):
super().__init__()
self.requests: list[TelegramMethod[Any]] = []
self._ids = count(1000)
self.files: dict[str, bytes] = {} # file_id -> content, for documents users "upload"
async def make_request(
self,
@@ -27,6 +28,10 @@ class FakeSession(BaseSession):
timeout: int | None = None, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
) -> TelegramType:
self.requests.append(method)
if isinstance(method, GetFile):
return File( # type: ignore[return-value]
file_id=method.file_id, file_unique_id=method.file_id, file_path=f"documents/{method.file_id}"
)
if isinstance(method, GetMe):
return User(id=42, is_bot=True, first_name="tgit", username="tgit_test_bot") # type: ignore[return-value]
if isinstance(method, SendPhoto):
@@ -54,9 +59,9 @@ class FakeSession(BaseSession):
timeout: int = 30, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
chunk_size: int = 65536,
raise_for_status: bool = True,
) -> AsyncGenerator[bytes, None]: # pragma: no cover - not used
raise NotImplementedError
yield b""
) -> AsyncGenerator[bytes, None]:
file_id = url.rsplit("/", 1)[-1]
yield self.files[file_id]
async def close(self) -> None:
pass
@@ -92,6 +97,18 @@ class TgClient:
message["entities"] = [{"type": "bot_command", "offset": 0, "length": len(cmd)}]
await self._feed({"message": message})
async def send_document(self, tg_id: int, name: str, content: bytes) -> None:
file_id = f"doc{next(self._message_ids)}"
self.session.files[file_id] = content
message = {
"message_id": next(self._message_ids),
"date": int(datetime.now(UTC).timestamp()),
"chat": {"id": tg_id, "type": "private"},
"from": self.user(tg_id),
"document": {"file_id": file_id, "file_unique_id": file_id, "file_name": name, "file_size": len(content)},
}
await self._feed({"message": message})
async def press(self, tg_id: int, data: str, message_text: str = "section") -> None:
callback = {
"id": str(next(self._update_ids)),
View File
+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"")
+85
View File
@@ -0,0 +1,85 @@
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()
+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)