feat: add board rule toggles for approval and review requirements

This commit is contained in:
Abhimanyu Saharan
2026-02-12 23:05:33 +05:30
parent 8ff75f4c56
commit 855885afaf
12 changed files with 965 additions and 46 deletions

View File

@@ -42,7 +42,10 @@ from app.schemas.errors import BlockedTaskError
from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity
from app.services.approval_task_links import load_task_ids_by_approval
from app.services.approval_task_links import (
load_task_ids_by_approval,
pending_approval_conflicts_by_task,
)
from app.services.mentions import extract_mentions, matches_agent_mention
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
@@ -113,6 +116,151 @@ def _blocked_task_error(blocked_by_task_ids: Sequence[UUID]) -> HTTPException:
)
def _approval_required_for_done_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": (
"Task can only be marked done when a linked approval has been approved."
),
"blocked_by_task_ids": [],
},
)
def _review_required_for_done_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": (
"Task can only be marked done from review when the board rule is enabled."
),
"blocked_by_task_ids": [],
},
)
def _pending_approval_blocks_status_change_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": (
"Task status cannot be changed while a linked approval is pending."
),
"blocked_by_task_ids": [],
},
)
async def _task_has_approved_linked_approval(
session: AsyncSession,
*,
board_id: UUID,
task_id: UUID,
) -> bool:
linked_approval_ids = select(col(ApprovalTaskLink.approval_id)).where(
col(ApprovalTaskLink.task_id) == task_id,
)
statement = (
select(col(Approval.id))
.where(col(Approval.board_id) == board_id)
.where(col(Approval.status) == "approved")
.where(
or_(
col(Approval.task_id) == task_id,
col(Approval.id).in_(linked_approval_ids),
),
)
.limit(1)
)
return (await session.exec(statement)).first() is not None
async def _task_has_pending_linked_approval(
session: AsyncSession,
*,
board_id: UUID,
task_id: UUID,
) -> bool:
conflicts = await pending_approval_conflicts_by_task(
session,
board_id=board_id,
task_ids=[task_id],
)
return task_id in conflicts
async def _require_approved_linked_approval_for_done(
session: AsyncSession,
*,
board_id: UUID,
task_id: UUID,
previous_status: str,
target_status: str,
) -> None:
if previous_status == "done" or target_status != "done":
return
requires_approval = (
await session.exec(
select(col(Board.require_approval_for_done)).where(col(Board.id) == board_id),
)
).first()
if requires_approval is False:
return
if not await _task_has_approved_linked_approval(
session,
board_id=board_id,
task_id=task_id,
):
raise _approval_required_for_done_error()
async def _require_review_before_done_when_enabled(
session: AsyncSession,
*,
board_id: UUID,
previous_status: str,
target_status: str,
) -> None:
if previous_status == "done" or target_status != "done":
return
requires_review = (
await session.exec(
select(col(Board.require_review_before_done)).where(col(Board.id) == board_id),
)
).first()
if requires_review and previous_status != "review":
raise _review_required_for_done_error()
async def _require_no_pending_approval_for_status_change_when_enabled(
session: AsyncSession,
*,
board_id: UUID,
task_id: UUID,
previous_status: str,
target_status: str,
status_requested: bool,
) -> None:
if not status_requested or previous_status == target_status:
return
blocks_status_change = (
await session.exec(
select(col(Board.block_status_changes_with_pending_approval)).where(
col(Board.id) == board_id,
),
)
).first()
if not blocks_status_change:
return
if await _task_has_pending_linked_approval(
session,
board_id=board_id,
task_id=task_id,
):
raise _pending_approval_blocks_status_change_error()
def _truncate_snippet(value: str) -> str:
text = value.strip()
if len(text) <= TASK_SNIPPET_MAX_LEN:
@@ -1447,6 +1595,27 @@ async def _apply_lead_task_update(
else:
await _lead_apply_assignment(session, update=update)
_lead_apply_status(update)
await _require_no_pending_approval_for_status_change_when_enabled(
session,
board_id=update.board_id,
task_id=update.task.id,
previous_status=update.previous_status,
target_status=update.task.status,
status_requested="status" in update.updates,
)
await _require_review_before_done_when_enabled(
session,
board_id=update.board_id,
previous_status=update.previous_status,
target_status=update.task.status,
)
await _require_approved_linked_approval_for_done(
session,
board_id=update.board_id,
task_id=update.task.id,
previous_status=update.previous_status,
target_status=update.task.status,
)
if normalized_tag_ids is not None:
await replace_tags(
@@ -1701,6 +1870,27 @@ async def _finalize_updated_task(
) -> TaskRead:
for key, value in update.updates.items():
setattr(update.task, key, value)
await _require_no_pending_approval_for_status_change_when_enabled(
session,
board_id=update.board_id,
task_id=update.task.id,
previous_status=update.previous_status,
target_status=update.task.status,
status_requested="status" in update.updates,
)
await _require_review_before_done_when_enabled(
session,
board_id=update.board_id,
previous_status=update.previous_status,
target_status=update.task.status,
)
await _require_approved_linked_approval_for_done(
session,
board_id=update.board_id,
task_id=update.task.id,
previous_status=update.previous_status,
target_status=update.task.status,
)
update.task.updated_at = utcnow()
status_raw = update.updates.get("status")