developer(ET): auto-commit from developer run_id=355
This commit is contained in:
@@ -75,3 +75,23 @@ def _reset_webhook_secrets(monkeypatch):
|
||||
if db_path_env:
|
||||
monkeypatch.setattr(db_mod.settings, "db_path", db_path_env, raising=False)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_merge_verify(monkeypatch):
|
||||
"""ORCH-071: disable the merge-verify under-gate by default in ALL tests.
|
||||
|
||||
The under-gate (deploy -> done) runs a deterministic merge-actor + a
|
||||
post-deploy merge verification that make REAL Gitea/git calls. Leaving it ON
|
||||
by default would (a) reach the network from unrelated deploy->done tests and
|
||||
(b) make them pass/fail by ACCIDENT depending on whether the live Gitea still
|
||||
has the historical PR merged (a hidden CI flake). We therefore default it to
|
||||
its documented kill-switch OFF state (``merge_verify_enabled=False`` == 1:1
|
||||
pre-ORCH-071 behaviour). Tests that specifically target the under-gate
|
||||
(test_merge_verify / test_deploy_finalizer_merge_gate / test_merge_actor /
|
||||
test_deploy_restart_merge_recovery) re-enable it via their own monkeypatch
|
||||
AFTER this autouse fixture, scoping the feature ON to just those tests.
|
||||
"""
|
||||
from src import config as _cfg
|
||||
monkeypatch.setattr(_cfg.settings, "merge_verify_enabled", False, raising=False)
|
||||
yield
|
||||
|
||||
188
tests/test_deploy_finalizer_merge_gate.py
Normal file
188
tests/test_deploy_finalizer_merge_gate.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""ORCH-071 — Phase C finalizer x merge-verify under-gate (integration).
|
||||
|
||||
Covers TC-05 (FR-3/G2/AC-1: deploy SUCCESS but PR open -> NOT done + alert),
|
||||
TC-06 (AC-4: deploy SUCCESS + merge confirmed -> done) and TC-14 (AC-11: Phase B
|
||||
runs only on confirm_deploy; merge/verify never introduce an auto-deploy).
|
||||
|
||||
Mirrors tests/test_deploy_terminal_sync.py: the finalizer drives advance_stage,
|
||||
the deploy gate is forced green, and the merge-actor/verifier are mocked so the
|
||||
test stays deterministic (no real Gitea/git).
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_merge_verify.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 import stage_engine # noqa: E402
|
||||
from src import self_deploy # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "write_deploy_log", MagicMock(return_value=True))
|
||||
# The under-gate is disabled by conftest default; these tests target it.
|
||||
monkeypatch.setattr(stage_engine.merge_gate.settings, "merge_verify_enabled", True)
|
||||
monkeypatch.setattr(stage_engine.merge_gate.settings, "merge_verify_repos", "")
|
||||
# The merged_to_main stamp is an observability side effect (no log file here).
|
||||
monkeypatch.setattr(
|
||||
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
|
||||
)
|
||||
# ORCH-021 post-deploy monitor is orthogonal; keep it off for these tests.
|
||||
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", False)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def silence_side_effects(monkeypatch):
|
||||
for name in (
|
||||
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
|
||||
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
|
||||
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
|
||||
"set_issue_blocked", "set_issue_done", "set_issue_analysis",
|
||||
"set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_monitoring",
|
||||
):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
|
||||
|
||||
|
||||
def _pass(*a, **k):
|
||||
return (True, "ok")
|
||||
|
||||
|
||||
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-071-x", wi="ORCH-071"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) VALUES (?, ?, ?, ?, ?)",
|
||||
(f"plane-{wi}", wi, repo, branch, stage),
|
||||
)
|
||||
task_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return task_id
|
||||
|
||||
|
||||
def _stage(task_id):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
conn.close()
|
||||
return row[0]
|
||||
|
||||
|
||||
def _force_deploy_gate_green(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05 (AC-1): deploy_status=SUCCESS but PR open -> task is HELD (not done) + alert.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc05_success_but_not_merged_holds_and_alerts(monkeypatch):
|
||||
self_deploy.write_marker("orchestrator", "ORCH-071", self_deploy.RESULT, "0")
|
||||
_force_deploy_gate_green(monkeypatch)
|
||||
# The merge-actor finds no merge and the verifier confirms NOT merged.
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", MagicMock(return_value=(False, "no open PR")))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", MagicMock(return_value=False))
|
||||
|
||||
task_id = _make_task("deploy")
|
||||
stage_engine.run_deploy_finalizer(
|
||||
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
|
||||
)
|
||||
|
||||
# AC-1 PASS: the task did NOT reach done and was Blocked for manual handling.
|
||||
assert _stage(task_id) == "deploy"
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
assert not stage_engine.set_issue_done.called
|
||||
assert not stage_engine.set_issue_monitoring.called
|
||||
# An alert was sent ("deploy succeeded but not merged").
|
||||
assert stage_engine.send_telegram.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06 (AC-4): deploy_status=SUCCESS + merge confirmed -> done (happy-path).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc06_success_and_merged_reaches_done(monkeypatch):
|
||||
self_deploy.write_marker("orchestrator", "ORCH-071", self_deploy.RESULT, "0")
|
||||
_force_deploy_gate_green(monkeypatch)
|
||||
merge_pr = MagicMock(return_value=(True, "merged PR #1"))
|
||||
verify = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge_pr)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", verify)
|
||||
|
||||
task_id = _make_task("deploy")
|
||||
stage_engine.run_deploy_finalizer(
|
||||
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
|
||||
)
|
||||
|
||||
assert _stage(task_id) == "done"
|
||||
# The deterministic merge-actor + verifier both ran on the deploy->done edge.
|
||||
assert merge_pr.called
|
||||
assert verify.called
|
||||
# Self-hosting: terminal status -> Monitoring (post_deploy off here -> Done set).
|
||||
assert not stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-14 (AC-11): a plain Approved on `deploy` (confirm_deploy=False) is a no-op —
|
||||
# Phase B (prod deploy) requires "Confirm Deploy", and merge/verify do NOT run
|
||||
# (the under-gate never introduces an auto-deploy).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc14_plain_approved_on_deploy_is_noop_no_merge(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_enabled", True)
|
||||
monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_repos", "")
|
||||
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
|
||||
|
||||
merge_pr = MagicMock()
|
||||
verify = MagicMock()
|
||||
initiate = MagicMock(return_value=(True, "ok"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge_pr)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", verify)
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||
|
||||
task_id = _make_task("deploy")
|
||||
# finished_agent=None + confirm_deploy=False == a plain Approved on `deploy`.
|
||||
result = stage_engine.advance_stage(
|
||||
task_id, "deploy", "orchestrator", "ORCH-071", "feature/ORCH-071-x",
|
||||
finished_agent=None, confirm_deploy=False,
|
||||
)
|
||||
|
||||
assert result.note == "approved-on-deploy-noop"
|
||||
assert _stage(task_id) == "deploy"
|
||||
# No prod deploy initiated and the merge-verify under-gate never fired.
|
||||
assert not initiate.called
|
||||
assert not merge_pr.called
|
||||
assert not verify.called
|
||||
|
||||
|
||||
def test_tc14_confirm_deploy_initiates_phase_b(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_enabled", True)
|
||||
monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_repos", "")
|
||||
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
|
||||
initiate = MagicMock(return_value=(True, "ok"))
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||
|
||||
task_id = _make_task("deploy")
|
||||
stage_engine.advance_stage(
|
||||
task_id, "deploy", "orchestrator", "ORCH-071", "feature/ORCH-071-x",
|
||||
finished_agent=None, confirm_deploy=True,
|
||||
)
|
||||
# Only the dedicated "Confirm Deploy" signal initiates the prod deploy.
|
||||
assert initiate.called
|
||||
116
tests/test_deploy_restart_merge_recovery.py
Normal file
116
tests/test_deploy_restart_merge_recovery.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""ORCH-071 TC-10 (AC-3/G3) — merge survives a restart during Phase B (smoke).
|
||||
|
||||
Scenario: the prod container "dies" during Phase B BEFORE the feature PR is merged
|
||||
(the holder of the merge step is gone). Because the merge runs in the
|
||||
restart-surviving Phase C finalizer (deploy->done under-gate), a re-drive of the
|
||||
finalizer in the NEW container catches the merge up: it merges the PR, the verifier
|
||||
turns green and the task finally reaches ``done`` — never stuck without an alert and
|
||||
never ``done`` without a confirmed merge.
|
||||
|
||||
The first finalizer pass models "died before merge": the merge-actor cannot complete
|
||||
and the verifier is red -> HOLD + alert (task stays on ``deploy``). The second pass
|
||||
models the re-drive after the restart: the merge lands, verify is green -> ``done``.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_merge_recovery.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 import stage_engine # noqa: E402
|
||||
from src import self_deploy # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "write_deploy_log", MagicMock(return_value=True))
|
||||
monkeypatch.setattr(stage_engine.merge_gate.settings, "merge_verify_enabled", True)
|
||||
monkeypatch.setattr(stage_engine.merge_gate.settings, "merge_verify_repos", "")
|
||||
monkeypatch.setattr(
|
||||
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
|
||||
)
|
||||
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", False)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def silence_side_effects(monkeypatch):
|
||||
for name in (
|
||||
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
|
||||
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
|
||||
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
|
||||
"set_issue_blocked", "set_issue_done", "set_issue_analysis",
|
||||
"set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_monitoring",
|
||||
):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
|
||||
|
||||
|
||||
def _stage(task_id):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
conn.close()
|
||||
return row[0]
|
||||
|
||||
|
||||
def test_tc10_merge_recovers_after_restart(monkeypatch):
|
||||
self_deploy.write_marker("orchestrator", "ORCH-071", self_deploy.RESULT, "0")
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS, "check_deploy_status": lambda *a, **k: (True, "ok")},
|
||||
)
|
||||
|
||||
# Stateful merge: the FIRST attempt (pre-restart) cannot complete; the SECOND
|
||||
# (the re-driven finalizer after the restart) merges and the verifier goes green.
|
||||
state = {"attempts": 0, "merged": False}
|
||||
|
||||
def fake_merge_pr(repo, branch):
|
||||
state["attempts"] += 1
|
||||
if state["attempts"] == 1:
|
||||
return (False, "interrupted by restart")
|
||||
state["merged"] = True
|
||||
return (True, "merged PR #1")
|
||||
|
||||
def fake_verify(repo, branch, sha):
|
||||
return state["merged"]
|
||||
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", fake_merge_pr)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", fake_verify)
|
||||
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) VALUES (?, ?, ?, ?, ?)",
|
||||
("plane-ORCH-071", "ORCH-071", "orchestrator", "feature/ORCH-071-x", "deploy"),
|
||||
)
|
||||
task_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
job = {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
|
||||
|
||||
# Pass 1 (process died before merge): HOLD — not done, alerted, Blocked.
|
||||
stage_engine.run_deploy_finalizer(job)
|
||||
assert _stage(task_id) == "deploy"
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
assert not stage_engine.set_issue_done.called
|
||||
|
||||
# Pass 2 (finalizer re-driven after restart): merge lands, verify green -> done.
|
||||
stage_engine.run_deploy_finalizer(job)
|
||||
assert _stage(task_id) == "done"
|
||||
assert state["merged"] is True
|
||||
135
tests/test_merge_actor.py
Normal file
135
tests/test_merge_actor.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""ORCH-071 — deterministic merge-actor (merge_gate.merge_pr).
|
||||
|
||||
Covers TC-07 (FR-1: merge via Gitea PR-merge API, no push/force-push), TC-08
|
||||
(AC-9: idempotency — already-merged -> no-op), TC-09 (AC-7: never-raise) and TC-13
|
||||
(AC-8/INV-2: self-hosting safety — no prod restart, no direct/force push to main).
|
||||
Gitea HTTP is mocked; the actor must NEVER shell out to git/docker/ssh.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload if payload is not None else []
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _settings(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", True)
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "admin")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_pr_timeout_s", 5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-07: an OPEN PR -> the actor calls Gitea POST /pulls/{index}/merge (Do: merge).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc07_merge_actor_calls_gitea_merge(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
|
||||
branch = "feature/ORCH-071-x"
|
||||
get_calls, post_calls = [], []
|
||||
|
||||
def fake_get(url, params=None, headers=None, timeout=None):
|
||||
get_calls.append((url, params))
|
||||
return _Resp(200, [{"head": {"ref": branch}, "number": 7}])
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
post_calls.append((url, json))
|
||||
return _Resp(200)
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
monkeypatch.setattr(httpx, "post", fake_post)
|
||||
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", branch)
|
||||
assert ok is True
|
||||
assert "PR #7" in msg
|
||||
# POST hit the PR-merge API endpoint with Do=merge.
|
||||
assert len(post_calls) == 1
|
||||
url, body = post_calls[0]
|
||||
assert url.endswith("/repos/admin/orchestrator/pulls/7/merge")
|
||||
assert body == {"Do": "merge"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08 (AC-9): already-merged PR -> no-op (no second merge, no Gitea error).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc08_idempotent_already_merged(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: True)
|
||||
|
||||
def must_not_call(*a, **k):
|
||||
raise AssertionError("no Gitea call must be made when already merged")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", must_not_call)
|
||||
monkeypatch.setattr(httpx, "post", must_not_call)
|
||||
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
|
||||
assert ok is True
|
||||
assert msg == "already-merged"
|
||||
|
||||
|
||||
def test_tc08_no_open_pr_is_not_an_error(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, []))
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
|
||||
assert ok is False
|
||||
assert msg == "no open PR"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-09 (AC-7): a Gitea HTTP error -> (False, reason), exception not propagated.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc09_never_raise_on_http_error(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise httpx.ConnectError("gitea unreachable")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", boom)
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
|
||||
assert ok is False
|
||||
assert "merge error" in msg
|
||||
|
||||
|
||||
def test_tc09_merge_endpoint_non_2xx_is_false(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 3}])
|
||||
)
|
||||
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(409, text="conflict"))
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
|
||||
assert ok is False
|
||||
assert "HTTP 409" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-13 (AC-8/INV-2): the merge-actor NEVER shells out (no git push/force-push,
|
||||
# no docker/ssh prod restart) — the only side effect is the Gitea PR-merge API.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc13_no_shell_out_no_force_push(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 9}])
|
||||
)
|
||||
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(200))
|
||||
|
||||
subprocess_calls = []
|
||||
monkeypatch.setattr(
|
||||
merge_gate.subprocess, "run",
|
||||
lambda cmd, *a, **k: subprocess_calls.append(cmd),
|
||||
)
|
||||
|
||||
ok, _ = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
|
||||
assert ok is True
|
||||
# No subprocess (git/docker/ssh) was invoked by the merge-actor at all.
|
||||
assert subprocess_calls == []
|
||||
126
tests/test_merge_verify.py
Normal file
126
tests/test_merge_verify.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""ORCH-071 — post-deploy merge verification + rollout conditionality.
|
||||
|
||||
Covers TC-01..04 (FR-2/G1/AC-1/AC-7: verify_merged_to_main), TC-11 (AC-4b: non-self
|
||||
repo no-op) and TC-12 (AC-10: kill-switch). All deterministic: git/HTTP are mocked,
|
||||
the verifier honours the never-raise contract.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
|
||||
class _R:
|
||||
"""Minimal stand-in for a completed subprocess result (returncode only)."""
|
||||
|
||||
def __init__(self, rc):
|
||||
self.returncode = rc
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable(monkeypatch):
|
||||
# The conftest disables the under-gate by default; these tests target it, so
|
||||
# re-enable the feature and pin the scope to self-hosting only (empty CSV).
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", True)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_repos", "")
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_timeout_s", 5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01: validated SHA is an ancestor of origin/main (merge-base rc=0) -> True.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc01_verify_true_when_sha_is_ancestor(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, *a, **k):
|
||||
calls.append(cmd)
|
||||
# fetch -> rc 0; merge-base --is-ancestor -> rc 0 (is ancestor).
|
||||
return _R(0)
|
||||
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", fake_run)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is True
|
||||
# The verifier consulted git merge-base --is-ancestor on origin/main.
|
||||
assert any("merge-base" in c and "--is-ancestor" in c and "origin/main" in c for c in calls)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02: PR.merged==true short-circuits to True even if git is unavailable.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc02_verify_true_when_pr_merged_even_without_git(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: True)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("git must NOT be consulted when PR is already merged")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03: not an ancestor (rc=1) AND PR not merged -> False (phantom merge).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc03_verify_false_when_phantom(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
|
||||
def fake_run(cmd, *a, **k):
|
||||
if "merge-base" in cmd:
|
||||
return _R(1) # NOT an ancestor.
|
||||
return _R(0) # fetch ok.
|
||||
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", fake_run)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04 (AC-7): never-raise — a git/OS error -> False, exception not propagated.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc04_verify_never_raises_on_git_error(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
|
||||
def boom(*a, **k):
|
||||
raise OSError("git exploded")
|
||||
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
|
||||
# No exception escapes; the conservative verdict is "not confirmed".
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
||||
|
||||
|
||||
def test_tc04_verify_never_raises_on_http_error(monkeypatch):
|
||||
def boom(r, b):
|
||||
raise RuntimeError("gitea down")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-11 (AC-4b): non-self repo -> under-gate is a no-op (merge stays with deployer).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc11_non_self_repo_does_not_apply(monkeypatch):
|
||||
# Empty CSV -> only the self-hosting repo is in scope.
|
||||
assert merge_gate.merge_verify_applies("orchestrator") is True
|
||||
assert merge_gate.merge_verify_applies("enduro-trails") is False
|
||||
|
||||
|
||||
def test_tc11_csv_scopes_to_listed_repos(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_repos", "enduro-trails")
|
||||
assert merge_gate.merge_verify_applies("enduro-trails") is True
|
||||
# When the CSV is set, the self repo is NOT auto-included.
|
||||
assert merge_gate.merge_verify_applies("orchestrator") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-12 (AC-10): kill-switch off -> applies False for everyone (1:1 prior behaviour).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc12_kill_switch_disables_under_gate(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", False)
|
||||
assert merge_gate.merge_verify_applies("orchestrator") is False
|
||||
assert merge_gate.merge_verify_applies("enduro-trails") is False
|
||||
@@ -53,6 +53,29 @@ def test_tc15_finalizer_log_roundtrips_through_parser():
|
||||
assert ok_f is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-071 TC-15: the deploy-status parsing contract is UNCHANGED by the new
|
||||
# merge-verify under-gate. The ``merged_to_main:`` observability field the
|
||||
# under-gate stamps into 14-deploy-log.md must NOT influence ``deploy_status:``
|
||||
# parsing — the gate keeps reading ONLY the ``deploy_status:`` frontmatter.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc15_merged_to_main_field_does_not_affect_deploy_status():
|
||||
ok_s, _ = _parse_deploy_status(
|
||||
"---\ndeploy_status: SUCCESS\nmerged_to_main: false\n---\n\nbody"
|
||||
)
|
||||
# deploy_status is the ONLY field read: SUCCESS stays SUCCESS regardless of
|
||||
# the merged_to_main observability stamp (which the under-gate enforces
|
||||
# separately, outside this parser).
|
||||
assert ok_s is True
|
||||
ok_f, _ = _parse_deploy_status(
|
||||
"---\ndeploy_status: FAILED\nmerged_to_main: true\n---\n\nbody"
|
||||
)
|
||||
assert ok_f is False
|
||||
# merged_to_main alone (no deploy_status) is NOT a verdict.
|
||||
ok_n, _ = _parse_deploy_status("---\nmerged_to_main: true\n---\n")
|
||||
assert ok_n is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-061 / TC-04 + TC-05: infra-tolerant staging verdict (pure logic, AC-2/AC-3).
|
||||
#
|
||||
|
||||
@@ -39,3 +39,21 @@ def test_tc16_deploy_staging_transition_unchanged():
|
||||
|
||||
def test_tc16_done_is_terminal():
|
||||
assert get_next_stage("done") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-071 TC-16: the merge-verify under-gate is an EDGE sub-gate врезанный in
|
||||
# advance_stage (like the merge-gate), NOT a new STAGE_TRANSITIONS edge and NOT a
|
||||
# new registered QG. The state machine + QG registry must stay untouched.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc16_merge_verify_adds_no_stage_or_qg():
|
||||
# The deploy->done edge keeps its single gate (no second registered QG).
|
||||
assert STAGE_TRANSITIONS["deploy"]["qg"] == "check_deploy_status"
|
||||
# No new stage was introduced for merge verification.
|
||||
assert "merge-verify" not in STAGE_TRANSITIONS
|
||||
assert "merge_verify" not in STAGE_TRANSITIONS
|
||||
|
||||
from src.qg.checks import QG_CHECKS
|
||||
# The under-gate is NOT a registered quality-gate check.
|
||||
assert "check_merged_to_main" not in QG_CHECKS
|
||||
assert "check_merge_verify" not in QG_CHECKS
|
||||
|
||||
Reference in New Issue
Block a user