feat(git): protected-branches enforcement + panel editor (#649)

projects.protected_branches existed end-to-end but nothing consulted it
— the panel had no editor and the git safety checks used hardcoded sets.
Now: GitService._protected_branches_for(slug) (frozenset, stripped,
fail-open to the hardcoded floor with a warning log) is unioned — never
replacing, only tightening — into rebase()'s refusal set, the shared
_delete_remote_branch_best_effort skip set (threaded through every
caller: task cleanup, PR merge/close cleanup), and sync_task_branch,
which now refuses to force-push a protected-named head (the dev-facing
sync_branch verb path the HTTP-only fix would have missed). Matching is
exact and case-sensitive; an empty list degrades to exactly the old
hardcoded behavior, pinned by union-floor regression tests (master/main
stay refused regardless of the project list).

Panel: chips editor for the field in the edit-project dialog (add via
Enter/comma, paste-splitting on comma-separated lists, dedup, clear-to-
empty persists []) with an honest tooltip scoped to what is actually
enforced. Tests cover both the incumbent GitHub-App dialog suite and the
new Protected Branches suite in one harness.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-22 20:30:05 +02:00
committed by GitHub
co-authored by Renn F
parent 4585a248ce
commit da4d9b333d
7 changed files with 775 additions and 25 deletions
+32
View File
@@ -28,6 +28,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.services.base import ValidationError
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
@@ -336,6 +337,37 @@ async def test_sync_branch_git_failure_steers_to_i_am_blocked() -> None:
assert "i_am_blocked" in (env.remediate or "")
@pytest.mark.asyncio
async def test_sync_branch_protected_head_refusal_steers_to_i_am_blocked() -> None:
"""GitService.sync_task_branch's protected-HEAD-branch guard (2026-07-22
follow-up — a mis-set branch_name matching master/main or a project's
declared protected_branches) surfaces through the same generic
invalid_state/i_am_blocked path as any other git failure — the
choreographer doesn't need to special-case it, only propagate it."""
aid = uuid4()
tid = uuid4()
t = _task(tid=tid, aid=aid)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
git_svc = AsyncMock()
git_svc.sync_task_branch.side_effect = ValidationError(
f"REBASE_FORBIDDEN: task branch_name '{_BRANCH}' is a protected branch"
)
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
with patch(
"roboco.services.gateway.choreographer._impl.resolve_parent_branch",
new=AsyncMock(return_value=_BASE),
):
env = await c.sync_branch(aid, tid)
assert env.error == "invalid_state"
assert "REBASE_FORBIDDEN" in (env.message or "")
assert "i_am_blocked" in (env.remediate or "")
@pytest.mark.asyncio
async def test_sync_branch_passes_stash_flag_through() -> None:
"""stash=True on the verb forwards to GitService.sync_task_branch."""