"""``_merge_with_retry`` treats an already-merged PR as idempotent success. A merge PUT on an already-merged PR returns the same 405 as a genuine "not mergeable" conflict. Treating it as a conflict made `cell_pm_complete` try to rebase/escalate a PR that had already landed — the block<->unblock respawn loop. The merge path now disambiguates: already-merged → success (no-op), otherwise a real `MergeConflictError`. """ from __future__ import annotations from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from roboco.exceptions import MergeConflictError from roboco.services.forge import RepoRef from roboco.services.git import GitService def _git_service() -> GitService: svc = GitService.__new__(GitService) svc.log = MagicMock() return svc def _resp(status_code: int, *, is_success: bool) -> Any: return type( "R", (), { "status_code": status_code, "is_success": is_success, "text": "", "json": lambda _self=None: {}, }, )() def _ctx() -> Any: return GitService._MergeContext( repo_ref=RepoRef("acme", "repo"), pr_number=42, git_token="tok", workspace=Path("/ws"), target="feature/main_pm/abc", ) @pytest.mark.asyncio async def test_merge_idempotent_when_pr_already_merged( monkeypatch: pytest.MonkeyPatch, ) -> None: svc = _git_service() monkeypatch.setattr( svc, "_call_merge_api", AsyncMock(return_value=_resp(405, is_success=False)) ) already = AsyncMock(return_value=True) monkeypatch.setattr(svc, "_pr_is_merged", already) # Must NOT raise — an already-merged PR is a no-op success. await svc._merge_with_retry(_ctx()) already.assert_awaited_once() @pytest.mark.asyncio async def test_merge_raises_conflict_when_not_already_merged( monkeypatch: pytest.MonkeyPatch, ) -> None: svc = _git_service() monkeypatch.setattr( svc, "_call_merge_api", AsyncMock(return_value=_resp(405, is_success=False)) ) monkeypatch.setattr(svc, "_pr_is_merged", AsyncMock(return_value=False)) with pytest.raises(MergeConflictError): await svc._merge_with_retry(_ctx()) @pytest.mark.asyncio async def test_pr_is_merged_true_when_github_reports_merged() -> None: svc = _git_service() resp = type( "R", (), {"is_success": True, "json": lambda _self=None: {"merged": True}} )() client = MagicMock() client.__aenter__ = AsyncMock(return_value=client) client.__aexit__ = AsyncMock(return_value=False) client.get = AsyncMock(return_value=resp) with patch("roboco.services.git.httpx.AsyncClient", return_value=client): assert await svc._pr_is_merged(RepoRef("acme", "repo"), 42, "tok") is True @pytest.mark.asyncio async def test_pr_is_merged_false_on_non_success() -> None: svc = _git_service() resp = type("R", (), {"is_success": False, "json": lambda _self=None: {}})() client = MagicMock() client.__aenter__ = AsyncMock(return_value=client) client.__aexit__ = AsyncMock(return_value=False) client.get = AsyncMock(return_value=resp) with patch("roboco.services.git.httpx.AsyncClient", return_value=client): assert await svc._pr_is_merged(RepoRef("acme", "repo"), 42, "tok") is False