mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -2177,6 +2177,23 @@ class GitService(BaseService):
|
||||
git_token = await self._get_project_token_or_raise(project.slug)
|
||||
owner, repo = self._parse_github_remote(workspace)
|
||||
|
||||
# CEO is the only one who merges to master. This agent-facing merge path
|
||||
# (a cell PM merging a leaf/cell PR up the chain) may NEVER target a
|
||||
# repo's default branch — a root→master PR is merged solely by the CEO
|
||||
# via approve-&-merge (merge_pr_for_task, CEO-gated from
|
||||
# awaiting_ceo_approval). Agents open the master PR and escalate.
|
||||
default_branch = await self._project_default_branch(project.slug)
|
||||
if target == default_branch:
|
||||
raise UnauthorizedError(
|
||||
action="pr_merge",
|
||||
reason=(
|
||||
"CEO_ONLY: merging into the default branch "
|
||||
f"('{default_branch}') is reserved for the CEO via "
|
||||
"approve-&-merge from awaiting_ceo_approval. Open the PR "
|
||||
"and escalate; agents never merge to master."
|
||||
),
|
||||
)
|
||||
|
||||
parent_id = UUID(str(task.parent_task_id)) if task.parent_task_id else None
|
||||
await self._lock_parent_task_for_merge(parent_id)
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user