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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user