feat(git): forbid the agent merge path from merging into master

pr_merge (the gateway path a cell PM uses to merge a leaf/cell PR up the
hierarchy) accepted any target, including a repo's default branch — the
hole that let cell completion land on master. It now refuses any target
equal to the project's default branch with a CEO_ONLY error: a root→master
PR is merged solely by the CEO via approve-&-merge (merge_pr_for_task,
already CEO-gated from awaiting_ceo_approval). Agents open the master PR
and escalate; they never merge it.

Belt-and-suspenders to the integration-branch routing: even if a target
ever resolved to master, this blocks the merge at the GitHub-API boundary.
This commit is contained in:
Renn F
2026-06-04 05:55:03 +02:00
parent 6cce556536
commit 3cfac2e503
2 changed files with 47 additions and 2 deletions
+30 -2
View File
@@ -15,7 +15,7 @@ from uuid import uuid4
import pytest
from roboco.api.schemas.git import GitCreateBranchRequest
from roboco.config import settings
from roboco.services.base import NotFoundError
from roboco.services.base import NotFoundError, UnauthorizedError
from roboco.services.git import GitService
if TYPE_CHECKING:
@@ -245,12 +245,40 @@ async def test_pr_merge_returns_merge_commit_dict() -> None:
_bind(svc, "_call_merge_api", AsyncMock(return_value=fake_resp))
_bind(svc, "_delete_pr_branch_best_effort", AsyncMock())
_bind(svc, "_sync_target_branch", AsyncMock(return_value="abc123sha"))
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
# Merges flow UP the chain (cell -> Main-PM branch), never into master via
# this agent path — target is the integration branch, not the default branch.
with _patch_project_service(fake_project):
out = await svc.pr_merge(11, target="master")
out = await svc.pr_merge(11, target="feature/main_pm/root1234")
assert out == {"merge_commit_sha": "abc123sha"}
@pytest.mark.asyncio
async def test_pr_merge_into_default_branch_is_ceo_only() -> None:
"""The agent merge path refuses to merge into a repo's default branch."""
fake_task = MagicMock(project_id=uuid4(), parent_task_id=None, assigned_to=uuid4())
fake_project = MagicMock(slug="roboco")
result = MagicMock()
result.scalar_one_or_none.return_value = fake_task
svc = _service(execute_returns=result)
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
merge_api = AsyncMock()
_bind(svc, "_call_merge_api", merge_api)
with (
_patch_project_service(fake_project),
pytest.raises(UnauthorizedError, match="CEO_ONLY"),
):
await svc.pr_merge(11, target="master")
# Guard fires before any GitHub merge call.
merge_api.assert_not_called()
# ---------------------------------------------------------------------------
# commit: stages + commits a large changeset with the longer git timeout
# (issue #13 — the panel commit verb timed out on the 30s default budget).