Конвейер продвигается только входящими 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>
120 lines
4.2 KiB
Python
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
|