CI / ruff · mypy · pytest (pull_request) Failing after 3m40s
- 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>
146 lines
6.1 KiB
Python
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
|