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>
596 lines
24 KiB
Python
596 lines
24 KiB
Python
"""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)
|