Files
multica/server/pkg/db/queries/issue_status.sql
T
d95c20aab2 feat: complete issue status category upgrade (MUL-7365 PR3) (#8466)
* feat: converge issue status categories on upgrade (MUL-7365)

Co-authored-by: multica-agent <[email protected]>

* refactor(status): remove redundant stored-category normalization (MUL-7365)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-16 13:51:05 +08:00

174 lines
7.9 KiB
SQL

-- Issue status catalog (MUL-6243). Each workspace holds the 7 built-in
-- statuses plus custom ones, all stored in four lifecycle categories.
-- name: SeedIssueStatusEntries :exec
-- Idempotent seed of the 7 built-ins. Safe to call concurrently from multiple
-- pods during a rolling deploy: the unique (workspace_id, key) index makes a
-- losing racer a no-op rather than an error. Initial positions are 0; the list's
-- built-in tiebreak preserves the seed order until an admin reorders the group.
INSERT INTO issue_status (workspace_id, key, name, description, category, color, is_system, position)
VALUES
(sqlc.arg('workspace_id')::uuid, 'backlog', 'Backlog', 'Parked. Assigning an issue here never starts an agent run.', 'unstarted', '#6b7280', TRUE, 0),
(sqlc.arg('workspace_id')::uuid, 'todo', 'Todo', 'Queued for work. Moving an issue here starts the assigned agent.', 'unstarted', '#6b7280', TRUE, 0),
(sqlc.arg('workspace_id')::uuid, 'in_progress', 'In Progress', 'Actively being worked on.', 'started', '#f59e0b', TRUE, 0),
(sqlc.arg('workspace_id')::uuid, 'in_review', 'In Review', 'Work delivered, waiting on human review. Finalizes the autopilot run.', 'started', '#22c55e', TRUE, 0),
(sqlc.arg('workspace_id')::uuid, 'done', 'Done', 'Completed.', 'done', '#3b82f6', TRUE, 0),
(sqlc.arg('workspace_id')::uuid, 'blocked', 'Blocked', 'Stalled on an external dependency.', 'started', '#ef4444', TRUE, 0),
(sqlc.arg('workspace_id')::uuid, 'cancelled', 'Cancelled', 'Decided not to do.', 'closed', '#6b7280', TRUE, 0)
ON CONFLICT DO NOTHING;
-- name: ListIssueStatusEntries :many
-- Position orders both built-in and custom rows inside each lifecycle group.
-- Built-in order is only a tiebreak for the original seeded positions.
SELECT * FROM issue_status
WHERE workspace_id = sqlc.arg('workspace_id')::uuid
AND (sqlc.arg('include_archived')::bool OR archived_at IS NULL)
ORDER BY
CASE category WHEN 'unstarted' THEN 0 WHEN 'started' THEN 1 WHEN 'done' THEN 2 WHEN 'closed' THEN 3 ELSE 4 END,
position,
CASE WHEN is_system THEN 0 ELSE 1 END,
CASE key
WHEN 'backlog' THEN 0
WHEN 'todo' THEN 1
WHEN 'in_progress' THEN 2
WHEN 'in_review' THEN 3
WHEN 'blocked' THEN 4
WHEN 'done' THEN 5
WHEN 'cancelled' THEN 6
ELSE 7
END,
key;
-- name: GetIssueStatusEntryByKey :one
SELECT * FROM issue_status
WHERE workspace_id = sqlc.arg('workspace_id')::uuid
AND key = sqlc.arg('key')::text;
-- name: GetIssueStatusEntryByID :one
SELECT * FROM issue_status
WHERE id = sqlc.arg('id')::uuid
AND workspace_id = sqlc.arg('workspace_id')::uuid;
-- name: CreateIssueStatusEntry :one
-- Custom statuses only: is_system is never set here, so the canonical-key and
-- non-archivable CHECK constraints can only ever apply to seeded rows.
INSERT INTO issue_status (workspace_id, key, name, description, category, color, icon, position)
VALUES (
sqlc.arg('workspace_id')::uuid,
sqlc.arg('key')::text,
sqlc.arg('name')::text,
sqlc.arg('description')::text,
sqlc.arg('category')::text,
sqlc.arg('color')::text,
sqlc.arg('icon')::text,
COALESCE(
(SELECT MAX(position) + 1 FROM issue_status
WHERE workspace_id = sqlc.arg('workspace_id')::uuid
AND category = sqlc.arg('category')::text),
0
)
)
RETURNING *;
-- name: UpdateIssueStatusEntry :one
-- key and category are absent on purpose: both are immutable after create.
-- The is_system guard is what enforces "built-in name and color are locked" at
-- the storage layer, so a handler bug cannot rename a built-in.
UPDATE issue_status SET
name = COALESCE(sqlc.narg('name'), name),
description = COALESCE(sqlc.narg('description'), description),
color = COALESCE(sqlc.narg('color'), color),
icon = COALESCE(sqlc.narg('icon'), icon),
position = COALESCE(sqlc.narg('position'), position),
updated_at = now()
WHERE id = sqlc.arg('id')::uuid
AND workspace_id = sqlc.arg('workspace_id')::uuid
AND is_system = FALSE
AND archived_at IS NULL
RETURNING *;
-- name: ArchiveIssueStatusEntry :one
-- Built-ins are excluded by is_system: archiving one would delete its
-- category's behavior definition.
--
-- Archiving retires a status from future use only. Issues already on it keep
-- it — Effective ignores archived_at, so their behavior is unchanged — while
-- Resolve rejects it, so nothing new can be assigned to it.
UPDATE issue_status SET
archived_at = now(),
updated_at = now()
WHERE id = sqlc.arg('id')::uuid
AND workspace_id = sqlc.arg('workspace_id')::uuid
AND is_system = FALSE
AND archived_at IS NULL
RETURNING *;
-- name: CountIssuesUsingStatusKey :one
-- Archive precondition, including terminal issues. Call under the catalog lock
-- so a concurrent status assignment cannot invalidate the empty check.
SELECT COUNT(*)::bigint FROM issue
WHERE workspace_id = sqlc.arg('workspace_id')::uuid
AND status = sqlc.arg('key')::text;
-- name: DeleteIssueStatusEntriesForWorkspace :exec
-- No foreign keys by project rule, so workspace teardown cleans up here.
DELETE FROM issue_status WHERE workspace_id = sqlc.arg('workspace_id')::uuid;
-- name: LockIssueStatusCatalog :exec
-- EXCLUSIVE transaction lock over a workspace's status catalog, held by archive
-- so no issue can be written onto a status between the in-use census and the
-- archive itself. pg_advisory_xact_lock (not pg_try_) so a concurrent archive
-- queues instead of failing.
SELECT pg_advisory_xact_lock(hashtextextended(sqlc.arg('workspace_id')::uuid::text || ':issue_status', 0));
-- name: LockIssueStatusCatalogShared :exec
-- SHARED counterpart, taken by a write that puts an issue ON a custom status.
-- Shared holders do not block each other, so concurrent issue writes keep their
-- current throughput; they only block against archive's exclusive lock.
--
-- Built-in statuses deliberately do NOT take this lock: a built-in can never be
-- archived (enforced by issue_status_system_not_archivable), so there is nothing
-- to race with, and the overwhelmingly common write path stays lock-free.
SELECT pg_advisory_xact_lock_shared(hashtextextended(sqlc.arg('workspace_id')::uuid::text || ':issue_status', 0));
-- name: ListIssueStatusKeysByCategories :many
-- Expands a set of categories to the status keys that belong to them, so a
-- category filter can be applied as an INDEXED `status = ANY(...)` predicate
-- instead of wrapping the column in issue_effective_status(), which makes
-- (workspace_id, status) unusable and forces a full workspace scan.
--
-- ARCHIVED statuses are included on purpose: archiving retires a status from
-- future assignment but leaves existing issues on it, and those issues must
-- still appear in their category's column.
SELECT key FROM issue_status
WHERE workspace_id = sqlc.arg('workspace_id')::uuid
AND category = ANY(sqlc.arg('categories')::text[]);
-- name: ListActiveCustomIssueStatusEntries :many
-- One category's ACTIVE custom statuses — the exact set a reorder must cover.
-- Read inside the reorder transaction, under the catalog lock, so a status
-- archived concurrently cannot slip in or out between validation and write.
SELECT * FROM issue_status
WHERE workspace_id = sqlc.arg('workspace_id')::uuid
AND category = sqlc.arg('category')::text
AND is_system = FALSE
AND archived_at IS NULL
ORDER BY position, key;
-- name: ReorderIssueStatusEntries :execrows
-- Atomic intra-category reorder. One statement, so a failure leaves the whole
-- order untouched instead of the partially-applied prefix a per-row PATCH loop
-- produces.
--
-- Full-catalog callers may reorder built-ins, without changing their semantics.
-- Legacy custom-only callers preserve the positions occupied by custom rows.
-- Archived rows remain frozen.
UPDATE issue_status s
SET position = (sqlc.arg('positions')::float8[])[v.ordinality],
updated_at = now()
FROM unnest(sqlc.arg('ids')::uuid[]) WITH ORDINALITY AS v(id, ordinality)
WHERE s.id = v.id
AND s.workspace_id = sqlc.arg('workspace_id')::uuid
AND (sqlc.arg('include_system')::bool OR s.is_system = FALSE)
AND s.archived_at IS NULL;