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()
|
||||
|
||||
@@ -231,6 +231,9 @@ export default function EditBoardPage() {
|
||||
blockStatusChangesWithPendingApproval,
|
||||
setBlockStatusChangesWithPendingApproval,
|
||||
] = useState<boolean | undefined>(undefined);
|
||||
const [onlyLeadCanChangeStatus, setOnlyLeadCanChangeStatus] = useState<
|
||||
boolean | undefined
|
||||
>(undefined);
|
||||
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -425,6 +428,8 @@ export default function EditBoardPage() {
|
||||
blockStatusChangesWithPendingApproval ??
|
||||
baseBoard?.block_status_changes_with_pending_approval ??
|
||||
false;
|
||||
const resolvedOnlyLeadCanChangeStatus =
|
||||
onlyLeadCanChangeStatus ?? baseBoard?.only_lead_can_change_status ?? false;
|
||||
const resolvedSuccessMetrics =
|
||||
successMetrics ??
|
||||
(baseBoard?.success_metrics
|
||||
@@ -498,6 +503,7 @@ export default function EditBoardPage() {
|
||||
setBlockStatusChangesWithPendingApproval(
|
||||
updated.block_status_changes_with_pending_approval ?? false,
|
||||
);
|
||||
setOnlyLeadCanChangeStatus(updated.only_lead_can_change_status ?? false);
|
||||
setSuccessMetrics(
|
||||
updated.success_metrics
|
||||
? JSON.stringify(updated.success_metrics, null, 2)
|
||||
@@ -559,6 +565,7 @@ export default function EditBoardPage() {
|
||||
require_review_before_done: resolvedRequireReviewBeforeDone,
|
||||
block_status_changes_with_pending_approval:
|
||||
resolvedBlockStatusChangesWithPendingApproval,
|
||||
only_lead_can_change_status: resolvedOnlyLeadCanChangeStatus,
|
||||
success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
|
||||
target_date:
|
||||
resolvedBoardType === "general"
|
||||
@@ -924,6 +931,41 @@ export default function EditBoardPage() {
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-lg border border-slate-200 px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={resolvedOnlyLeadCanChangeStatus}
|
||||
aria-label="Only lead can change status"
|
||||
onClick={() =>
|
||||
setOnlyLeadCanChangeStatus(
|
||||
!resolvedOnlyLeadCanChangeStatus,
|
||||
)
|
||||
}
|
||||
disabled={isLoading}
|
||||
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
||||
resolvedOnlyLeadCanChangeStatus
|
||||
? "border-emerald-600 bg-emerald-600"
|
||||
: "border-slate-300 bg-slate-200"
|
||||
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
|
||||
resolvedOnlyLeadCanChangeStatus
|
||||
? "translate-x-5"
|
||||
: "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-medium text-slate-900">
|
||||
Only lead can change status
|
||||
</span>
|
||||
<span className="block text-xs text-slate-600">
|
||||
Restrict status changes to the board lead.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{gateways.length === 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user