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>
This commit is contained in:
2026-10-15 19:18:49 +03:00
co-authored by Claude Opus 5.5
parent dbb17bd774
commit 614f8eafd5
11 changed files with 951 additions and 91 deletions
+2 -4
View File
@@ -87,13 +87,11 @@ async def test_pagination(tg: TgClient, texts: Texts) -> None:
assert labels(second.reply_markup)[0].startswith("📄 f20.txt") and texts.commit.prev in labels(second.reply_markup)
async def test_unfinished_actions_and_stale_buttons(tg: TgClient, texts: Texts) -> None:
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.NEW_FILE))
await tg.press(ADMIN, cb(Act.OPEN, 99))
soon, stale = tg.session.sent(AnswerCallbackQuery)
assert soon.text == texts.commit.soon and soon.show_alert
[stale] = tg.session.sent(AnswerCallbackQuery)
assert stale.text is None
assert tg.session.sent(EditMessageText) == []
+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
+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)),
+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"")
+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)