Files
tgit/tests/repo/test_publisher.py
T
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

146 lines
6.1 KiB
Python

from datetime import timedelta
from pathlib import Path
import pytest
from sqlalchemy import select
from app.access.service import AccessService
from app.db.models import ChangeOp, PipelineStatus, Push, User, utcnow
from app.db.session import SessionMaker
from app.repo.drafts import Change, DraftView
from app.repo.git import UploadsRepo
from app.repo.publisher import PUSHES_PER_HOUR, Publisher, PushStatus
from tests.conftest import ADMIN, git
@pytest.fixture
async def member(access: AccessService) -> User:
user = await access.get_member(ADMIN)
assert user is not None
return user
def remote_log(remote: Path, tmp_path: Path, name: str = "check") -> tuple[Path, str]:
clone = tmp_path / name
git(tmp_path, "clone", "-q", str(remote), str(clone))
return clone, git(clone, "log", "-1", "--format=%an <%ae>%n%B")
def foreign_commit(remote: Path, tmp_path: Path, path: str, text: str, name: str) -> None:
other = tmp_path / name
git(tmp_path, "clone", "-q", str(remote), str(other))
target = other / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text, encoding="utf-8")
git(other, "add", "-A")
git(other, "commit", "-q", "-m", f"foreign {path}")
git(other, "push", "-q", "origin", "main")
async def draft_on(repo: UploadsRepo, *changes: Change) -> DraftView:
await repo.refresh(force=True)
return DraftView(base_sha=await repo.head(), message=None, changes=list(changes))
async def test_push_applies_changes_with_trailer(
repo: UploadsRepo, sessions: SessionMaker, member: User, remote: Path, tmp_path: Path
) -> None:
draft = await draft_on(
repo,
Change("services/hello/utils.py", ChangeOp.ADD, b"X = 1\n"),
Change("services/hello/main.py", ChangeOp.MODIFY, b"print('v2')\n"),
Change("docs/logo.bin", ChangeOp.DELETE, None),
Change("docs/мои заметки.md", ChangeOp.DELETE, None),
)
publisher = Publisher(repo, sessions)
result = await publisher.push(member, draft, "feat: v2\n\nподробности")
assert result.status is PushStatus.OK and result.sha is not None
clone, log = remote_log(remote, tmp_path)
assert log.startswith("tgit-bot <tgit-bot@users.noreply.tgit>\nfeat: v2\n\nподробности\n\nTelegram-User: ")
assert f"({ADMIN})" in log
assert (clone / "services/hello/utils.py").read_text() == "X = 1\n"
assert (clone / "services/hello/main.py").read_text() == "print('v2')\n"
assert not (clone / "docs").exists() # both files gone → the folder is gone too
assert git(clone, "rev-parse", "HEAD").strip() == result.sha
async def test_nothing_to_commit(repo: UploadsRepo, sessions: SessionMaker, member: User) -> None:
draft = await draft_on(repo, Change("README.md", ChangeOp.MODIFY, b"# uploads\n"))
assert (await Publisher(repo, sessions).push(member, draft, "noop")).status is PushStatus.EMPTY
async def test_conflict_then_overwrite(
repo: UploadsRepo, sessions: SessionMaker, member: User, remote: Path, tmp_path: Path
) -> None:
draft = await draft_on(repo, Change("README.md", ChangeOp.MODIFY, b"mine\n"))
foreign_commit(remote, tmp_path, "README.md", "theirs\n", "other")
publisher = Publisher(repo, sessions)
conflict = await publisher.push(member, draft, "edit readme")
assert conflict.status is PushStatus.CONFLICT and conflict.conflicts == ["README.md"]
forced = await publisher.push(member, draft, "edit readme", overwrite=True)
assert forced.status is PushStatus.OK
clone, _ = remote_log(remote, tmp_path)
assert (clone / "README.md").read_text() == "mine\n"
async def test_unrelated_change_in_main_is_not_a_conflict(
repo: UploadsRepo, sessions: SessionMaker, member: User, remote: Path, tmp_path: Path
) -> None:
draft = await draft_on(repo, Change("a.txt", ChangeOp.ADD, b"a"))
foreign_commit(remote, tmp_path, "b.txt", "b", "other")
assert (await Publisher(repo, sessions).push(member, draft, "add a")).status is PushStatus.OK
clone, _ = remote_log(remote, tmp_path)
assert (clone / "a.txt").exists() and (clone / "b.txt").exists()
async def test_rejected_push_is_rebased(
repo: UploadsRepo, sessions: SessionMaker, member: User, remote: Path, tmp_path: Path
) -> None:
raced = False
async def someone_pushes_first() -> None:
nonlocal raced
if not raced:
raced = True
foreign_commit(remote, tmp_path, "race.txt", "r", "racer")
draft = await draft_on(repo, Change("mine.txt", ChangeOp.ADD, b"m"))
result = await Publisher(repo, sessions, before_push=someone_pushes_first).push(member, draft, "add mine")
assert result.status is PushStatus.OK and raced
clone, log = remote_log(remote, tmp_path)
assert "add mine" in log and (clone / "race.txt").exists() and (clone / "mine.txt").exists()
async def test_path_escape_is_refused(repo: UploadsRepo, sessions: SessionMaker, member: User, tmp_path: Path) -> None:
draft = await draft_on(repo, Change("../evil.txt", ChangeOp.ADD, b"x"))
assert (await Publisher(repo, sessions).push(member, draft, "evil")).status is PushStatus.FAILED
assert not (tmp_path / "evil.txt").exists()
async def test_rate_limit_and_record(repo: UploadsRepo, sessions: SessionMaker, member: User) -> None:
publisher = Publisher(repo, sessions)
draft = await draft_on(repo, Change("x.txt", ChangeOp.ADD, b"x"))
result = await publisher.push(member, draft, "x")
status = (PipelineStatus.PENDING, PipelineStatus.SKIPPED)
await publisher.record(member, result, "x", draft, chat_id=ADMIN, message_id=77, status=status)
async with sessions() as s:
push = await s.scalar(select(Push))
assert push is not None and push.sha == result.sha and push.paths == ["x.txt"] and push.message_id == 77
assert (push.ci_status, push.cd_status) == status
for i in range(PUSHES_PER_HOUR):
s.add(
Push(user_id=ADMIN, sha=f"{i:040d}", message="m", chat_id=1, created_at=utcnow() - timedelta(minutes=1))
)
await s.commit()
assert (await publisher.push(member, draft, "y")).status is PushStatus.LIMIT