feat: add lead-only status change rule for boards and update related logic
This commit is contained in:
@@ -1061,9 +1061,7 @@ async def update_task(
|
||||
board_id=board_id,
|
||||
previous_status=previous_status,
|
||||
previous_assigned=previous_assigned,
|
||||
status_requested=(
|
||||
requested_status is not None and requested_status != previous_status
|
||||
),
|
||||
status_requested=(requested_status is not None and requested_status != previous_status),
|
||||
updates=updates,
|
||||
comment=comment,
|
||||
depends_on_task_ids=depends_on_task_ids,
|
||||
@@ -1678,6 +1676,18 @@ async def _apply_non_lead_agent_task_rules(
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
if "status" in update.updates:
|
||||
only_lead_can_change_status = (
|
||||
await session.exec(
|
||||
select(col(Board.only_lead_can_change_status)).where(
|
||||
col(Board.id) == update.board_id,
|
||||
),
|
||||
)
|
||||
).first()
|
||||
if only_lead_can_change_status:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only board leads can change task status.",
|
||||
)
|
||||
status_value = _required_status_value(update.updates["status"])
|
||||
if status_value != "inbox":
|
||||
dep_ids = await _task_dep_ids(
|
||||
|
||||
@@ -42,5 +42,6 @@ class Board(TenantScoped, table=True):
|
||||
require_approval_for_done: bool = Field(default=True)
|
||||
require_review_before_done: bool = Field(default=False)
|
||||
block_status_changes_with_pending_approval: bool = Field(default=False)
|
||||
only_lead_can_change_status: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
updated_at: datetime = Field(default_factory=utcnow)
|
||||
|
||||
@@ -32,6 +32,7 @@ class BoardBase(SQLModel):
|
||||
require_approval_for_done: bool = True
|
||||
require_review_before_done: bool = False
|
||||
block_status_changes_with_pending_approval: bool = False
|
||||
only_lead_can_change_status: bool = False
|
||||
|
||||
|
||||
class BoardCreate(BoardBase):
|
||||
@@ -74,6 +75,7 @@ class BoardUpdate(SQLModel):
|
||||
require_approval_for_done: bool | None = None
|
||||
require_review_before_done: bool | None = None
|
||||
block_status_changes_with_pending_approval: bool | None = None
|
||||
only_lead_can_change_status: bool | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_gateway_id(self) -> Self:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add lead-only status change board rule
|
||||
|
||||
Revision ID: 1a7b2c3d4e5f
|
||||
Revises: c2e9f1a6d4b8
|
||||
Create Date: 2026-02-13 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "1a7b2c3d4e5f"
|
||||
down_revision = "fa6e83f8d9a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
board_columns = {column["name"] for column in inspector.get_columns("boards")}
|
||||
if "only_lead_can_change_status" not in board_columns:
|
||||
op.add_column(
|
||||
"boards",
|
||||
sa.Column(
|
||||
"only_lead_can_change_status",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
board_columns = {column["name"] for column in inspector.get_columns("boards")}
|
||||
if "only_lead_can_change_status" in board_columns:
|
||||
op.drop_column("boards", "only_lead_can_change_status")
|
||||
@@ -87,7 +87,7 @@ If you create cron jobs, track them in memory and delete them when no longer nee
|
||||
|
||||
## Collaboration (mandatory)
|
||||
- You are one of multiple agents on a board. Act like a team, not a silo.
|
||||
- The assigned agent is the DRI for a task. Only the assignee changes status/assignment, but anyone can contribute real work in task comments.
|
||||
- The assigned agent is the DRI for a task. Anyone can contribute real work in task comments.
|
||||
- Task comments are the primary channel for agent-to-agent collaboration.
|
||||
- Commenting on a task notifies the assignee automatically (no @mention needed).
|
||||
- Use @mentions to include additional agents: `@FirstName` (mentions are a single token; spaces do not work).
|
||||
|
||||
@@ -66,7 +66,6 @@ jq -r '
|
||||
|
||||
## Task mentions
|
||||
- If you receive TASK MENTION or are @mentioned in a task, reply in that task.
|
||||
- If you are not assigned, do not change task status or assignment.
|
||||
- If a non-lead peer posts a task update and you are not mentioned, only reply when you add net-new value.
|
||||
|
||||
## Board chat messages
|
||||
|
||||
@@ -87,15 +87,18 @@ def test_board_rule_toggles_have_expected_defaults() -> None:
|
||||
assert created.require_approval_for_done is True
|
||||
assert created.require_review_before_done is False
|
||||
assert created.block_status_changes_with_pending_approval is False
|
||||
assert created.only_lead_can_change_status is False
|
||||
|
||||
updated = BoardUpdate(
|
||||
require_approval_for_done=False,
|
||||
require_review_before_done=True,
|
||||
block_status_changes_with_pending_approval=True,
|
||||
only_lead_can_change_status=True,
|
||||
)
|
||||
assert updated.require_approval_for_done is False
|
||||
assert updated.require_review_before_done is True
|
||||
assert updated.block_status_changes_with_pending_approval is True
|
||||
assert updated.only_lead_can_change_status is True
|
||||
|
||||
|
||||
def test_onboarding_confirm_requires_goal_fields() -> None:
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.organizations import Organization
|
||||
from app.models.tasks import Task
|
||||
from app.schemas.tasks import TaskUpdate
|
||||
from app.schemas.tasks import TaskRead, TaskUpdate
|
||||
|
||||
|
||||
async def _make_engine() -> AsyncEngine:
|
||||
@@ -39,6 +39,8 @@ async def _seed_board_task_and_agent(
|
||||
require_approval_for_done: bool = True,
|
||||
require_review_before_done: bool = False,
|
||||
block_status_changes_with_pending_approval: bool = False,
|
||||
only_lead_can_change_status: bool = False,
|
||||
agent_is_board_lead: bool = False,
|
||||
) -> tuple[Board, Task, Agent]:
|
||||
organization_id = uuid4()
|
||||
gateway = Gateway(
|
||||
@@ -57,6 +59,7 @@ async def _seed_board_task_and_agent(
|
||||
require_approval_for_done=require_approval_for_done,
|
||||
require_review_before_done=require_review_before_done,
|
||||
block_status_changes_with_pending_approval=block_status_changes_with_pending_approval,
|
||||
only_lead_can_change_status=only_lead_can_change_status,
|
||||
)
|
||||
task = Task(id=uuid4(), board_id=board.id, title="Task", status=task_status)
|
||||
agent = Agent(
|
||||
@@ -65,7 +68,7 @@ async def _seed_board_task_and_agent(
|
||||
gateway_id=gateway.id,
|
||||
name="agent",
|
||||
status="online",
|
||||
is_board_lead=False,
|
||||
is_board_lead=agent_is_board_lead,
|
||||
)
|
||||
|
||||
session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
|
||||
@@ -97,8 +100,8 @@ async def _update_task_status(
|
||||
task: Task,
|
||||
agent: Agent,
|
||||
status: Literal["inbox", "in_progress", "review", "done"],
|
||||
) -> None:
|
||||
await tasks_api.update_task(
|
||||
) -> TaskRead:
|
||||
return await tasks_api.update_task(
|
||||
payload=TaskUpdate(status=status),
|
||||
task=task,
|
||||
session=session,
|
||||
@@ -356,6 +359,81 @@ async def test_update_task_allows_status_change_with_pending_approval_when_toggl
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_rejects_non_lead_status_change_when_only_lead_rule_enabled() -> None:
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
_board, task, agent = await _seed_board_task_and_agent(
|
||||
session,
|
||||
task_status="inbox",
|
||||
require_approval_for_done=False,
|
||||
only_lead_can_change_status=True,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _update_task_status(
|
||||
session,
|
||||
task=task,
|
||||
agent=agent,
|
||||
status="in_progress",
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_allows_non_lead_status_change_when_only_lead_rule_disabled() -> None:
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
_board, task, agent = await _seed_board_task_and_agent(
|
||||
session,
|
||||
task_status="inbox",
|
||||
require_approval_for_done=False,
|
||||
only_lead_can_change_status=False,
|
||||
)
|
||||
|
||||
updated = await _update_task_status(
|
||||
session,
|
||||
task=task,
|
||||
agent=agent,
|
||||
status="in_progress",
|
||||
)
|
||||
|
||||
assert updated.status == "in_progress"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_lead_can_still_change_status_when_only_lead_rule_enabled() -> None:
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
_board, task, lead_agent = await _seed_board_task_and_agent(
|
||||
session,
|
||||
task_status="review",
|
||||
require_approval_for_done=False,
|
||||
require_review_before_done=False,
|
||||
only_lead_can_change_status=True,
|
||||
agent_is_board_lead=True,
|
||||
)
|
||||
|
||||
updated = await tasks_api.update_task(
|
||||
payload=TaskUpdate(status="inbox"),
|
||||
task=task,
|
||||
session=session,
|
||||
actor=ActorContext(actor_type="agent", agent=lead_agent),
|
||||
)
|
||||
|
||||
assert updated.status == "inbox"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_allows_dependency_change_with_pending_approval() -> None:
|
||||
engine = await _make_engine()
|
||||
|
||||
Reference in New Issue
Block a user