Files
multica/server/pkg/db/generated/issue_status.sql.go
T
446080bd57 MUL-6243 feat(issue-status): per-workspace custom issue statuses over 7 canonical categories (#7065)
* feat(issue-status): per-workspace custom issue statuses over 7 canonical categories (MUL-6243)

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

* fix(issue-status): address review — normalize all status consumers, close archive race, gate rollout, split PK index

Review round 1 (MUL-6243 / PR #7065):

1. Behavior normalization was incomplete. Adds issue_effective_status() as a
   SQL mirror of issuestatus.Effective so the six SQL consumers the Go resolver
   cannot reach (duplicate guard, open-issue list, sub-issue progress, project
   stats, completed-inbox archive, open_only) resolve categories too. Wires the
   resolver into the task-failure sweeper and the stage-barrier helpers.

2. Archive TOCTOU. Archive now runs its in-use census and the archive itself in
   one transaction under an exclusive catalog lock; writes that target a custom
   status take the shared side. Built-in writes stay lock-free — a built-in can
   never be archived.

3. Rollout safety. Creating a custom status is gated behind the default-off
   CustomIssueStatuses flag, so a value old pods cannot interpret cannot exist
   until the fleet is updated. Readers ship ungated and are safe.

4. Primary key index. Follows the repo convention (327-329): table without a
   PK, unique index built CONCURRENTLY in its own migration, attached via
   PRIMARY KEY USING INDEX, and registered for invalid-index cleanup.

5. Status normalization vs stored value. Write paths now persist the canonical
   key from Resolve, so 'HUMAN_REVIEW' no longer validates and then fails the
   column format constraint as a 500.

Also fixes the two CLI tests failing in CI: they asserted the pre-MUL-6243
contract that an unknown-but-well-formed status is rejected locally.

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

* fix(issue-status): close the archive race in both orderings; resolve categories in batch barrier and CLI progress

Review round 2 (MUL-6243 / PR #7065):

1. The archive race was only half closed. Handlers resolved the status BEFORE
   opening a transaction, so an archive could commit in that window and the
   write would still land on an archived key. assertIssueStatusStillActive now
   takes the shared catalog lock and RE-RESOLVES inside the caller's
   transaction, which is what actually makes the status provably active at
   write time. Applied to create, update, batch, and both description-merge
   branches, which previously bypassed the guard entirely. A losing writer gets
   409 rather than a silent partial update.

2. The batch stage-barrier prefilter still compared literals, so a batch that
   moved the last child onto a custom done status left childDoneCompleted empty
   and skipped the parent notification. It now resolves categories first.

3. multica issue children --output json counted only literal done/cancelled.
   ListChildIssues now emits status_category and the CLI reads it, falling back
   to status for older backends.

TestArchiveCommitsInsideTheWriteRaceWindow interleaves the two operations so the
writer validates against an active status and the archive commits before the
write. Verified it fails without the re-resolve (issue stranded on the archived
status) and passes with it.

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

* refactor(issue-status): address review nits — honest race-test naming, one catalog read per list

Review round 3 nits (MUL-6243 / PR #7065):

1. TestArchiveRaceBothOrderings claimed more than it proved. Its 'archive wins'
   cases archived first and only then sent the request, which the pre-flight
   check alone rejects. Split into TestWritesRejectAnArchivedStatus (the
   sequential case, named for what it is) and TestArchiveRefusesAfterACommitted
   Write, and extended TestArchiveCommitsInsideTheWriteRaceWindow — the only
   test that truly interleaves archive between pre-flight and write — from
   create alone to all five write endpoints. Re-verified by deleting the
   in-transaction re-resolve: 4 of 5 fail with issues stranded on the archived
   status (create is guarded in the service layer and checked separately).

2. ListChildIssues / ListChildrenByParents issued one category lookup per
   custom-status child. issuestatus.Resolver now amortizes that to a single
   catalog read per request, lazily on the first custom key, so built-in
   statuses still cost zero queries however long the list is.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 18:32:02 +08:00

368 lines
12 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: issue_status.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const archiveIssueStatusEntry = `-- name: ArchiveIssueStatusEntry :one
UPDATE issue_status SET
archived_at = now(),
updated_at = now()
WHERE id = $1::uuid
AND workspace_id = $2::uuid
AND is_system = FALSE
AND archived_at IS NULL
RETURNING id, workspace_id, key, name, description, category, color, is_system, position, archived_at, created_at, updated_at
`
type ArchiveIssueStatusEntryParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Built-ins are excluded by is_system: archiving one would delete its
// category's behavior definition. Callers must migrate issues off the status
// first; CountIssuesUsingStatusKey is the guard.
func (q *Queries) ArchiveIssueStatusEntry(ctx context.Context, arg ArchiveIssueStatusEntryParams) (IssueStatus, error) {
row := q.db.QueryRow(ctx, archiveIssueStatusEntry, arg.ID, arg.WorkspaceID)
var i IssueStatus
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Key,
&i.Name,
&i.Description,
&i.Category,
&i.Color,
&i.IsSystem,
&i.Position,
&i.ArchivedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const countIssuesUsingStatusKey = `-- name: CountIssuesUsingStatusKey :one
SELECT COUNT(*)::bigint FROM issue
WHERE workspace_id = $1::uuid
AND status = $2::text
`
type CountIssuesUsingStatusKeyParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Key string `json:"key"`
}
func (q *Queries) CountIssuesUsingStatusKey(ctx context.Context, arg CountIssuesUsingStatusKeyParams) (int64, error) {
row := q.db.QueryRow(ctx, countIssuesUsingStatusKey, arg.WorkspaceID, arg.Key)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const createIssueStatusEntry = `-- name: CreateIssueStatusEntry :one
INSERT INTO issue_status (workspace_id, key, name, description, category, color, position)
VALUES (
$1::uuid,
$2::text,
$3::text,
$4::text,
$5::text,
$6::text,
COALESCE(
(SELECT MAX(position) + 1 FROM issue_status
WHERE workspace_id = $1::uuid
AND category = $5::text),
0
)
)
RETURNING id, workspace_id, key, name, description, category, color, is_system, position, archived_at, created_at, updated_at
`
type CreateIssueStatusEntryParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
Color string `json:"color"`
}
// 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.
func (q *Queries) CreateIssueStatusEntry(ctx context.Context, arg CreateIssueStatusEntryParams) (IssueStatus, error) {
row := q.db.QueryRow(ctx, createIssueStatusEntry,
arg.WorkspaceID,
arg.Key,
arg.Name,
arg.Description,
arg.Category,
arg.Color,
)
var i IssueStatus
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Key,
&i.Name,
&i.Description,
&i.Category,
&i.Color,
&i.IsSystem,
&i.Position,
&i.ArchivedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteIssueStatusEntriesForWorkspace = `-- name: DeleteIssueStatusEntriesForWorkspace :exec
DELETE FROM issue_status WHERE workspace_id = $1::uuid
`
// No foreign keys by project rule, so workspace teardown cleans up here.
func (q *Queries) DeleteIssueStatusEntriesForWorkspace(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteIssueStatusEntriesForWorkspace, workspaceID)
return err
}
const getIssueStatusEntryByID = `-- name: GetIssueStatusEntryByID :one
SELECT id, workspace_id, key, name, description, category, color, is_system, position, archived_at, created_at, updated_at FROM issue_status
WHERE id = $1::uuid
AND workspace_id = $2::uuid
`
type GetIssueStatusEntryByIDParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetIssueStatusEntryByID(ctx context.Context, arg GetIssueStatusEntryByIDParams) (IssueStatus, error) {
row := q.db.QueryRow(ctx, getIssueStatusEntryByID, arg.ID, arg.WorkspaceID)
var i IssueStatus
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Key,
&i.Name,
&i.Description,
&i.Category,
&i.Color,
&i.IsSystem,
&i.Position,
&i.ArchivedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getIssueStatusEntryByKey = `-- name: GetIssueStatusEntryByKey :one
SELECT id, workspace_id, key, name, description, category, color, is_system, position, archived_at, created_at, updated_at FROM issue_status
WHERE workspace_id = $1::uuid
AND key = $2::text
`
type GetIssueStatusEntryByKeyParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Key string `json:"key"`
}
func (q *Queries) GetIssueStatusEntryByKey(ctx context.Context, arg GetIssueStatusEntryByKeyParams) (IssueStatus, error) {
row := q.db.QueryRow(ctx, getIssueStatusEntryByKey, arg.WorkspaceID, arg.Key)
var i IssueStatus
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Key,
&i.Name,
&i.Description,
&i.Category,
&i.Color,
&i.IsSystem,
&i.Position,
&i.ArchivedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listIssueStatusEntries = `-- name: ListIssueStatusEntries :many
SELECT id, workspace_id, key, name, description, category, color, is_system, position, archived_at, created_at, updated_at FROM issue_status
WHERE workspace_id = $1::uuid
AND ($2::bool OR archived_at IS NULL)
ORDER BY
CASE category
WHEN 'backlog' THEN 0
WHEN 'todo' THEN 1
WHEN 'in_progress' THEN 2
WHEN 'in_review' THEN 3
WHEN 'done' THEN 4
WHEN 'blocked' THEN 5
WHEN 'cancelled' THEN 6
ELSE 7
END,
position,
key
`
type ListIssueStatusEntriesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
IncludeArchived bool `json:"include_archived"`
}
// Ordered by category rank (the historical STATUS_ORDER, so the default board
// and picker stay pixel-identical for a workspace with no custom statuses),
// then intra-category position, then key as a stable tiebreak.
func (q *Queries) ListIssueStatusEntries(ctx context.Context, arg ListIssueStatusEntriesParams) ([]IssueStatus, error) {
rows, err := q.db.Query(ctx, listIssueStatusEntries, arg.WorkspaceID, arg.IncludeArchived)
if err != nil {
return nil, err
}
defer rows.Close()
items := []IssueStatus{}
for rows.Next() {
var i IssueStatus
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Key,
&i.Name,
&i.Description,
&i.Category,
&i.Color,
&i.IsSystem,
&i.Position,
&i.ArchivedAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockIssueStatusCatalog = `-- name: LockIssueStatusCatalog :exec
SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text || ':issue_status', 0))
`
// 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.
func (q *Queries) LockIssueStatusCatalog(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, lockIssueStatusCatalog, workspaceID)
return err
}
const lockIssueStatusCatalogShared = `-- name: LockIssueStatusCatalogShared :exec
SELECT pg_advisory_xact_lock_shared(hashtextextended($1::uuid::text || ':issue_status', 0))
`
// 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.
func (q *Queries) LockIssueStatusCatalogShared(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, lockIssueStatusCatalogShared, workspaceID)
return err
}
const seedIssueStatusEntries = `-- name: SeedIssueStatusEntries :exec
INSERT INTO issue_status (workspace_id, key, name, description, category, color, is_system, position)
VALUES
($1::uuid, 'backlog', 'Backlog', 'Parked. Assigning an issue here never starts an agent run.', 'backlog', '#6b7280', TRUE, 0),
($1::uuid, 'todo', 'Todo', 'Queued for work. Moving an issue here starts the assigned agent.', 'todo', '#6b7280', TRUE, 0),
($1::uuid, 'in_progress', 'In Progress', 'Actively being worked on.', 'in_progress', '#f59e0b', TRUE, 0),
($1::uuid, 'in_review', 'In Review', 'Work delivered, waiting on human review. Finalizes the autopilot run.', 'in_review', '#22c55e', TRUE, 0),
($1::uuid, 'done', 'Done', 'Completed.', 'done', '#3b82f6', TRUE, 0),
($1::uuid, 'blocked', 'Blocked', 'Stalled on an external dependency.', 'blocked', '#ef4444', TRUE, 0),
($1::uuid, 'cancelled', 'Cancelled', 'Decided not to do.', 'cancelled', '#6b7280', TRUE, 0)
ON CONFLICT DO NOTHING
`
// Issue status catalog (MUL-6243). Each workspace holds the 7 built-in
// statuses plus any custom ones. A category's value IS its canonical built-in
// key, so resolving a custom status to its platform behavior is a plain column
// read — no mapping table, no second concept.
// 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. Positions are intra-category, and
// each built-in is the only member of its category at seed time, so all 0.
func (q *Queries) SeedIssueStatusEntries(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, seedIssueStatusEntries, workspaceID)
return err
}
const updateIssueStatusEntry = `-- name: UpdateIssueStatusEntry :one
UPDATE issue_status SET
name = COALESCE($1, name),
description = COALESCE($2, description),
color = COALESCE($3, color),
position = COALESCE($4, position),
updated_at = now()
WHERE id = $5::uuid
AND workspace_id = $6::uuid
AND is_system = FALSE
AND archived_at IS NULL
RETURNING id, workspace_id, key, name, description, category, color, is_system, position, archived_at, created_at, updated_at
`
type UpdateIssueStatusEntryParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Color pgtype.Text `json:"color"`
Position pgtype.Float8 `json:"position"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// 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.
func (q *Queries) UpdateIssueStatusEntry(ctx context.Context, arg UpdateIssueStatusEntryParams) (IssueStatus, error) {
row := q.db.QueryRow(ctx, updateIssueStatusEntry,
arg.Name,
arg.Description,
arg.Color,
arg.Position,
arg.ID,
arg.WorkspaceID,
)
var i IssueStatus
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Key,
&i.Name,
&i.Description,
&i.Category,
&i.Color,
&i.IsSystem,
&i.Position,
&i.ArchivedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}