feat(merge-verify): guarantee idempotent open code-PR before merge_pr (ORCH-082)

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>
This commit is contained in:
2026-06-09 00:48:43 +03:00
committed by stream
parent 74269b467c
commit 0ab6a33ef5
9 changed files with 557 additions and 29 deletions

View File

@@ -98,4 +98,10 @@ def _disable_merge_verify(monkeypatch):
# _handle_merge_verify's confirmed branch. Default it OFF too so unrelated
# deploy->done tests stay 1:1; the dedicated ORCH-073 tests re-enable it.
monkeypatch.setattr(_cfg.settings, "regression_guard_enabled", False, raising=False)
# ORCH-082: the merge-verify ensure_open_pr врезка makes REAL Gitea calls before
# merge_pr. Default it OFF so unrelated deploy->done / merge-verify tests stay 1:1
# (no network); the dedicated ORCH-082 tests re-enable it via their own monkeypatch.
monkeypatch.setattr(
_cfg.settings, "merge_verify_autocreate_pr_enabled", False, raising=False
)
yield

View File

@@ -0,0 +1,163 @@
"""ORCH-082 FR-1 — merge_gate.ensure_open_pr: idempotent open-code-PR actor.
Covers TC-01..05 / AC-2 / AC-6 / AC-7. The actor guarantees an open code-PR
(``head==branch`` AND ``base=="main"``) exists before the deterministic ``merge_pr``,
without ever creating a duplicate. Gitea HTTP is mocked; the actor honours the strict
never-raise contract (any error -> ``("failed", reason)``).
"""
import pytest
from src import merge_gate
REPO = "orchestrator"
BRANCH = "feature/ORCH-082-x"
class _Resp:
"""Minimal httpx.Response stand-in (status_code + json/text)."""
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_pr_timeout_s", 5)
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "owner")
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
def _install_httpx(monkeypatch, get_resp, post_resp=None, record=None):
"""Patch merge_gate's lazily-imported httpx with stub get/post callables."""
import httpx
def fake_get(url, *a, **k):
if record is not None:
record.append(("GET", url, k.get("params")))
return get_resp() if callable(get_resp) else get_resp
def fake_post(url, *a, **k):
if record is not None:
record.append(("POST", url, k.get("json")))
if post_resp is None:
raise AssertionError("POST must NOT be called")
return post_resp() if callable(post_resp) else post_resp
monkeypatch.setattr(httpx, "get", fake_get)
monkeypatch.setattr(httpx, "post", fake_post)
# ---------------------------------------------------------------------------
# TC-01: no open code-PR -> POST creates one -> ("created", N); base==main filter.
# ---------------------------------------------------------------------------
def test_tc01_creates_pr_when_absent(monkeypatch):
record = []
_install_httpx(
monkeypatch,
get_resp=_Resp(200, []), # no open PRs at all
post_resp=_Resp(201, {"number": 42}),
record=record,
)
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
assert (status, detail) == ("created", "42")
# POST body targets head=branch, base=main.
post = [r for r in record if r[0] == "POST"][0]
assert post[2]["head"] == BRANCH
assert post[2]["base"] == "main"
# ---------------------------------------------------------------------------
# TC-02: an open code-PR (head==branch AND base==main) already exists -> existed,
# POST is never called (no duplicate).
# ---------------------------------------------------------------------------
def test_tc02_existed_no_duplicate(monkeypatch):
payload = [{"number": 7, "head": {"ref": BRANCH}, "base": {"ref": "main"}}]
_install_httpx(monkeypatch, get_resp=_Resp(200, payload), post_resp=None)
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
assert (status, detail) == ("existed", "7") # POST stub would raise if called
# ---------------------------------------------------------------------------
# TC-03 (AC-6): only a docs-PR (base != main) exists -> NOT a code-PR -> create on main.
# ---------------------------------------------------------------------------
def test_tc03_docs_pr_not_counted_creates_on_main(monkeypatch):
record = []
# An open PR exists but onto a docs base, and another onto a different head.
docs_payload = [
{"number": 9, "head": {"ref": BRANCH}, "base": {"ref": "docs/logs"}},
{"number": 10, "head": {"ref": "other/branch"}, "base": {"ref": "main"}},
]
_install_httpx(
monkeypatch,
get_resp=_Resp(200, docs_payload),
post_resp=_Resp(201, {"number": 11}),
record=record,
)
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
assert (status, detail) == ("created", "11")
assert any(r[0] == "POST" for r in record)
# ---------------------------------------------------------------------------
# TC-04 (AC-7): Gitea GET/POST raise -> ("failed", reason), never raises.
# ---------------------------------------------------------------------------
def test_tc04_never_raise_on_get_error(monkeypatch):
import httpx
def boom(*a, **k):
raise httpx.ConnectError("gitea down")
monkeypatch.setattr(httpx, "get", boom)
monkeypatch.setattr(httpx, "post", boom)
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
assert status == "failed"
assert detail # carries a reason
def test_tc04_never_raise_on_post_error(monkeypatch):
import httpx
def boom_post(*a, **k):
raise httpx.ConnectError("post exploded")
_install_httpx(monkeypatch, get_resp=_Resp(200, []), post_resp=None)
monkeypatch.setattr(httpx, "post", boom_post)
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
assert status == "failed"
def test_tc04_failed_when_post_non_2xx(monkeypatch):
# A plain non-2xx, non-conflict POST -> failed (not silently swallowed).
_install_httpx(
monkeypatch, get_resp=_Resp(200, []), post_resp=_Resp(500, text="boom")
)
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
assert status == "failed"
assert "500" in detail
# ---------------------------------------------------------------------------
# TC-05 (AC-2 / FR-5): race -> POST returns 409/422 "PR exists" -> re-GET confirms
# the existing PR -> ("existed", N), no duplicate.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("conflict_code", [409, 422])
def test_tc05_race_post_conflict_confirms_existing(monkeypatch, conflict_code):
# First GET: no PR (so we attempt POST). POST: conflict. Re-GET: PR now present.
gets = iter([
_Resp(200, []), # first probe: absent
_Resp(200, [{"number": 99, "head": {"ref": BRANCH}, "base": {"ref": "main"}}]),
])
_install_httpx(
monkeypatch,
get_resp=lambda: next(gets),
post_resp=_Resp(conflict_code, text="pull request already exists"),
)
status, detail = merge_gate.ensure_open_pr(REPO, BRANCH)
assert (status, detail) == ("existed", "99")

View File

@@ -0,0 +1,183 @@
"""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"