feat(pipeline): уведомления CI/CD — PipelineWatcher, CI загрузок на Gitea Actions
CI / ruff · mypy · pytest (pull_request) Failing after 3m40s
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>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.config import CISettings
|
||||
from app.db.models import PipelineStatus
|
||||
from app.pipeline.gitea import GiteaCI, GiteaError
|
||||
from app.pipeline.provider import CIRun
|
||||
|
||||
API = "https://gitea.example.test"
|
||||
RUNS = f"{API}/api/v1/repos/HSE/up/actions/runs"
|
||||
SHA = "eda569427093acb3b39ad5b301216cb5b7400ab8"
|
||||
|
||||
|
||||
def run(
|
||||
id: int, path: str = "check.yml@refs/heads/main", status: str = "completed", conclusion: Any = "success"
|
||||
) -> Any:
|
||||
"""Shape of a real Gitea 1.27 run (fields the client doesn't read trimmed)."""
|
||||
run = {"id": id, "path": path, "status": status, "head_sha": SHA, "html_url": f"{API}/HSE/up/actions/runs/{id}"}
|
||||
return run | ({"conclusion": conclusion} if conclusion else {}) # in-progress runs have no conclusion key
|
||||
|
||||
|
||||
def job(id: int, conclusion: str, steps: list[tuple[str, str]]) -> Any:
|
||||
return {
|
||||
"id": id,
|
||||
"name": "check",
|
||||
"status": "completed",
|
||||
"conclusion": conclusion,
|
||||
"steps": [{"name": n, "number": i, "status": "completed", "conclusion": c} for i, (n, c) in enumerate(steps)],
|
||||
}
|
||||
|
||||
|
||||
async def no_sleep(_: float) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def ci() -> Any:
|
||||
c = GiteaCI(CISettings(token="secret", api_url=API, repo="HSE/up", max_attempts=3), sleep=no_sleep) # type: ignore[arg-type]
|
||||
yield c
|
||||
await c.aclose()
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_finds_newest_run_of_the_workflow(ci: GiteaCI) -> None:
|
||||
other = run(9, path="other.yml@refs/heads/main")
|
||||
route = respx.get(RUNS).respond(
|
||||
json={"workflow_runs": [run(5), other, run(7, status="in_progress", conclusion=None)]}
|
||||
)
|
||||
|
||||
found = await ci.find(SHA)
|
||||
|
||||
assert found == CIRun("7", PipelineStatus.RUNNING, f"{API}/HSE/up/actions/runs/7")
|
||||
assert route.calls.last.request.url.params["head_sha"] == SHA
|
||||
assert route.calls.last.request.headers["Authorization"] == "token secret"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_no_run_yet(ci: GiteaCI) -> None:
|
||||
respx.get(RUNS).respond(json={"workflow_runs": [], "total_count": 0})
|
||||
|
||||
assert await ci.find(SHA) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "conclusion", "expected"),
|
||||
[
|
||||
("queued", None, PipelineStatus.PENDING),
|
||||
("waiting", None, PipelineStatus.PENDING),
|
||||
("in_progress", None, PipelineStatus.RUNNING),
|
||||
("completed", "success", PipelineStatus.SUCCESS),
|
||||
("completed", "skipped", PipelineStatus.SKIPPED),
|
||||
],
|
||||
)
|
||||
@respx.mock
|
||||
async def test_status_mapping(ci: GiteaCI, status: str, conclusion: str | None, expected: PipelineStatus) -> None:
|
||||
respx.get(RUNS).respond(json={"workflow_runs": [run(1, status=status, conclusion=conclusion)]})
|
||||
|
||||
found = await ci.find(SHA)
|
||||
|
||||
assert found is not None and found.status is expected
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_failure_names_the_step_and_reads_its_log(ci: GiteaCI) -> None:
|
||||
respx.get(RUNS).respond(json={"workflow_runs": [run(3, conclusion="failure")]})
|
||||
steps = [("Run actions/checkout@v4", "success"), ("lint", "failure"), ("tests", "skipped")]
|
||||
respx.get(f"{RUNS}/3/jobs").respond(json={"jobs": [job(31, "failure", steps)], "total_count": 1})
|
||||
log = "2026-10-18T20:44:56.7721431Z ruff check .\n2026-10-18T20:44:57.1Z F821 undefined name 'x'\n"
|
||||
respx.get(f"{API}/api/v1/repos/HSE/up/actions/jobs/31/logs").respond(text=log)
|
||||
|
||||
found = await ci.find(SHA)
|
||||
assert found is not None
|
||||
text = await ci.failure_log(found)
|
||||
|
||||
assert (found.status, found.failed_step) == (PipelineStatus.FAILED, "check / lint")
|
||||
assert text == "ruff check .\nF821 undefined name 'x'\n"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_cancelled_run_is_a_failure(ci: GiteaCI) -> None:
|
||||
respx.get(RUNS).respond(json={"workflow_runs": [run(4, conclusion="cancelled")]})
|
||||
respx.get(f"{RUNS}/4/jobs").respond(json={"jobs": [job(41, "cancelled", [("lint", "success")])]})
|
||||
|
||||
found = await ci.find(SHA)
|
||||
|
||||
assert found is not None and (found.status, found.failed_step) == (PipelineStatus.FAILED, "check / cancelled")
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_retries_transport_errors_and_5xx(ci: GiteaCI) -> None:
|
||||
route = respx.get(RUNS)
|
||||
route.side_effect = [
|
||||
httpx.ConnectError("reset"),
|
||||
httpx.Response(502),
|
||||
httpx.Response(200, json={"workflow_runs": []}),
|
||||
]
|
||||
|
||||
assert await ci.find(SHA) is None
|
||||
assert route.call_count == 3
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_gives_up_with_a_typed_error(ci: GiteaCI) -> None:
|
||||
respx.get(RUNS).respond(404, json={"message": "not found"})
|
||||
|
||||
with pytest.raises(GiteaError, match="404"):
|
||||
await ci.find(SHA)
|
||||
Reference in New Issue
Block a user