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>
This commit is contained in:
2026-10-15 19:23:09 +03:00
co-authored by Claude Opus 5.5
parent 614f8eafd5
commit ae0cd5d42d
11 changed files with 526 additions and 4 deletions
+83 -1
View File
@@ -20,10 +20,12 @@ 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,
@@ -53,6 +55,10 @@ class Act(StrEnum):
UNSTAGE = "x"
FORCE = "y"
FIX = "z"
MESSAGE = "m"
PUSH = "ps"
PUSH_GO = "pg"
PUSH_FORCE = "pf"
class CommitCb(CallbackData, prefix="c"):
@@ -66,6 +72,7 @@ class Flow(StatesGroup):
dir_name = State()
content = State()
syntax = State()
message = State()
class Mark(StrEnum):
@@ -201,7 +208,9 @@ def to_dir_keyboard(texts: Texts) -> InlineKeyboardMarkup:
return kb.as_markup()
def build_router(texts: Texts, repo: UploadsRepo, drafts: DraftService) -> Router:
def build_router(
texts: Texts, repo: UploadsRepo, drafts: DraftService, publisher: Publisher, commit_url_template: str = ""
) -> Router:
router = Router(name="commit")
t = texts.commit
@@ -236,15 +245,59 @@ def build_router(texts: Texts, repo: UploadsRepo, drafts: DraftService) -> Route
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:
@@ -411,6 +464,23 @@ def build_router(texts: Texts, repo: UploadsRepo, drafts: DraftService) -> Route
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)
@@ -470,6 +540,18 @@ def build_router(texts: Texts, repo: UploadsRepo, drafts: DraftService) -> Route
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()