2026-06-29 05:38:21 +02:00
|
|
|
"""IntentSpec for the dev `sync_branch` verb (multi-level sequencing Phase B1).
|
|
|
|
|
|
|
|
|
|
Raw shell git is denied to agents by design (the `Bash(git:*)` base deny), so a
|
|
|
|
|
developer whose branch has fallen behind its base had no gate-level way to
|
|
|
|
|
rebase — only the CEO/PM-only `/rebase` HTTP route. `sync_branch` is the dev
|
|
|
|
|
verb that wraps the git rebase through the gate (traced + evidenced), so the
|
|
|
|
|
"everything goes through the gates" invariant holds. These tests lock the spec
|
|
|
|
|
declaration: dev-only, ownership-gated, composes nothing (git-only, no DB
|
|
|
|
|
transition), and present in the dev flow.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
2026-06-30 08:08:35 +02:00
|
|
|
import pytest
|
2026-06-29 05:38:21 +02:00
|
|
|
from roboco.foundation.identity import Role
|
|
|
|
|
from roboco.foundation.policy.lifecycle import (
|
|
|
|
|
Context,
|
|
|
|
|
Decision,
|
|
|
|
|
can_invoke_intent,
|
|
|
|
|
intents_for_role,
|
|
|
|
|
)
|
|
|
|
|
from roboco.services.gateway.role_config import _DEV_FLOW
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sync_branch_is_a_dev_flow_verb() -> None:
|
|
|
|
|
# Declared with _DEV_ROLES, so intents_for_role propagates it into the dev
|
|
|
|
|
# flow automatically — no role_config edit needed (the spec is canon).
|
|
|
|
|
assert "sync_branch" in intents_for_role(Role.DEVELOPER)
|
|
|
|
|
assert "sync_branch" in _DEV_FLOW
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sync_branch_is_dev_only() -> None:
|
|
|
|
|
# QA / documenter / PM cannot call it — it's a developer's branch-sync verb.
|
|
|
|
|
for role in (Role.QA, Role.DOCUMENTER, Role.CELL_PM, Role.MAIN_PM):
|
|
|
|
|
assert "sync_branch" not in intents_for_role(role), (
|
|
|
|
|
f"{role} must not get sync_branch"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class _Task:
|
|
|
|
|
assigned_to: object = None
|
2026-06-30 08:08:35 +02:00
|
|
|
# An active dev state by default so the ownership / role tests don't trip the
|
|
|
|
|
# source-status gate (which they're not exercising). Rejection tests pass an
|
|
|
|
|
# explicit non-active status.
|
|
|
|
|
status: object = "in_progress"
|
2026-06-29 05:38:21 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sync_branch_requires_ownership() -> None:
|
|
|
|
|
# PRECONDITION_OWNERSHIP (rejection_kind='not_authorized') gates it: a task
|
|
|
|
|
# assigned to another agent rejects as not_authorized, not a tracing gap.
|
|
|
|
|
owner = uuid4()
|
|
|
|
|
other = uuid4()
|
|
|
|
|
task = _Task(assigned_to=other)
|
|
|
|
|
decision = can_invoke_intent(
|
|
|
|
|
Role.DEVELOPER, "sync_branch", task, Context(actor_id=owner)
|
|
|
|
|
)
|
|
|
|
|
assert not decision.allowed
|
|
|
|
|
assert decision.rejection_kind == "not_authorized"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sync_branch_allowed_when_owner() -> None:
|
2026-06-30 08:08:35 +02:00
|
|
|
# composes=() and the only preconditions are ownership + an active
|
|
|
|
|
# source-status, so the owner of an in_progress task passes the spec gate.
|
|
|
|
|
# (The choreographer handler does the git work + branch/base guards
|
|
|
|
|
# separately.)
|
2026-06-29 05:38:21 +02:00
|
|
|
owner = uuid4()
|
2026-06-30 08:08:35 +02:00
|
|
|
task = _Task(assigned_to=owner, status="in_progress")
|
2026-06-29 05:38:21 +02:00
|
|
|
decision = can_invoke_intent(
|
|
|
|
|
Role.DEVELOPER, "sync_branch", task, Context(actor_id=owner)
|
|
|
|
|
)
|
|
|
|
|
assert isinstance(decision, Decision)
|
|
|
|
|
assert decision.allowed
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 08:08:35 +02:00
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"status",
|
|
|
|
|
["completed", "cancelled", "paused", "blocked", "awaiting_qa", "pending"],
|
|
|
|
|
)
|
|
|
|
|
def test_sync_branch_rejects_non_active_status(status: str) -> None:
|
|
|
|
|
"""#50: sync_branch composes=() so without a source-status gate the spec
|
|
|
|
|
accepted a terminal / paused / blocked / reviewing task and the handler
|
|
|
|
|
rebased a branch whose task was no longer the dev's to move. The owner of a
|
|
|
|
|
non-active task is now rejected as invalid_state."""
|
|
|
|
|
owner = uuid4()
|
|
|
|
|
task = _Task(assigned_to=owner, status=status)
|
|
|
|
|
decision = can_invoke_intent(
|
|
|
|
|
Role.DEVELOPER, "sync_branch", task, Context(actor_id=owner)
|
|
|
|
|
)
|
|
|
|
|
assert not decision.allowed
|
|
|
|
|
assert decision.rejection_kind == "invalid_state"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("status", ["claimed", "verifying", "needs_revision"])
|
|
|
|
|
def test_sync_branch_allows_active_dev_status(status: str) -> None:
|
|
|
|
|
"""The active dev working states (claimed / in_progress / verifying /
|
|
|
|
|
needs_revision) are exactly where a behind-base rebase is meaningful."""
|
|
|
|
|
owner = uuid4()
|
|
|
|
|
task = _Task(assigned_to=owner, status=status)
|
|
|
|
|
decision = can_invoke_intent(
|
|
|
|
|
Role.DEVELOPER, "sync_branch", task, Context(actor_id=owner)
|
|
|
|
|
)
|
|
|
|
|
assert decision.allowed
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 05:38:21 +02:00
|
|
|
def test_sync_branch_unknown_to_other_role_rejects() -> None:
|
|
|
|
|
# A role not in allowed_roles is rejected as not_authorized (role gating).
|
|
|
|
|
decision = can_invoke_intent(Role.QA, "sync_branch", _Task(), Context())
|
|
|
|
|
assert not decision.allowed
|
|
|
|
|
assert decision.rejection_kind == "not_authorized"
|