Level A — merge/deploy serialization within one repo: reuse the existing ORCH-043/065 merge-lease (no new mechanism); the only new logic is an unconditional pre-merge rebase in check_branch_mergeable — under the held lease, auto_rebase_onto_main is ALWAYS called when premerge_rebase_always (default True), not just when the branch is behind. No-op on an up-to-date branch (rebase keeps HEAD, force-with-lease -> "Everything up-to-date", CI not triggered). Kill-switch off -> ORCH-043 behaviour 1:1. Level B — declarative task dependencies: additive job_deps table (CREATE ... IF NOT EXISTS, no live-DB migration); claim_next_job gate (NOT EXISTS) defers a job whose depends-on tasks are not yet 'done' without occupying a max_concurrency slot; inert on empty job_deps -> zero regression. New leaf src/task_deps.py (never-raise): is_task_ready (fail-open), DFS cycle detection + Blocked/alert, declare/ingest_plane_relations (db source never hits the network on the hot path), snapshot. Telegram waiting-line, /queue observability, reconciler skip + cycle backstop, reaper untouched. Invariants unchanged: STAGE_TRANSITIONS, QG_CHECKS registry (dep gate is a claim_next_job врезка, not a registered QG), DB schema of existing tables, HTTP endpoints; non-self repos remain a no-op on empty deps/scope. Flags: ORCH_PREMERGE_REBASE_ALWAYS, ORCH_TASK_DEPS_ENABLED, ORCH_TASK_DEPS_SOURCE. Docs: docs/architecture/README.md, CLAUDE.md, .env.example, CHANGELOG.md, adr-0015. Tests: tests/test_orch026_*.py (64 tests); full suite 991 green. Refs: ORCH-026 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
218 lines
9.5 KiB
Python
218 lines
9.5 KiB
Python
"""ORCH-043 / TC-12..17: the merge-gate quality check ``check_branch_mergeable``.
|
|
|
|
These exercise the COMPOSITION logic in src/qg/checks.check_branch_mergeable —
|
|
the deterministic gate the engine runs on the deploy-staging -> deploy edge. The
|
|
merge_gate primitives (rebase / re-test / lease) are mocked here; their real-git
|
|
behaviour is covered in tests/test_merge_gate.py.
|
|
|
|
Contract under test (ADR-001 §4):
|
|
* conditionality: merge_gate_enabled=False / repo-out-of-scope -> no-op pass,
|
|
NEVER touching the lease;
|
|
* up-to-date branch -> pass, lease HELD;
|
|
* behind + clean rebase + green re-test -> pass, lease HELD;
|
|
* rebase conflict -> fail, lease RELEASED;
|
|
* red / timeout re-test after rebase -> fail, lease RELEASED;
|
|
* never-raise: an exception inside the gate -> (False, ...) with lease released.
|
|
"""
|
|
|
|
import os
|
|
|
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
|
|
|
import pytest # noqa: E402
|
|
|
|
from src import merge_gate # noqa: E402
|
|
from src.qg import checks as qg # noqa: E402
|
|
from src.qg.checks import check_branch_mergeable # noqa: E402
|
|
|
|
_REPO = "orchestrator"
|
|
_BRANCH = "feature/ORCH-043-x"
|
|
_WI = "ORCH-043"
|
|
|
|
|
|
@pytest.fixture
|
|
def lease_spy(monkeypatch):
|
|
"""Replace the merge_gate lease primitives with in-memory spies.
|
|
|
|
Tracks acquire/release calls and lets each test program the acquire outcome
|
|
so we can assert the gate's lease lifecycle without touching the filesystem.
|
|
"""
|
|
state = {
|
|
"acquired": False,
|
|
"released": False,
|
|
"acquire_result": (True, "lease acquired"),
|
|
}
|
|
|
|
def _acquire(repo, branch, work_item_id=None, task_id=None):
|
|
ok, reason = state["acquire_result"]
|
|
if ok:
|
|
state["acquired"] = True
|
|
return ok, reason
|
|
|
|
def _release(repo, branch=None):
|
|
state["released"] = True
|
|
|
|
monkeypatch.setattr(merge_gate, "acquire_merge_lease", _acquire)
|
|
monkeypatch.setattr(merge_gate, "release_merge_lease", _release)
|
|
# Default merge_gate scope: real for the self-hosting orchestrator repo.
|
|
monkeypatch.setattr(qg.settings, "merge_gate_enabled", True)
|
|
monkeypatch.setattr(qg.settings, "merge_gate_repos", "")
|
|
# ORCH-026: these ORCH-043 composition tests assert the ancestor-based
|
|
# short-circuit ("branch up-to-date with main" -> no rebase). That is now the
|
|
# `premerge_rebase_always=False` kill-switch path; pin it OFF here so they
|
|
# keep testing the legacy ORCH-043 behaviour. The new always-rebase default
|
|
# (True) is covered by tests/test_orch026_premerge_rebase.py (TC-A01).
|
|
monkeypatch.setattr(qg.settings, "premerge_rebase_always", False, raising=False)
|
|
return state
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Conditionality (no-op variants) — must NOT touch the lease.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc16_disabled_is_noop(monkeypatch, lease_spy):
|
|
"""TC-16 / AC-8: merge_gate_enabled=False -> pass, lease untouched."""
|
|
monkeypatch.setattr(qg.settings, "merge_gate_enabled", False)
|
|
ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH)
|
|
assert ok is True
|
|
assert reason == "merge-gate disabled"
|
|
assert lease_spy["acquired"] is False
|
|
assert lease_spy["released"] is False
|
|
|
|
|
|
def test_tc17_repo_out_of_scope_is_noop(monkeypatch, lease_spy):
|
|
"""TC-17 / AC-8: non-self-hosting repo (empty CSV) -> conditional no-op."""
|
|
ok, reason = check_branch_mergeable("enduro-trails", "ET-1", "feature/ET-1-x")
|
|
assert ok is True
|
|
assert reason == "merge-gate N/A for enduro-trails"
|
|
assert lease_spy["acquired"] is False
|
|
assert lease_spy["released"] is False
|
|
|
|
|
|
def test_csv_scopes_gate_to_listed_repo(monkeypatch, lease_spy):
|
|
"""merge_gate_repos CSV makes the gate real for a non-self-hosting repo."""
|
|
monkeypatch.setattr(qg.settings, "merge_gate_repos", "enduro-trails")
|
|
monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: False)
|
|
ok, reason = check_branch_mergeable("enduro-trails", "ET-1", "feature/ET-1-x")
|
|
assert ok is True
|
|
assert reason == "branch up-to-date with main"
|
|
assert lease_spy["acquired"] is True # gate actually ran
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lock busy -> DEFER signal (no rollback at this layer).
|
|
# ---------------------------------------------------------------------------
|
|
def test_lock_busy_returns_defer_signal(monkeypatch, lease_spy):
|
|
"""Lease busy -> (False, 'merge-lock busy'); nothing acquired or released."""
|
|
lease_spy["acquire_result"] = (False, "merge-lock busy")
|
|
ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH)
|
|
assert ok is False
|
|
assert reason == "merge-lock busy"
|
|
assert lease_spy["acquired"] is False
|
|
assert lease_spy["released"] is False # we never held it
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-12: branch already up-to-date -> pass, lease HELD.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc12_up_to_date_passes_lease_held(monkeypatch, lease_spy):
|
|
monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: False)
|
|
# If these were called the test would wrongly proceed — guard with raisers.
|
|
monkeypatch.setattr(
|
|
merge_gate, "auto_rebase_onto_main",
|
|
lambda r, b: pytest.fail("must not rebase an up-to-date branch"),
|
|
)
|
|
ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH)
|
|
assert ok is True
|
|
assert reason == "branch up-to-date with main"
|
|
assert lease_spy["acquired"] is True
|
|
assert lease_spy["released"] is False # lease HELD until the merge
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-13: behind + clean rebase + green re-test -> pass, lease HELD.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc13_behind_clean_rebase_green_passes_lease_held(monkeypatch, lease_spy):
|
|
monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: True)
|
|
monkeypatch.setattr(
|
|
merge_gate, "auto_rebase_onto_main",
|
|
lambda r, b: (True, "rebased onto origin/main"),
|
|
)
|
|
monkeypatch.setattr(merge_gate, "retest_branch", lambda r, b: (True, "re-test green"))
|
|
ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH)
|
|
assert ok is True
|
|
assert reason == "rebased onto main, re-test green"
|
|
assert lease_spy["acquired"] is True
|
|
assert lease_spy["released"] is False # lease HELD
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-14: rebase conflict -> fail, lease RELEASED.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc14_rebase_conflict_fails_lease_released(monkeypatch, lease_spy):
|
|
monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: True)
|
|
monkeypatch.setattr(
|
|
merge_gate, "auto_rebase_onto_main",
|
|
lambda r, b: (False, "rebase conflict: src/db.py"),
|
|
)
|
|
monkeypatch.setattr(
|
|
merge_gate, "retest_branch",
|
|
lambda r, b: pytest.fail("must not re-test after a failed rebase"),
|
|
)
|
|
ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH)
|
|
assert ok is False
|
|
assert reason == "rebase conflict: src/db.py"
|
|
assert lease_spy["released"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-15: red / timeout re-test after rebase -> fail, lease RELEASED.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc15_red_retest_fails_lease_released(monkeypatch, lease_spy):
|
|
monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: True)
|
|
monkeypatch.setattr(
|
|
merge_gate, "auto_rebase_onto_main",
|
|
lambda r, b: (True, "rebased onto origin/main"),
|
|
)
|
|
monkeypatch.setattr(
|
|
merge_gate, "retest_branch",
|
|
lambda r, b: (False, "re-test failed: ...1 failed, 5 passed"),
|
|
)
|
|
ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH)
|
|
assert ok is False
|
|
assert reason.startswith("re-test failed after rebase:")
|
|
assert "1 failed, 5 passed" in reason
|
|
assert lease_spy["released"] is True
|
|
|
|
|
|
def test_tc15_retest_timeout_passes_reason_through(monkeypatch, lease_spy):
|
|
"""AC-6: a re-test timeout keeps its distinct reason and releases the lease."""
|
|
monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: True)
|
|
monkeypatch.setattr(
|
|
merge_gate, "auto_rebase_onto_main",
|
|
lambda r, b: (True, "rebased onto origin/main"),
|
|
)
|
|
monkeypatch.setattr(
|
|
merge_gate, "retest_branch",
|
|
lambda r, b: (False, "re-test timeout after 600s"),
|
|
)
|
|
ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH)
|
|
assert ok is False
|
|
assert reason == "re-test timeout after 600s"
|
|
assert lease_spy["released"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Never-raise: an exception inside the gate -> (False, ...) + lease released.
|
|
# ---------------------------------------------------------------------------
|
|
def test_never_raise_releases_lease_on_internal_error(monkeypatch, lease_spy):
|
|
"""AC-9: a blowing-up primitive is caught; the gate returns and releases."""
|
|
def _boom(r, b):
|
|
raise RuntimeError("git exploded")
|
|
|
|
monkeypatch.setattr(merge_gate, "branch_is_behind_main", _boom)
|
|
ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH)
|
|
assert ok is False
|
|
assert "merge-gate error" in reason
|
|
assert lease_spy["released"] is True # held then released on the error path
|