Files
tgit/app/bot/routers/commit.py
ElebobaandClaude Opus 5.5 95a72437ec
CI / ruff · mypy · pytest (pull_request) Failing after 3m40s
feat(pipeline): уведомления CI/CD — PipelineWatcher, CI загрузок на Gitea Actions
- app/pipeline: протокол CIProvider, клиент Gitea Actions (прогон по head_sha, упавший
  шаг, лог джоба, ретраи), PipelineWatcher — опрос только при незавершённых push,
  сообщение о push обновляется CI ⏳ → ✅/❌ → CD 🚀/⛔, автору — отдельные уведомления
- деплой сервисов — только после зелёного CI; старый push не откатывает сервис,
  который развернёт более новый; после рестарта бот продолжает с того же места
- CI_PROVIDER=none — прежнее поведение (деплой сразу после push)
- шаблон tgit-uploads: .gitea/workflows/check.yml; .gitea/ защищён от правок из бота
- миграция 0002: старые push помечены skipped, чтобы не слать им «таймаут CI»
- ADR-016, ARCHITECTURE §2.5/§5.2, README, DEPLOY, .env.example

Refs #11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:24:08 +03:00

616 lines
27 KiB
Python

"""«📝 Коммит»: 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, free_input
from app.db.models import ChangeOp, User
from app.pipeline.watcher import PipelineWatcher, initial_status, render
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 = "",
pipeline: PipelineWatcher | None = None,
) -> 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:
paths = [c.path for c in draft.changes]
status = pipeline.initial_status(paths) if pipeline else initial_status(False, False, paths)
push = await publisher.record(member, result, draft.message, draft, msg.chat.id, msg.message_id, status)
await msg.edit_text(render(texts, push, commit_url_template=commit_url_template))
await drafts.clear(member.tg_id)
await state.update_data(commit_virtual=[])
if pipeline:
pipeline.wake()
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))
conflicts = "\n".join(f"/{p}" for p in result.conflicts)
await msg.edit_text(t.conflict.format(paths=conflicts), 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, free_input(texts))
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, free_input(texts))
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, free_input(texts))
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, free_input(texts))
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