Close the missing invariant "by merge-verify time the branch has an open code-PR". The pipeline created a PR only on the developer path with a fresh worktree commit (launcher._ensure_pr), so a branch (e.g. after a manual main restore) could reach the deploy->done merge-verify under-gate PR-less -> merge_pr returned "no open PR" -> a FALSE HOLD (ORCH-074 incident). - merge_gate.ensure_open_pr(repo, branch) -> (status, detail): idempotent leaf-actor (never-raise). GET open PRs filtered head==branch AND base==main (identical to merge_pr/ORCH-073 FR-3 — auto docs-PR is not a code-PR) -> existed; else POST -> created; 409/422 race -> re-GET -> existed (no dup); any other error -> failed. - stage_engine._handle_merge_verify: врезка after validated_revision and BEFORE merge_pr. created|existed -> proceed; failed -> honest HOLD via new _hold_pr_create_failed (note "pr-create-failed-hold", text distinguishable from the not-merged HOLD; task stays on deploy, NO rollback). - launcher._ensure_pr delegated to ensure_open_pr (single PR-creation path, shared head==branch & base==main filter); the developer-only trigger is unchanged. - ORCH-073 protection untouched & authoritative: merge is confirmed ONLY by verify_merged_to_main (SHA-in-main) + check_main_regression. Real un-merged code still HOLDs. - Kill-switch ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED (default true); scope = merge_verify_applies (self-hosting / merge_verify_repos); non-self -> no-op; false -> ORCH-074 behaviour 1:1. No DB migration; main never push/force-push. - Append ORCH-082 marker to MAIN_REGRESSION_MARKERS (append-only convention). - conftest defaults the autocreate flag OFF (mirrors merge_verify_enabled) so unrelated deploy->done tests stay 1:1 (no network). Tests: tests/test_orch082_ensure_pr.py (TC-01..05), tests/test_orch082_merge_verify_autocreate.py (TC-06..12). Docs: README merge-verify block (ORCH-082), CHANGELOG, .env.example. Refs: ORCH-082 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
8.6 KiB
Python
184 lines
8.6 KiB
Python
"""ORCH-082 FR-2/FR-3/FR-4 — ensure_open_pr врезка in _handle_merge_verify.
|
|
|
|
Covers TC-06..12 / AC-3 / AC-4 / AC-5 / AC-7 / AC-8 / AC-9 / FR-5. Calls the
|
|
``deploy -> done`` under-gate handler directly with mocked merge_gate primitives +
|
|
side effects (Plane/Telegram). Asserts the return contract: ``False`` == advance to
|
|
``done``, ``True`` == HOLD (alert, NOT done). The ORCH-073 SHA-in-main proof stays
|
|
authoritative — auto-creating a PR must NEVER mask un-merged code.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
|
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch082.db"))
|
|
|
|
import logging # noqa: E402
|
|
from unittest.mock import MagicMock # noqa: E402
|
|
|
|
import pytest # noqa: E402
|
|
|
|
from src import stage_engine, image_freshness # noqa: E402
|
|
from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402
|
|
|
|
REPO = "orchestrator"
|
|
WI = "ORCH-082"
|
|
BRANCH = "feature/ORCH-082-x"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _wire(monkeypatch):
|
|
# Under-gate in scope; autocreate ON; regression guard OFF (its own tests cover it).
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True)
|
|
monkeypatch.setattr(stage_engine.settings, "merge_verify_autocreate_pr_enabled", True)
|
|
monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", False)
|
|
monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef")
|
|
# Silence Plane/Telegram side effects (assert on .called where relevant).
|
|
for name in ("set_issue_blocked", "plane_add_comment", "send_telegram", "link_for"):
|
|
monkeypatch.setattr(stage_engine, name, MagicMock())
|
|
monkeypatch.setattr(
|
|
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-06 (AC-3): PR absent -> ensure_open_pr creates -> merge_pr -> verify True ->
|
|
# deploy->done with NO false HOLD.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc06_autocreate_then_merge_then_done(monkeypatch):
|
|
ensure = MagicMock(return_value=("created", "5"))
|
|
merge = MagicMock(return_value=(True, "merged PR #5"))
|
|
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure)
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge)
|
|
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
|
|
|
res = AdvanceResult()
|
|
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
|
|
|
assert intervened is False # advance to done
|
|
assert res.alerted is False
|
|
ensure.assert_called_once_with(REPO, BRANCH)
|
|
assert merge.called
|
|
assert not stage_engine.set_issue_blocked.called
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-07 (AC-4 / FR-3): PR created/merged but verify_merged_to_main=False (code not
|
|
# in main) -> HOLD + set_issue_blocked, NOT done, no rollback. ORCH-073 protection
|
|
# is untouched by auto-create.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc07_verify_false_still_holds(monkeypatch):
|
|
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("created", "5"))
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #5"))
|
|
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False)
|
|
|
|
res = AdvanceResult()
|
|
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
|
|
|
assert intervened is True # HOLD
|
|
assert res.advanced is False
|
|
assert res.note == "merge-not-verified-hold"
|
|
assert stage_engine.set_issue_blocked.called
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-08 (AC-7 / AC-5): ensure_open_pr -> failed -> honest HOLD with distinguishable
|
|
# text/note; merge_pr is NOT reached; advance_stage does not raise.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc08_ensure_failed_holds_distinct(monkeypatch):
|
|
monkeypatch.setattr(
|
|
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("failed", "gitea down")
|
|
)
|
|
merge = MagicMock()
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge)
|
|
|
|
res = AdvanceResult()
|
|
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
|
|
|
assert intervened is True # HOLD
|
|
assert res.advanced is False
|
|
assert res.note == "pr-create-failed-hold" # distinct from "merge-not-verified-hold"
|
|
assert not merge.called # merge_pr never reached
|
|
assert stage_engine.set_issue_blocked.called
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-09 (AC-8): kill-switch OFF -> ensure_open_pr NOT called; "no open PR" -> prior
|
|
# HOLD 1:1 (ORCH-074 behaviour reproduced).
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc09_killswitch_off_no_autocreate(monkeypatch):
|
|
monkeypatch.setattr(stage_engine.settings, "merge_verify_autocreate_pr_enabled", False)
|
|
ensure = MagicMock()
|
|
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure)
|
|
# merge_pr finds no open PR -> verify False -> prior not-merged HOLD.
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (False, "no open PR"))
|
|
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False)
|
|
|
|
res = AdvanceResult()
|
|
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
|
|
|
assert intervened is True
|
|
assert res.note == "merge-not-verified-hold" # exactly the prior HOLD
|
|
assert not ensure.called # auto-create skipped entirely
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-10 (AC-9): non-self repo (merge_verify_applies=False) -> врезка no-op, neither
|
|
# ensure_open_pr nor merge_pr called.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc10_non_self_repo_noop(monkeypatch):
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: False)
|
|
ensure = MagicMock()
|
|
merge = MagicMock()
|
|
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure)
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge)
|
|
|
|
res = AdvanceResult()
|
|
intervened = _handle_merge_verify(1, "enduro-trails", "ET-1", "feature/x", res)
|
|
|
|
assert intervened is False # advance unchanged
|
|
assert not ensure.called
|
|
assert not merge.called
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-11 (AC-2 / FR-5): idempotent re-drive (reaper/reconciler) -> ensure existed,
|
|
# merge_pr already-merged -> verify True -> done, no duplicate PR.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc11_idempotent_redrive(monkeypatch):
|
|
ensure = MagicMock(return_value=("existed", "5"))
|
|
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure)
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "already-merged"))
|
|
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
|
|
|
res = AdvanceResult()
|
|
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
|
|
|
assert intervened is False # advance to done
|
|
assert ensure.return_value[0] == "existed"
|
|
assert not stage_engine.set_issue_blocked.called
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-12 (AC-5): logs distinguish created/existed/failed; the create-failed HOLD text
|
|
# differs from the not-merged HOLD text.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc12_logs_distinguish_outcomes(monkeypatch, caplog):
|
|
monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("created", "5"))
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #5"))
|
|
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
|
|
|
with caplog.at_level(logging.INFO, logger="orchestrator"):
|
|
_handle_merge_verify(1, REPO, WI, BRANCH, AdvanceResult())
|
|
assert any("ensure_open_pr -> created" in r.message for r in caplog.records)
|
|
|
|
# create-failed note differs from not-merged note (text-distinguishable HOLD).
|
|
monkeypatch.setattr(
|
|
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("failed", "gitea down")
|
|
)
|
|
res = AdvanceResult()
|
|
_handle_merge_verify(1, REPO, WI, BRANCH, res)
|
|
assert res.note == "pr-create-failed-hold"
|
|
assert res.note != "merge-not-verified-hold"
|