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,
|
board_id=board_id,
|
||||||
previous_status=previous_status,
|
previous_status=previous_status,
|
||||||
previous_assigned=previous_assigned,
|
previous_assigned=previous_assigned,
|
||||||
status_requested=(
|
status_requested=(requested_status is not None and requested_status != previous_status),
|
||||||
requested_status is not None and requested_status != previous_status
|
|
||||||
),
|
|
||||||
updates=updates,
|
updates=updates,
|
||||||
comment=comment,
|
comment=comment,
|
||||||
depends_on_task_ids=depends_on_task_ids,
|
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)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
if "status" in update.updates:
|
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"])
|
status_value = _required_status_value(update.updates["status"])
|
||||||
if status_value != "inbox":
|
if status_value != "inbox":
|
||||||
dep_ids = await _task_dep_ids(
|
dep_ids = await _task_dep_ids(
|
||||||
|
|||||||
@@ -42,5 +42,6 @@ class Board(TenantScoped, table=True):
|
|||||||
require_approval_for_done: bool = Field(default=True)
|
require_approval_for_done: bool = Field(default=True)
|
||||||
require_review_before_done: bool = Field(default=False)
|
require_review_before_done: bool = Field(default=False)
|
||||||
block_status_changes_with_pending_approval: 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)
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
updated_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_approval_for_done: bool = True
|
||||||
require_review_before_done: bool = False
|
require_review_before_done: bool = False
|
||||||
block_status_changes_with_pending_approval: bool = False
|
block_status_changes_with_pending_approval: bool = False
|
||||||
|
only_lead_can_change_status: bool = False
|
||||||
|
|
||||||
|
|
||||||
class BoardCreate(BoardBase):
|
class BoardCreate(BoardBase):
|
||||||
@@ -74,6 +75,7 @@ class BoardUpdate(SQLModel):
|
|||||||
require_approval_for_done: bool | None = None
|
require_approval_for_done: bool | None = None
|
||||||
require_review_before_done: bool | None = None
|
require_review_before_done: bool | None = None
|
||||||
block_status_changes_with_pending_approval: bool | None = None
|
block_status_changes_with_pending_approval: bool | None = None
|
||||||
|
only_lead_can_change_status: bool | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_gateway_id(self) -> Self:
|
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)
|
## Collaboration (mandatory)
|
||||||
- You are one of multiple agents on a board. Act like a team, not a silo.
|
- 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.
|
- Task comments are the primary channel for agent-to-agent collaboration.
|
||||||
- Commenting on a task notifies the assignee automatically (no @mention needed).
|
- 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).
|
- Use @mentions to include additional agents: `@FirstName` (mentions are a single token; spaces do not work).
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ jq -r '
|
|||||||
|
|
||||||
## Task mentions
|
## Task mentions
|
||||||
- If you receive TASK MENTION or are @mentioned in a task, reply in that task.
|
- 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.
|
- 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
|
## 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_approval_for_done is True
|
||||||
assert created.require_review_before_done is False
|
assert created.require_review_before_done is False
|
||||||
assert created.block_status_changes_with_pending_approval is False
|
assert created.block_status_changes_with_pending_approval is False
|
||||||
|
assert created.only_lead_can_change_status is False
|
||||||
|
|
||||||
updated = BoardUpdate(
|
updated = BoardUpdate(
|
||||||
require_approval_for_done=False,
|
require_approval_for_done=False,
|
||||||
require_review_before_done=True,
|
require_review_before_done=True,
|
||||||
block_status_changes_with_pending_approval=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_approval_for_done is False
|
||||||
assert updated.require_review_before_done is True
|
assert updated.require_review_before_done is True
|
||||||
assert updated.block_status_changes_with_pending_approval 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:
|
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.gateways import Gateway
|
||||||
from app.models.organizations import Organization
|
from app.models.organizations import Organization
|
||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
from app.schemas.tasks import TaskUpdate
|
from app.schemas.tasks import TaskRead, TaskUpdate
|
||||||
|
|
||||||
|
|
||||||
async def _make_engine() -> AsyncEngine:
|
async def _make_engine() -> AsyncEngine:
|
||||||
@@ -39,6 +39,8 @@ async def _seed_board_task_and_agent(
|
|||||||
require_approval_for_done: bool = True,
|
require_approval_for_done: bool = True,
|
||||||
require_review_before_done: bool = False,
|
require_review_before_done: bool = False,
|
||||||
block_status_changes_with_pending_approval: 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]:
|
) -> tuple[Board, Task, Agent]:
|
||||||
organization_id = uuid4()
|
organization_id = uuid4()
|
||||||
gateway = Gateway(
|
gateway = Gateway(
|
||||||
@@ -57,6 +59,7 @@ async def _seed_board_task_and_agent(
|
|||||||
require_approval_for_done=require_approval_for_done,
|
require_approval_for_done=require_approval_for_done,
|
||||||
require_review_before_done=require_review_before_done,
|
require_review_before_done=require_review_before_done,
|
||||||
block_status_changes_with_pending_approval=block_status_changes_with_pending_approval,
|
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)
|
task = Task(id=uuid4(), board_id=board.id, title="Task", status=task_status)
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
@@ -65,7 +68,7 @@ async def _seed_board_task_and_agent(
|
|||||||
gateway_id=gateway.id,
|
gateway_id=gateway.id,
|
||||||
name="agent",
|
name="agent",
|
||||||
status="online",
|
status="online",
|
||||||
is_board_lead=False,
|
is_board_lead=agent_is_board_lead,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
|
session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
|
||||||
@@ -97,8 +100,8 @@ async def _update_task_status(
|
|||||||
task: Task,
|
task: Task,
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
status: Literal["inbox", "in_progress", "review", "done"],
|
status: Literal["inbox", "in_progress", "review", "done"],
|
||||||
) -> None:
|
) -> TaskRead:
|
||||||
await tasks_api.update_task(
|
return await tasks_api.update_task(
|
||||||
payload=TaskUpdate(status=status),
|
payload=TaskUpdate(status=status),
|
||||||
task=task,
|
task=task,
|
||||||
session=session,
|
session=session,
|
||||||
@@ -356,6 +359,81 @@ async def test_update_task_allows_status_change_with_pending_approval_when_toggl
|
|||||||
await engine.dispose()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_update_task_allows_dependency_change_with_pending_approval() -> None:
|
async def test_update_task_allows_dependency_change_with_pending_approval() -> None:
|
||||||
engine = await _make_engine()
|
engine = await _make_engine()
|
||||||
|
|||||||
@@ -231,6 +231,9 @@ export default function EditBoardPage() {
|
|||||||
blockStatusChangesWithPendingApproval,
|
blockStatusChangesWithPendingApproval,
|
||||||
setBlockStatusChangesWithPendingApproval,
|
setBlockStatusChangesWithPendingApproval,
|
||||||
] = useState<boolean | undefined>(undefined);
|
] = useState<boolean | undefined>(undefined);
|
||||||
|
const [onlyLeadCanChangeStatus, setOnlyLeadCanChangeStatus] = useState<
|
||||||
|
boolean | undefined
|
||||||
|
>(undefined);
|
||||||
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
|
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -425,6 +428,8 @@ export default function EditBoardPage() {
|
|||||||
blockStatusChangesWithPendingApproval ??
|
blockStatusChangesWithPendingApproval ??
|
||||||
baseBoard?.block_status_changes_with_pending_approval ??
|
baseBoard?.block_status_changes_with_pending_approval ??
|
||||||
false;
|
false;
|
||||||
|
const resolvedOnlyLeadCanChangeStatus =
|
||||||
|
onlyLeadCanChangeStatus ?? baseBoard?.only_lead_can_change_status ?? false;
|
||||||
const resolvedSuccessMetrics =
|
const resolvedSuccessMetrics =
|
||||||
successMetrics ??
|
successMetrics ??
|
||||||
(baseBoard?.success_metrics
|
(baseBoard?.success_metrics
|
||||||
@@ -498,6 +503,7 @@ export default function EditBoardPage() {
|
|||||||
setBlockStatusChangesWithPendingApproval(
|
setBlockStatusChangesWithPendingApproval(
|
||||||
updated.block_status_changes_with_pending_approval ?? false,
|
updated.block_status_changes_with_pending_approval ?? false,
|
||||||
);
|
);
|
||||||
|
setOnlyLeadCanChangeStatus(updated.only_lead_can_change_status ?? false);
|
||||||
setSuccessMetrics(
|
setSuccessMetrics(
|
||||||
updated.success_metrics
|
updated.success_metrics
|
||||||
? JSON.stringify(updated.success_metrics, null, 2)
|
? JSON.stringify(updated.success_metrics, null, 2)
|
||||||
@@ -559,6 +565,7 @@ export default function EditBoardPage() {
|
|||||||
require_review_before_done: resolvedRequireReviewBeforeDone,
|
require_review_before_done: resolvedRequireReviewBeforeDone,
|
||||||
block_status_changes_with_pending_approval:
|
block_status_changes_with_pending_approval:
|
||||||
resolvedBlockStatusChangesWithPendingApproval,
|
resolvedBlockStatusChangesWithPendingApproval,
|
||||||
|
only_lead_can_change_status: resolvedOnlyLeadCanChangeStatus,
|
||||||
success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
|
success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
|
||||||
target_date:
|
target_date:
|
||||||
resolvedBoardType === "general"
|
resolvedBoardType === "general"
|
||||||
@@ -924,6 +931,41 @@ export default function EditBoardPage() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
{gateways.length === 0 ? (
|
{gateways.length === 0 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user