Files
orchestrator/tests/test_merge_gate.py
claude-bot 0b25fc1527 fix(merge_gate): retry transient Gitea merge errors + already-in-main guard
merge_pr now wraps ONLY the mutating POST /pulls/{n}/merge in a bounded
exponential-backoff retry-loop on TRANSIENT outcomes (405 "try again later",
408, any 5xx, network/timeout, and 409|422 while the PR is still mergeable);
TERMINAL outcomes (403/404/real conflict via mergeable==False) -> fast honest
False, so the ORCH-071/081 not-merged HOLD backstop is unchanged. Fixes the
ORCH-063 false HOLD + manual re-merge on Gitea's post-push mergeability hiccup.

ensure_open_pr gains an "already fully in main" guard (_branch_fully_in_main,
git merge-base --is-ancestor HEAD origin/main) BEFORE creating a PR -> new
"already-in-main" outcome avoids the garbage empty PR on a re-driven finalizer;
_handle_merge_verify skips merge_pr on that outcome and lets the authoritative
SHA-in-main check confirm -> done (not a HOLD). git error of the guard fails
OPEN to the create path.

New ORCH_MERGE_RETRY_* settings (kill-switch merge_retry_enabled -> one-shot,
max_attempts=3, backoff base=2/max=5). INV-4 (merge only via Gitea PR-merge API,
never push/force-push main), never-raise, STAGE_TRANSITIONS/QG_CHECKS/DB schema
unchanged. Docs (README merge-verify section, CLAUDE.md, CHANGELOG, .env.example)
updated in the same PR. Tests: test_merge_gate.py TC-01..12, test_config.py
TC-13, test_merge_verify.py TC-14..16; full suite green (1389).

Refs: ORCH-093

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:47:20 +03:00

