Files
orchestrator/tests/test_gitea_sha_resolve.py
claude-bot 7d2d77217a feat(reconciler): sweeper потерянных webhook (реконсиляция застрявших стадий)
Конвейер продвигается только входящими webhook; потерянное событие (502 на
ребилде, отсутствие ретраев у Plane/Gitea, неразрезолвленный sha→branch)
оставляет задачу молча застрявшей (класс инцидента ORCH-044). Новый фоновый
daemon-поток src/reconciler.py (паттерн queue_worker) доигрывает пропущенный
переход через те же штатные гейты/обработчики, что и webhook:

- F-1 gate-side: для задач stage≠done, без активного job и age(updated_at) ≥
  grace_for_stage(stage) — read-only пред-оценка канонического QG; зелёный →
  stage_engine.advance_stage(..., finished_agent=None); красный → тишина (спам
  нотификаций структурно невозможен). analysis F-1 не трогает (человеческий гейт).
- F-2 plane-side: опрос Plane API per-project (plane_sync.list_issues_by_state,
  курсорная пагинация, never-raise) → реплей In Progress/Approved/Rejected через
  существующие handle_status_start/handle_verdict (async из sync-потока, asyncio.run).
- F-3: усиление sha→branch в handle_ci_status — БД-fallback по единственной
  development-задаче repo (неоднозначность → не резолвим), debug→info.
- Анти-дубль на создании (db.create_task_atomic под process-wide Lock): гонка
  reconcile↔webhook не плодит второй task/branch/worktree/analyst-job (AC-4).
- F-4 observability: лог-строка разблокировки + Telegram + блок reconcile в /queue.

Старт/стоп в main.lifespan (после worker.start() / перед worker.stop()),
restart-safe, never-raise на единицу работы. Kill-switches ORCH_RECONCILE_ENABLED
/ ORCH_RECONCILE_PLANE_ENABLED + grace-настройки. Схема БД и реестры
STAGE_TRANSITIONS/QG_CHECKS не менялись.

Тесты: test_reconciler.py, test_reconciler_plane.py, test_gitea_sha_resolve.py,
test_config.py (33 новых, 563 всего зелёные). Документация обновлена (golden source):
architecture/README.md, INFRA.md, README.md, CHANGELOG.md, adr-0007 → accepted.

Refs: ORCH-053

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:55:25 +00:00

120 lines
4.2 KiB
Python

"""ORCH-053 (F-3): sha->branch resolution hardening in handle_ci_status.
When a CI-status webhook carries no ``branches[]`` and the SHA cannot be
resolved to a feature branch via ``git branch -r --contains`` (lost on a 502
rebuild, shallow clone, etc.), handle_ci_status now falls back to the tasks DB
and matches the UNIQUE development-stage task of the repo. Ambiguity (more than
one development task) is deliberately left unresolved so it can never make a
false match.
The git subprocess and the network QG / Plane / Telegram side effects are mocked
so the handler runs offline against a real isolated sqlite DB.
"""
import asyncio
import os
import tempfile
from types import SimpleNamespace
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_gitea_sha.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src.webhooks import gitea as gitea_mod # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
yield
@pytest.fixture(autouse=True)
def silence_and_stub_git(monkeypatch):
# git branch -r --contains <sha> resolves to nothing (forces the DB fallback).
monkeypatch.setattr(
gitea_mod.subprocess, "run",
lambda *a, **k: SimpleNamespace(stdout="", returncode=0),
)
# Mute the network side effects bound module-level in gitea.
for name in ("notify_stage_change", "notify_qg_failure", "notify_error",
"plane_notify_stage"):
monkeypatch.setattr(gitea_mod, name, MagicMock(), raising=False)
def _make_dev_task(branch, wi, repo="enduro-trails"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, 'development')",
(f"plane-{wi}", wi, repo, branch),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _stage_of(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id = ?", (task_id,)).fetchone()
conn.close()
return row["stage"]
def _ci_payload(sha="deadbeef", repo="enduro-trails", state="success"):
return {
"state": state,
"sha": sha,
"branches": [], # no branch in the event -> forces resolution
"repository": {"name": repo},
}
# ---------------------------------------------------------------------------
# TC-18: unique development task -> DB fallback resolves the branch, advances.
# ---------------------------------------------------------------------------
def test_tc18_db_fallback_unique_match_advances(monkeypatch):
ci = MagicMock(return_value=(True, "CI green"))
monkeypatch.setattr(gitea_mod, "check_ci_green", ci)
tid = _make_dev_task("feature/ET-050-x", "ET-050")
asyncio.run(gitea_mod.handle_ci_status(_ci_payload()))
assert _stage_of(tid) == "review"
ci.assert_called_once()
# The fallback resolved to the unique dev task's branch.
assert ci.call_args.args[1] == "feature/ET-050-x"
# ---------------------------------------------------------------------------
# TC-19: several development tasks -> ambiguous -> no false match, no advance.
# ---------------------------------------------------------------------------
def test_tc19_db_fallback_ambiguous_no_match(monkeypatch, caplog):
ci = MagicMock(return_value=(True, "CI green"))
monkeypatch.setattr(gitea_mod, "check_ci_green", ci)
t1 = _make_dev_task("feature/ET-051-a", "ET-051")
t2 = _make_dev_task("feature/ET-052-b", "ET-052")
with caplog.at_level("INFO", logger="orchestrator.webhooks.gitea"):
asyncio.run(gitea_mod.handle_ci_status(_ci_payload()))
# Ambiguity -> branch unresolved -> handler returns before touching the gate.
assert _stage_of(t1) == "development"
assert _stage_of(t2) == "development"
ci.assert_not_called()
assert "could not determine branch" in caplog.text