feat(merge-gate): auto-rebase onto current main + re-test + serialise merges
Deterministic (no-LLM) sub-gate on the deploy-staging -> deploy edge that catches a feature branch up to the CURRENT origin/main, re-tests the combined tree, and serialises merges with a per-repo file lease — so two green parallel branches can no longer break main (self-hosting safety for the orchestrator repo). - src/merge_gate.py: branch_is_behind_main, auto_rebase_onto_main (push --force-with-lease ONLY the task branch, NEVER main), retest_branch, and a file merge-lease (atomic O_CREAT|O_EXCL, holder-aware release, stale reclaim). Strict never-raise contract; all git ops in the per-branch worktree. - src/qg/checks.py: check_branch_mergeable composes the primitives under the lease; registered in QG_CHECKS. Conditional rollout (merge_gate_enabled / merge_gate_repos, default self-hosting only). - src/stage_engine.py: sub-gate hook on deploy-staging (not a new stage). PASS -> advance; "merge-lock busy" -> DEFER (re-queue with available_at, anti-deadlock at max_concurrency=1, capped); conflict/red re-test -> rollback to development + developer retry (capped by MAX_DEVELOPER_RETRIES). Lease released on deploy->done / rollback / PR-merged webhook. - src/db.py: enqueue_job(available_at_delay_s=...) for the defer (no schema change). - src/webhooks/gitea.py: holder-aware lease release on PR-merged. - src/config.py + .env.example: ORCH_MERGE_* settings. Docs: README + adr-0006 (architect) already cover the design; CHANGELOG updated. Tests: test_merge_gate.py, test_qg_merge_gate.py, test_merge_gate_race.py, test_stage_engine.py::TestMergeGate, test_config.py, QG-registry snapshot. Full suite: 535 passed. Refs: ORCH-043 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -25,3 +25,50 @@ def test_tracker_mode_reads_env_arbitrary(monkeypatch):
|
||||
# -> edit) happens in notifications, not here (AC-1/AC-2 split).
|
||||
monkeypatch.setenv("ORCH_TRACKER_MODE", "garbage")
|
||||
assert Settings().tracker_mode == "garbage"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-043 / TC-25: merge-gate settings defaults + env override.
|
||||
# ---------------------------------------------------------------------------
|
||||
_MERGE_ENV = (
|
||||
"ORCH_MERGE_GATE_ENABLED",
|
||||
"ORCH_MERGE_GATE_REPOS",
|
||||
"ORCH_MERGE_RETEST_TIMEOUT_S",
|
||||
"ORCH_MERGE_RETEST_TARGET",
|
||||
"ORCH_MERGE_LOCK_TIMEOUT_S",
|
||||
"ORCH_MERGE_DEFER_DELAY_S",
|
||||
"ORCH_MERGE_DEFER_MAX_ATTEMPTS",
|
||||
)
|
||||
|
||||
|
||||
def test_merge_gate_settings_defaults(monkeypatch):
|
||||
"""TC-25 / AC-10: documented defaults when no env is set."""
|
||||
for name in _MERGE_ENV:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
s = Settings()
|
||||
assert s.merge_gate_enabled is True
|
||||
assert s.merge_gate_repos == ""
|
||||
assert s.merge_retest_timeout_s == 600
|
||||
assert s.merge_retest_target == "tests/"
|
||||
assert s.merge_lock_timeout_s == 300
|
||||
assert s.merge_defer_delay_s == 60
|
||||
assert s.merge_defer_max_attempts == 5
|
||||
|
||||
|
||||
def test_merge_gate_settings_env_override(monkeypatch):
|
||||
"""TC-25 / AC-10: each field is read from its ORCH_* env var."""
|
||||
monkeypatch.setenv("ORCH_MERGE_GATE_ENABLED", "false")
|
||||
monkeypatch.setenv("ORCH_MERGE_GATE_REPOS", "orchestrator,enduro-trails")
|
||||
monkeypatch.setenv("ORCH_MERGE_RETEST_TIMEOUT_S", "120")
|
||||
monkeypatch.setenv("ORCH_MERGE_RETEST_TARGET", "tests/unit")
|
||||
monkeypatch.setenv("ORCH_MERGE_LOCK_TIMEOUT_S", "90")
|
||||
monkeypatch.setenv("ORCH_MERGE_DEFER_DELAY_S", "5")
|
||||
monkeypatch.setenv("ORCH_MERGE_DEFER_MAX_ATTEMPTS", "9")
|
||||
s = Settings()
|
||||
assert s.merge_gate_enabled is False
|
||||
assert s.merge_gate_repos == "orchestrator,enduro-trails"
|
||||
assert s.merge_retest_timeout_s == 120
|
||||
assert s.merge_retest_target == "tests/unit"
|
||||
assert s.merge_lock_timeout_s == 90
|
||||
assert s.merge_defer_delay_s == 5
|
||||
assert s.merge_defer_max_attempts == 9
|
||||
|
||||
301
tests/test_merge_gate.py
Normal file
301
tests/test_merge_gate.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""ORCH-043: tests for src/merge_gate core (TC-01..TC-11).
|
||||
|
||||
Git tests use REAL local repos in tmp (a bare 'origin' + a main clone), so
|
||||
fetch / merge-base / rebase / push --force-with-lease are exercised without
|
||||
network, mirroring tests/test_git_worktree.py. The re-test (pytest) and lease
|
||||
units are isolated with monkeypatch / tmp files.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
# Env before importing app modules (same convention as the other suites).
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_merge_gate.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
||||
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
||||
|
||||
from src import git_worktree, merge_gate # noqa: E402
|
||||
|
||||
|
||||
def _git(cwd, *args):
|
||||
return subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True)
|
||||
|
||||
|
||||
def _origin_sha(origin, ref):
|
||||
return _git(str(origin), "rev-parse", ref).stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repos(tmp_path, monkeypatch):
|
||||
"""Bare 'origin' (main@C1) + main clone + two feature branches branched from C0.
|
||||
|
||||
Layout:
|
||||
C0 README.md
|
||||
feature/behind : C0 + adds f.txt (rebases cleanly onto C1)
|
||||
feature/conflict : C0 + edits README.md (textual conflict with C1)
|
||||
feature/uptodate : branched from C1 (already contains origin/main)
|
||||
main C1 : edits README.md + adds other.txt
|
||||
Returns (repo_name, origin_path).
|
||||
"""
|
||||
repo = "orchestrator"
|
||||
repos_dir = tmp_path / "repos"
|
||||
wt_dir = tmp_path / "repos" / "_wt"
|
||||
repos_dir.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(repos_dir))
|
||||
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir))
|
||||
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(wt_dir))
|
||||
|
||||
origin = tmp_path / "origin.git"
|
||||
subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], capture_output=True)
|
||||
|
||||
seed = tmp_path / "seed"
|
||||
seed.mkdir()
|
||||
_git(str(seed), "init", "-b", "main")
|
||||
_git(str(seed), "config", "user.email", "t@t")
|
||||
_git(str(seed), "config", "user.name", "t")
|
||||
(seed / "README.md").write_text("base\n")
|
||||
_git(str(seed), "add", ".")
|
||||
_git(str(seed), "commit", "-m", "C0")
|
||||
_git(str(seed), "remote", "add", "origin", str(origin))
|
||||
_git(str(seed), "push", "origin", "main")
|
||||
|
||||
# Branches off C0.
|
||||
_git(str(seed), "checkout", "-b", "feature/behind")
|
||||
(seed / "f.txt").write_text("feature\n")
|
||||
_git(str(seed), "add", ".")
|
||||
_git(str(seed), "commit", "-m", "feat: add f.txt")
|
||||
_git(str(seed), "push", "origin", "feature/behind")
|
||||
|
||||
_git(str(seed), "checkout", "main")
|
||||
_git(str(seed), "checkout", "-b", "feature/conflict")
|
||||
(seed / "README.md").write_text("feature readme\n")
|
||||
_git(str(seed), "add", ".")
|
||||
_git(str(seed), "commit", "-m", "feat: edit README")
|
||||
_git(str(seed), "push", "origin", "feature/conflict")
|
||||
|
||||
# Advance main to C1.
|
||||
_git(str(seed), "checkout", "main")
|
||||
(seed / "README.md").write_text("main v2\n")
|
||||
(seed / "other.txt").write_text("main change\n")
|
||||
_git(str(seed), "add", ".")
|
||||
_git(str(seed), "commit", "-m", "C1")
|
||||
_git(str(seed), "push", "origin", "main")
|
||||
|
||||
# Branch that already contains C1.
|
||||
_git(str(seed), "checkout", "-b", "feature/uptodate")
|
||||
(seed / "g.txt").write_text("uptodate\n")
|
||||
_git(str(seed), "add", ".")
|
||||
_git(str(seed), "commit", "-m", "feat: on top of C1")
|
||||
_git(str(seed), "push", "origin", "feature/uptodate")
|
||||
|
||||
# Main clone at repos_dir/<repo>.
|
||||
main_clone = repos_dir / repo
|
||||
subprocess.run(["git", "clone", str(origin), str(main_clone)], capture_output=True)
|
||||
_git(str(main_clone), "config", "user.email", "t@t")
|
||||
_git(str(main_clone), "config", "user.name", "t")
|
||||
return repo, origin
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01..03: branch_is_behind_main
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc01_behind_when_main_ahead(repos):
|
||||
repo, _ = repos
|
||||
assert merge_gate.branch_is_behind_main(repo, "feature/behind") is True
|
||||
|
||||
|
||||
def test_tc02_not_behind_when_branch_contains_main(repos):
|
||||
repo, _ = repos
|
||||
assert merge_gate.branch_is_behind_main(repo, "feature/uptodate") is False
|
||||
|
||||
|
||||
def test_tc03_never_raises_on_bad_repo(monkeypatch, tmp_path):
|
||||
# Point repos_dir at an empty dir -> ensure_worktree raises -> caught -> True.
|
||||
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(tmp_path / "nope"))
|
||||
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(tmp_path / "nope"))
|
||||
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(tmp_path / "_wt"))
|
||||
result = merge_gate.branch_is_behind_main("orchestrator", "feature/x")
|
||||
assert result is True # safe bool, not an exception
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04..06: auto_rebase_onto_main
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc04_clean_catchup_pushes_with_lease(repos):
|
||||
repo, origin = repos
|
||||
main_before = _origin_sha(origin, "main")
|
||||
|
||||
ok, reason = merge_gate.auto_rebase_onto_main(repo, "feature/behind")
|
||||
assert ok is True, reason
|
||||
|
||||
# origin/main must be UNTOUCHED (AC-7).
|
||||
assert _origin_sha(origin, "main") == main_before
|
||||
# The pushed branch now contains origin/main (origin/main is its ancestor).
|
||||
rc = subprocess.run(
|
||||
["git", "-C", str(origin), "merge-base", "--is-ancestor",
|
||||
"main", "feature/behind"],
|
||||
capture_output=True,
|
||||
).returncode
|
||||
assert rc == 0
|
||||
# And it carries main's new file plus its own.
|
||||
assert _git(str(origin), "cat-file", "-e", "feature/behind:other.txt").returncode == 0
|
||||
assert _git(str(origin), "cat-file", "-e", "feature/behind:f.txt").returncode == 0
|
||||
|
||||
|
||||
def test_tc05_conflict_aborts_clean_and_reports(repos):
|
||||
repo, origin = repos
|
||||
main_before = _origin_sha(origin, "main")
|
||||
branch_before = _origin_sha(origin, "feature/conflict")
|
||||
|
||||
ok, reason = merge_gate.auto_rebase_onto_main(repo, "feature/conflict")
|
||||
assert ok is False
|
||||
assert "rebase conflict" in reason
|
||||
# main untouched, branch NOT force-pushed past the conflict.
|
||||
assert _origin_sha(origin, "main") == main_before
|
||||
assert _origin_sha(origin, "feature/conflict") == branch_before
|
||||
# Worktree left clean (no rebase in progress).
|
||||
wt = git_worktree.get_worktree_path(repo, "feature/conflict")
|
||||
assert not os.path.isdir(os.path.join(wt, ".git", "rebase-merge"))
|
||||
assert not os.path.isdir(os.path.join(wt, ".git", "rebase-apply"))
|
||||
|
||||
|
||||
def test_tc06_never_pushes_main(repos, monkeypatch):
|
||||
repo, origin = repos
|
||||
calls = []
|
||||
real_run = subprocess.run
|
||||
|
||||
def _spy(cmd, *a, **k):
|
||||
if isinstance(cmd, list):
|
||||
calls.append(cmd)
|
||||
return real_run(cmd, *a, **k)
|
||||
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", _spy)
|
||||
merge_gate.auto_rebase_onto_main(repo, "feature/behind")
|
||||
|
||||
pushes = [c for c in calls if "push" in c]
|
||||
assert pushes, "expected at least one push"
|
||||
for c in pushes:
|
||||
# Never push main; force only via --force-with-lease on the task branch.
|
||||
assert "main" not in c, f"push touched main: {c}"
|
||||
assert "--force-with-lease" in c
|
||||
assert "feature/behind" in c
|
||||
# Hard force must never be used.
|
||||
assert "--force" not in c or "--force-with-lease" in c
|
||||
assert "-f" not in c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-07..09: retest_branch (isolated from real pytest)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def fake_worktree(tmp_path, monkeypatch):
|
||||
wt = tmp_path / "wt"
|
||||
wt.mkdir()
|
||||
monkeypatch.setattr(merge_gate, "get_worktree_path", lambda repo, branch: str(wt))
|
||||
return str(wt)
|
||||
|
||||
|
||||
def test_tc07_retest_green(fake_worktree, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
merge_gate.subprocess, "run",
|
||||
lambda *a, **k: subprocess.CompletedProcess(a, 0, "1 passed", ""),
|
||||
)
|
||||
ok, reason = merge_gate.retest_branch("orchestrator", "feature/x")
|
||||
assert ok is True
|
||||
assert reason == "re-test green"
|
||||
|
||||
|
||||
def test_tc08_retest_red_with_tail(fake_worktree, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
merge_gate.subprocess, "run",
|
||||
lambda *a, **k: subprocess.CompletedProcess(
|
||||
a, 1, "FAILED tests/test_x.py::t - AssertionError\n1 failed", ""
|
||||
),
|
||||
)
|
||||
ok, reason = merge_gate.retest_branch("orchestrator", "feature/x")
|
||||
assert ok is False
|
||||
assert "re-test failed" in reason
|
||||
assert "AssertionError" in reason # output tail embedded
|
||||
|
||||
|
||||
def test_tc09_retest_timeout(fake_worktree, monkeypatch):
|
||||
def _boom(*a, **k):
|
||||
raise subprocess.TimeoutExpired(cmd="pytest", timeout=1)
|
||||
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_retest_timeout_s", 1)
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", _boom)
|
||||
ok, reason = merge_gate.retest_branch("orchestrator", "feature/x")
|
||||
assert ok is False
|
||||
assert "re-test timeout" in reason
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-10..11: merge-lease (serialisation)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def lease_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "repos"
|
||||
d.mkdir()
|
||||
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(d))
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300)
|
||||
return d
|
||||
|
||||
|
||||
def test_tc10_second_acquire_busy_until_released(lease_dir):
|
||||
repo = "orchestrator"
|
||||
ok, _ = merge_gate.acquire_merge_lease(repo, "feature/A", "ORCH-1")
|
||||
assert ok is True
|
||||
|
||||
# A different branch cannot acquire while held.
|
||||
ok2, reason2 = merge_gate.acquire_merge_lease(repo, "feature/B", "ORCH-2")
|
||||
assert ok2 is False
|
||||
assert reason2 == "merge-lock busy"
|
||||
|
||||
# Same holder is idempotent.
|
||||
ok_self, _ = merge_gate.acquire_merge_lease(repo, "feature/A", "ORCH-1")
|
||||
assert ok_self is True
|
||||
|
||||
# Release (holder-aware) frees it for B.
|
||||
merge_gate.release_merge_lease(repo, "feature/A")
|
||||
ok3, _ = merge_gate.acquire_merge_lease(repo, "feature/B", "ORCH-2")
|
||||
assert ok3 is True
|
||||
|
||||
|
||||
def test_tc10_release_is_holder_aware(lease_dir):
|
||||
repo = "orchestrator"
|
||||
merge_gate.acquire_merge_lease(repo, "feature/A", "ORCH-1")
|
||||
# A stale release from a DIFFERENT branch must NOT drop A's lease.
|
||||
merge_gate.release_merge_lease(repo, "feature/OTHER")
|
||||
ok, reason = merge_gate.acquire_merge_lease(repo, "feature/B", "ORCH-2")
|
||||
assert ok is False and reason == "merge-lock busy"
|
||||
|
||||
|
||||
def test_tc11_stale_lease_is_reclaimed(lease_dir, monkeypatch):
|
||||
repo = "orchestrator"
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 10)
|
||||
# Write a lease that is older than the timeout (orphaned by a dead process).
|
||||
path = merge_gate._lease_path(repo)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"branch": "feature/dead", "acquired_at": time.time() - 999, "pid": 1},
|
||||
f,
|
||||
)
|
||||
ok, reason = merge_gate.acquire_merge_lease(repo, "feature/new", "ORCH-9")
|
||||
assert ok is True
|
||||
assert "reclaimed" in reason
|
||||
# The new holder now owns it.
|
||||
held = json.load(open(path, encoding="utf-8"))
|
||||
assert held["branch"] == "feature/new"
|
||||
|
||||
|
||||
def test_tc11_release_missing_is_noop(lease_dir):
|
||||
# Releasing a non-existent lease never raises.
|
||||
merge_gate.release_merge_lease("orchestrator", "feature/none")
|
||||
merge_gate.release_merge_lease("orchestrator") # force form
|
||||
150
tests/test_merge_gate_race.py
Normal file
150
tests/test_merge_gate_race.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""ORCH-043 / TC-24: the parallel-merge race the gate exists to prevent.
|
||||
|
||||
Scenario (two green branches in ONE repo, the self-hosting risk, ТЗ §1):
|
||||
* main is at C1 because branch A already merged.
|
||||
* branch B was validated against C0 (the main it branched from) and is GREEN
|
||||
there — but B has NOT seen A's change. A blind merge of B could break main
|
||||
(semantic conflict): B is "green" yet stale.
|
||||
|
||||
The merge-gate makes this deterministic:
|
||||
1. While A holds the merge-lease, B's gate sees "merge-lock busy" -> DEFER
|
||||
(serialisation: no two catch-up+merge sequences interleave).
|
||||
2. After A releases, B acquires the lease, rebases onto the CURRENT origin/main
|
||||
(C1) and re-tests the COMBINED tree:
|
||||
- re-test GREEN -> gate passes, lease HELD -> B is safe to merge; main stays green.
|
||||
- re-test RED -> gate fails, lease RELEASED -> B rolls back to development;
|
||||
main is NEVER touched.
|
||||
origin/main's SHA is asserted unchanged throughout — the gate never pushes main.
|
||||
|
||||
Real local git (bare origin + clone), real file lease; only the pytest re-test is
|
||||
stubbed (its real behaviour lives in test_merge_gate.py::retest_branch tests).
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_merge_gate_race.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
||||
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
||||
|
||||
from src import git_worktree, merge_gate # noqa: E402
|
||||
from src.qg import checks as qg # noqa: E402
|
||||
from src.qg.checks import check_branch_mergeable # noqa: E402
|
||||
|
||||
|
||||
def _git(cwd, *args):
|
||||
return subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def race_repo(tmp_path, monkeypatch):
|
||||
"""Bare origin at C1 (A merged) + clone + feature/B branched from C0.
|
||||
|
||||
Returns (repo, origin_path). feature/B rebases cleanly onto origin/main.
|
||||
The gate is forced REAL for this repo via merge_gate_repos.
|
||||
"""
|
||||
repo = "orchestrator"
|
||||
repos_dir = tmp_path / "repos"
|
||||
wt_dir = tmp_path / "repos" / "_wt"
|
||||
repos_dir.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(repos_dir))
|
||||
monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir))
|
||||
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(wt_dir))
|
||||
monkeypatch.setattr(qg.settings, "merge_gate_enabled", True)
|
||||
monkeypatch.setattr(qg.settings, "merge_gate_repos", repo)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300)
|
||||
|
||||
origin = tmp_path / "origin.git"
|
||||
subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], capture_output=True)
|
||||
|
||||
seed = tmp_path / "seed"
|
||||
seed.mkdir()
|
||||
_git(str(seed), "init", "-b", "main")
|
||||
_git(str(seed), "config", "user.email", "t@t")
|
||||
_git(str(seed), "config", "user.name", "t")
|
||||
(seed / "README.md").write_text("base\n")
|
||||
_git(str(seed), "add", ".")
|
||||
_git(str(seed), "commit", "-m", "C0")
|
||||
_git(str(seed), "remote", "add", "origin", str(origin))
|
||||
_git(str(seed), "push", "origin", "main")
|
||||
|
||||
# B branches off C0, adds an isolated file (clean rebase onto C1).
|
||||
_git(str(seed), "checkout", "-b", "feature/B")
|
||||
(seed / "b.txt").write_text("from B\n")
|
||||
_git(str(seed), "add", ".")
|
||||
_git(str(seed), "commit", "-m", "feat(B): add b.txt")
|
||||
_git(str(seed), "push", "origin", "feature/B")
|
||||
|
||||
# A merged -> main advances to C1 (touches a DIFFERENT file: no textual conflict).
|
||||
_git(str(seed), "checkout", "main")
|
||||
(seed / "a.txt").write_text("from A\n")
|
||||
_git(str(seed), "add", ".")
|
||||
_git(str(seed), "commit", "-m", "C1 (A merged)")
|
||||
_git(str(seed), "push", "origin", "main")
|
||||
|
||||
main_clone = repos_dir / repo
|
||||
subprocess.run(["git", "clone", str(origin), str(main_clone)], capture_output=True)
|
||||
_git(str(main_clone), "config", "user.email", "t@t")
|
||||
_git(str(main_clone), "config", "user.name", "t")
|
||||
return repo, origin
|
||||
|
||||
|
||||
def _origin_main_sha(origin):
|
||||
return _git(str(origin), "rev-parse", "main").stdout.strip()
|
||||
|
||||
|
||||
def test_tc24_busy_lock_serialises_then_green_catch_up_is_safe(race_repo, monkeypatch):
|
||||
"""A holds the lease -> B defers; after release B catches up + green re-test ->
|
||||
safe merge (lease held), and origin/main is never pushed by the gate."""
|
||||
repo, origin = race_repo
|
||||
main_before = _origin_main_sha(origin)
|
||||
|
||||
# A is mid-merge: it holds the lease.
|
||||
ok, _ = merge_gate.acquire_merge_lease(repo, "feature/A", "ORCH-A")
|
||||
assert ok is True
|
||||
|
||||
# B's gate must DEFER (serialisation), touching nothing.
|
||||
passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B")
|
||||
assert passed is False
|
||||
assert reason == "merge-lock busy"
|
||||
assert _origin_main_sha(origin) == main_before # main untouched
|
||||
|
||||
# A finishes and releases.
|
||||
merge_gate.release_merge_lease(repo, "feature/A")
|
||||
|
||||
# B catches up: real rebase onto C1, GREEN re-test -> pass, lease HELD.
|
||||
monkeypatch.setattr(merge_gate, "retest_branch", lambda r, b: (True, "re-test green"))
|
||||
passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B")
|
||||
assert passed is True
|
||||
assert reason == "rebased onto main, re-test green"
|
||||
# The gate rebased+pushed ONLY the task branch; origin/main is unchanged.
|
||||
assert _origin_main_sha(origin) == main_before
|
||||
# feature/B now contains C1 (a.txt) on origin after the force-with-lease push.
|
||||
assert "a.txt" in _git(str(origin), "ls-tree", "--name-only", "feature/B").stdout
|
||||
# Lease is HELD by B until the actual merge.
|
||||
held = merge_gate._read_lease(merge_gate._lease_path(repo))
|
||||
assert held is not None and held.get("branch") == "feature/B"
|
||||
|
||||
|
||||
def test_tc24_red_catch_up_fails_and_releases_main_stays_green(race_repo, monkeypatch):
|
||||
"""B catches up but the COMBINED tree is red -> gate fails, lease released,
|
||||
origin/main never touched (B will roll back to development upstream)."""
|
||||
repo, origin = race_repo
|
||||
main_before = _origin_main_sha(origin)
|
||||
|
||||
monkeypatch.setattr(
|
||||
merge_gate, "retest_branch",
|
||||
lambda r, b: (False, "re-test failed: ...1 failed, 9 passed"),
|
||||
)
|
||||
passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B")
|
||||
assert passed is False
|
||||
assert reason.startswith("re-test failed after rebase:")
|
||||
# main is still green / untouched.
|
||||
assert _origin_main_sha(origin) == main_before
|
||||
# The lease was released on failure (a later task can proceed).
|
||||
assert merge_gate._read_lease(merge_gate._lease_path(repo)) is None
|
||||
211
tests/test_qg_merge_gate.py
Normal file
211
tests/test_qg_merge_gate.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""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", "")
|
||||
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
|
||||
@@ -28,6 +28,7 @@ _EXPECTED_QGS = {
|
||||
"check_tests_local",
|
||||
"check_deploy_status",
|
||||
"check_staging_status",
|
||||
"check_branch_mergeable", # ORCH-043 merge-gate (deploy-staging -> deploy edge)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -805,6 +805,188 @@ class TestStagingGate:
|
||||
# ---------------------------------------------------------------------------
|
||||
# launcher + plane both delegate to the engine
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestMergeGate:
|
||||
"""ORCH-043 / TC-20..23: the merge-gate sub-gate on the deploy-staging -> deploy
|
||||
edge. The QG ``check_branch_mergeable`` is monkeypatched on stage_engine.QG_CHECKS
|
||||
so we drive the engine's reaction (advance / defer / rollback) deterministically;
|
||||
the gate's own composition is covered in test_qg_merge_gate.py.
|
||||
"""
|
||||
|
||||
def _jobs_full(self):
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT agent, task_content, available_at FROM jobs ORDER BY id"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def test_tc20_pass_advances_to_deploy(self, monkeypatch):
|
||||
"""TC-20 / AC-1: gate PASS (rebased + green) -> advance to deploy, deployer
|
||||
enqueued, NO rollback. staging gate must pass first (same edge)."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_branch_mergeable": _pass},
|
||||
)
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
|
||||
branch="feature/ORCH-043-x")
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "orchestrator", "ORCH-043",
|
||||
"feature/ORCH-043-x", finished_agent="deployer",
|
||||
)
|
||||
assert res.advanced is True
|
||||
assert res.to_stage == "deploy"
|
||||
assert _stage(task_id) == "deploy"
|
||||
assert res.rolled_back_to is None
|
||||
jobs = _jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0]["agent"] == "deployer"
|
||||
|
||||
def test_tc21_busy_lock_defers_without_rollback(self, monkeypatch):
|
||||
"""TC-21 / AC-5: 'merge-lock busy' -> DEFER: task stays on deploy-staging,
|
||||
deployer re-queued with a delay (available_at set), no rollback, no alert."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_branch_mergeable": _fail("merge-lock busy")},
|
||||
)
|
||||
monkeypatch.setattr(stage_engine.settings, "merge_defer_delay_s", 30)
|
||||
monkeypatch.setattr(stage_engine.settings, "merge_defer_max_attempts", 5)
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
|
||||
branch="feature/ORCH-043-x")
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "orchestrator", "ORCH-043",
|
||||
"feature/ORCH-043-x", finished_agent="deployer",
|
||||
)
|
||||
assert res.advanced is False
|
||||
assert res.rolled_back_to is None
|
||||
assert res.note == "merge-gate-deferred"
|
||||
assert _stage(task_id) == "deploy-staging" # stays put
|
||||
jobs = self._jobs_full()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0]["agent"] == "deployer"
|
||||
assert "merge-gate defer" in jobs[0]["task_content"]
|
||||
assert jobs[0]["available_at"] is not None # delayed re-pickup
|
||||
assert stage_engine.set_issue_blocked.called is False
|
||||
|
||||
def test_tc21_defer_exhausted_blocks_and_alerts(self, monkeypatch):
|
||||
"""AC-5: after merge_defer_max_attempts defers -> block + Telegram, no new job."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_branch_mergeable": _fail("merge-lock busy")},
|
||||
)
|
||||
monkeypatch.setattr(stage_engine.settings, "merge_defer_max_attempts", 3)
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
|
||||
branch="feature/ORCH-043-x")
|
||||
# Pre-seed 3 prior defer jobs (the restart-safe counter reads task_content).
|
||||
conn = get_db()
|
||||
for _ in range(3):
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (agent, repo, task_id, task_content) "
|
||||
"VALUES ('deployer','orchestrator',?, 'Note: merge-gate defer')",
|
||||
(task_id,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "orchestrator", "ORCH-043",
|
||||
"feature/ORCH-043-x", finished_agent="deployer",
|
||||
)
|
||||
assert res.advanced is False
|
||||
assert res.note == "merge-gate-defer-exhausted"
|
||||
assert res.alerted is True
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
assert stage_engine.send_telegram.called
|
||||
# No NEW defer job past the cap (still the 3 we seeded).
|
||||
assert len(self._jobs_full()) == 3
|
||||
|
||||
def test_tc22_conflict_rolls_back_to_development(self, monkeypatch):
|
||||
"""TC-22 / AC-3: rebase conflict -> rollback to development + developer retry."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_branch_mergeable": _fail("rebase conflict: src/db.py")},
|
||||
)
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
|
||||
branch="feature/ORCH-043-x")
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "orchestrator", "ORCH-043",
|
||||
"feature/ORCH-043-x", finished_agent="deployer",
|
||||
)
|
||||
assert res.advanced is False
|
||||
assert res.rolled_back_to == "development"
|
||||
assert _stage(task_id) == "development"
|
||||
assert res.qg_name == "check_branch_mergeable"
|
||||
jobs = _jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0]["agent"] == "developer"
|
||||
assert stage_engine.set_issue_in_progress.called
|
||||
|
||||
def test_tc22_red_retest_rolls_back_to_development(self, monkeypatch):
|
||||
"""AC-2/AC-3: red re-test after rebase -> rollback to development."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_branch_mergeable": _fail("re-test failed after rebase: 1 failed")},
|
||||
)
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
|
||||
branch="feature/ORCH-043-x")
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "orchestrator", "ORCH-043",
|
||||
"feature/ORCH-043-x", finished_agent="deployer",
|
||||
)
|
||||
assert res.rolled_back_to == "development"
|
||||
assert _stage(task_id) == "development"
|
||||
jobs = _jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0]["agent"] == "developer"
|
||||
# The rollback task_desc carries the gate reason for the developer.
|
||||
assert "re-test failed after rebase: 1 failed" in _job_contents()[0]
|
||||
|
||||
def test_tc23_rollback_respects_max_developer_retries(self, monkeypatch):
|
||||
"""TC-23 / AC-11: merge-gate rollback is capped by MAX_DEVELOPER_RETRIES —
|
||||
no infinite bounce. 4th attempt -> block + alert, no new developer job."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_branch_mergeable": _fail("rebase conflict: src/db.py")},
|
||||
)
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
|
||||
branch="feature/ORCH-043-x")
|
||||
_add_developer_runs(task_id, 3) # already at the cap
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "orchestrator", "ORCH-043",
|
||||
"feature/ORCH-043-x", finished_agent="deployer",
|
||||
)
|
||||
assert res.rolled_back_to == "development"
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
assert stage_engine.send_telegram.called
|
||||
assert _jobs() == [] # no developer job past the cap
|
||||
|
||||
def test_non_self_hosting_repo_skips_merge_gate(self, monkeypatch):
|
||||
"""Regression: for a non-self-hosting repo the REAL gate is a no-op, so
|
||||
deploy-staging -> deploy advances exactly as before ORCH-043."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS, "check_staging_status": _pass},
|
||||
) # check_branch_mergeable left REAL -> N/A for enduro-trails
|
||||
task_id = _make_task("deploy-staging", repo="enduro-trails", wi="ET-035",
|
||||
branch="feature/ET-035-x")
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "enduro-trails", "ET-035",
|
||||
"feature/ET-035-x", finished_agent="deployer",
|
||||
)
|
||||
assert res.advanced is True
|
||||
assert _stage(task_id) == "deploy"
|
||||
|
||||
|
||||
class TestDelegation:
|
||||
def test_launcher_calls_engine(self):
|
||||
from src.agents.launcher import AgentLauncher
|
||||
|
||||
Reference in New Issue
Block a user