596 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 httpx
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
# ---------------------------------------------------------------------------
# ORCH-065 / TC-16: idempotent merge finalization — pr_already_merged guard.
# ---------------------------------------------------------------------------
class _FakeResp:
def __init__(self, status_code, payload):
self.status_code = status_code
self._payload = payload
def json(self):
return self._payload
def test_tc16_pr_already_merged_true(monkeypatch):
"""A merged code-PR -> True so a re-driven/reaped task is a no-op (no second merge).
ORCH-073 FR-2: the guard now counts a PR only when it carries THIS branch's code
into main (merged & head.ref==branch & base.ref=="main").
"""
monkeypatch.setattr(
httpx, "get",
lambda *a, **k: _FakeResp(
200,
[{"number": 7, "merged": True, "head": {"ref": "feature/x"}, "base": {"ref": "main"}}],
),
)
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is True
def test_tc16_pr_open_not_merged_false(monkeypatch):
"""An open / not-yet-merged PR -> False (the normal merge path proceeds)."""
monkeypatch.setattr(
httpx, "get",
lambda *a, **k: _FakeResp(200, [{"number": 7, "merged": False}]),
)
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False
def test_tc16_pr_no_pr_false(monkeypatch):
monkeypatch.setattr(
httpx, "get", lambda *a, **k: _FakeResp(200, []),
)
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False
def test_tc16_pr_already_merged_never_raises(monkeypatch):
"""Any HTTP/parse error -> False (conservative), never an exception (AC-9)."""
def boom(*a, **k):
raise RuntimeError("gitea down")
monkeypatch.setattr(httpx, "get", boom)
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False
def test_tc16_pr_non_200_false(monkeypatch):
monkeypatch.setattr(
httpx, "get", lambda *a, **k: _FakeResp(500, None),
)
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False
# ---------------------------------------------------------------------------
# ORCH-065 / TC-16 (wiring): the merge path consults the guard.
#
# pr_already_merged is consulted by the actual merge actor — the deployer agent
# (webhooks/gitea.py: "the deployer merges the PR at the START of its run"). The
# `deploy` stage can be re-driven by the job-reaper, so the deployer prompt MUST
# instruct an idempotent pre-merge consult of pr_already_merged (ADR-001 Р-3 /
# README / CHANGELOG). This test fails if that wiring regresses, so the guard can
# never silently become dead code again while the docs claim it is consulted.
# ---------------------------------------------------------------------------
def test_tc16_deployer_prompt_consults_guard():
"""The deployer prompt (merge path) wires the idempotent merge guard."""
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
prompt_path = os.path.join(repo_root, ".openclaw", "agents", "deployer.md")
with open(prompt_path, "r", encoding="utf-8") as f:
prompt = f.read()
# The guard function is named and the prompt instructs consulting it BEFORE merge.
assert "pr_already_merged" in prompt, "deployer prompt must name the guard"
lowered = prompt.lower()
assert "before" in lowered and "merge" in lowered, (
"deployer prompt must instruct consulting the guard BEFORE merging"
)
# The idempotent no-op contract (already merged -> no second merge) is documented.
assert "no second merge" in lowered, (
"deployer prompt must document the already-merged no-op (AC-11)"
)
# ===========================================================================
# ORCH-093: merge_pr transient-retry + ensure_open_pr already-in-main guard.
# TC-01..TC-12 — httpx mocked; time.sleep no-op so backoff never slows tests.
# ===========================================================================
ORCH093_BRANCH = "feature/ORCH-093-x"
class _Resp093:
"""Response stand-in with status_code / json() / text (merge_pr reads .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
def merge093(monkeypatch):
"""Wire Gitea settings + retry defaults; no-op backoff; PR not-already-merged."""
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)
monkeypatch.setattr(merge_gate.settings, "merge_retry_enabled", True)
monkeypatch.setattr(merge_gate.settings, "merge_retry_max_attempts", 3)
monkeypatch.setattr(merge_gate.settings, "merge_retry_backoff_base_s", 2)
monkeypatch.setattr(merge_gate.settings, "merge_retry_backoff_max_s", 5)
monkeypatch.setattr(merge_gate.time, "sleep", lambda *a, **k: None)
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
def _open_code_pr_get(number=7):
"""A list-PRs GET returning exactly one open code-PR (head==branch, base==main)."""
return lambda *a, **k: _Resp093(
200, [{"head": {"ref": ORCH093_BRANCH}, "base": {"ref": "main"}, "number": number}]
)
class _PostSeq:
"""Returns queued responses (or raises queued exceptions) on each POST call."""
def __init__(self, items):
self._items = list(items)
self.calls = 0
def __call__(self, *a, **k):
self.calls += 1
item = self._items.pop(0) if self._items else self._items_last
self._items_last = item
if isinstance(item, Exception):
raise item
return item
# --- TC-01: 405, 405, 200 -> (True, ...); exactly 3 POST; no false False (AC-1) ---
def test_tc01_merge_retries_405_then_succeeds(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(405, text="try again later"),
_Resp093(405, text="try again later"),
_Resp093(200)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is True and "PR #7" in msg
assert seq.calls == 3
# --- TC-02: 503 (5xx) then 200 -> retry -> (True, ...) (AC-1) ---
def test_tc02_merge_retries_5xx_then_succeeds(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(503, text="bad gateway"), _Resp093(200)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is True and seq.calls == 2
# --- TC-03: httpx Timeout in attempt 1, then 200 -> retry; never-raise (AC-1/AC-6) ---
def test_tc03_merge_retries_network_error_then_succeeds(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([httpx.ConnectTimeout("timed out"), _Resp093(200)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is True and seq.calls == 2
# --- TC-04: real conflict 409 + mergeable=False -> (False, ...), no extra POST (AC-2) ---
def test_tc04_real_conflict_terminal_no_retry(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
monkeypatch.setattr(merge_gate, "_pr_mergeable", lambda r, i: False)
seq = _PostSeq([_Resp093(409, text="conflict")])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False and "HTTP 409" in msg
assert seq.calls == 1 # terminal -> no retry
# --- TC-05: ambiguous 409 + mergeable=True -> transient -> retry -> 200 (AC-2) ---
def test_tc05_ambiguous_409_mergeable_true_retries(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
monkeypatch.setattr(merge_gate, "_pr_mergeable", lambda r, i: True)
seq = _PostSeq([_Resp093(409, text="recomputing"), _Resp093(200)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is True and seq.calls == 2
# --- TC-06: 403 (no rights) -> immediate (False, ...) without retry (AC-2) ---
def test_tc06_403_terminal_no_retry(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(403, text="forbidden")])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False and "HTTP 403" in msg and seq.calls == 1
# --- TC-07: 405 on all N attempts -> (False, "merge failed after N attempts: HTTP 405") (AC-3) ---
def test_tc07_exhausts_retries_clear_reason(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(405), _Resp093(405), _Resp093(405)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False
assert "after 3 attempts" in msg and "HTTP 405" in msg
assert seq.calls == 3
# --- TC-08: kill-switch off -> exactly one POST (one-shot) at 405 -> (False, ...) (AC-5/AC-3) ---
def test_tc08_killswitch_off_one_shot(merge093, monkeypatch):
monkeypatch.setattr(merge_gate.settings, "merge_retry_enabled", False)
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(405), _Resp093(200)]) # 2nd would succeed if retried
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False and seq.calls == 1 # one-shot: never retried
# --- TC-09: ensure_open_pr — no open PR, branch fully in main -> already-in-main, no POST (AC-4) ---
def test_tc09_ensure_already_in_main_no_post(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp093(200, [])) # no open PR
monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: True)
monkeypatch.setattr(httpx, "post", lambda *a, **k: (_ for _ in ()).throw(
AssertionError("must NOT POST /pulls for an already-in-main branch")))
status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH)
assert status == "already-in-main"
# --- TC-10: ensure_open_pr — no open PR, commits beyond main -> creates PR (regress) (AC-4) ---
def test_tc10_ensure_creates_when_commits_beyond_main(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp093(200, []))
monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: False)
post_calls = []
def fake_post(url, json=None, headers=None, timeout=None):
post_calls.append(url)
return _Resp093(201, {"number": 12})
monkeypatch.setattr(httpx, "post", fake_post)
status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH)
assert status == "created" and detail == "12"
assert len(post_calls) == 1
# --- TC-11: ensure_open_pr — git error in guard (None) -> fail-OPEN -> create path (AC-6) ---
def test_tc11_ensure_guard_git_error_fail_open(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp093(200, []))
# None == git/OS error / ambiguous -> must NOT block; degrade to create.
monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: None)
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp093(201, {"number": 13}))
status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH)
assert status == "created" # fail-open: did not become a false no-op
def test_tc11_branch_fully_in_main_never_raises(monkeypatch):
"""_branch_fully_in_main: any git/OS error -> None (never-raise) (AC-6)."""
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)
assert merge_gate._branch_fully_in_main("orchestrator", ORCH093_BRANCH) is None
# --- TC-12: merge_pr / ensure_open_pr — uncaught httpx error -> safe tuple (never-raise) (AC-6) ---
def test_tc12_merge_pr_never_raises(merge093, monkeypatch):
def boom(*a, **k):
raise httpx.HTTPError("kaboom")
monkeypatch.setattr(httpx, "get", boom)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False and isinstance(msg, str)
def test_tc12_ensure_open_pr_never_raises(merge093, monkeypatch):
def boom(*a, **k):
raise httpx.HTTPError("kaboom")
monkeypatch.setattr(httpx, "get", boom)
status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH)
assert status == "failed" and isinstance(detail, str)