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>
This commit is contained in:
2026-10-15 19:10:30 +03:00
co-authored by Claude Opus 5.5
parent 219bebd5a2
commit dbb17bd774
17 changed files with 692 additions and 12 deletions
+103
View File
@@ -0,0 +1,103 @@
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_unfinished_actions_and_stale_buttons(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
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
+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))
View File
+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()