feat: add lead-only status change rule for boards and update related logic

This commit is contained in:
Abhimanyu Saharan
2026-02-13 16:21:54 +05:30
parent 366f5231ab
commit ebb9c659d2
9 changed files with 187 additions and 9 deletions

View File

@@ -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(

View File

@@ -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)

View File

@@ -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:

View File

@@ -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")

View File

@@ -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).

View File

@@ -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

View File

@@ -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:

View File

@@ -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()

View File

@@ -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 ? (