Files
multica/server/pkg/db/generated/issue_status.sql.go
T
e3a40b0b8d feat(MUL-6243): custom issue status UI — picker, board, filters, settings (#7103)
* refactor(views): widen IssueStatus to open key, resolve category at render sites

Board columns, swimlane columns and hidden-column panels are CATEGORIES,
not status keys, so adding a custom status never adds a column. Retyped
those to IssueStatusCategory and made every presentation lookup
(STATUS_CONFIG, i18n labels, status icons) resolve the key to its
category first. Status filters stay in concrete keys and map back to
columns via the catalog.

MUL-6243

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

* feat(views): catalog-driven status picker and per-status labels

The picker now offers the workspace catalog's active statuses grouped by
category, instead of the 7 hardcoded built-ins, and falls back to the
built-ins until the catalog lands so a cold render is unchanged.

Built-in names always resolve through i18n, never through the catalog's
server-seeded English name — otherwise every non-English workspace would
see 'In Progress' where it used to see '进行中'. Custom names come from
the catalog. That rule lives in one place: useStatusLabel.

Applied it to every surface that shows an issue's OWN status (detail
activity feed, table group headers and CSV export, hover card, inbox),
which previously showed the category label and so could not tell two
statuses in the same category apart.

MUL-6243

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

* feat(views): show custom statuses on cards and in filters

Board columns and list sections are categories, so two issues in the
same column can sit on different statuses with nothing to tell them
apart. CustomStatusChip is that signal, and it renders nothing for a
built-in — a workspace with no custom statuses looks unchanged.

The status filter now lists the workspace catalog instead of the 7
hardcoded built-ins. Without this an issue moved onto a custom status
could not be narrowed to at all. Picker and filter share one
useStatusOptions hook so the two lists cannot drift apart.

MUL-6243

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

* feat(settings): issue status catalog management with drag reorder

Owner/admin CRUD for custom statuses, organised by CATEGORY rather than
as one flat list. Category is not decoration here — it is the behavior a
status inherits, and picking it is the one moment an admin can, since it
is immutable afterwards. Built-ins are shown but locked: each is its
category's canonical definition.

Drag reorder writes intra-category positions starting at 1, because the
built-in is seeded at 0 and PATCH on it is a 403, so it always heads its
category. The optimistic cache write re-SORTS rather than only writing
positions, or the drag would visually undo itself until the refetch.

custom_issue_statuses is now published to the frontend, so the tab can
hide creation when the deployment has the rollout gate off instead of
offering a button that 403s.

MUL-6243

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

* fix(issues): close the category contract end to end

Elon's review found the board only half-converted: the UI targeted
categories while the server still paged by concrete status key, so a
card on a custom status was dropped twice — it never came back from the
server, and the row filter would have discarded it if it had.

1. New `status_category` group kind (and compound secondary axis).
   Grouping rewrites the column with a static CASE built from the
   catalog; paging expands the category to concrete keys and stays on
   `status = ANY(...)`, so (workspace_id, status) is still usable. A
   workspace with no custom statuses gets exactly `i.status` back.
   Row filter, facet totals and swimlane cells all fold by category now.

2. Column visibility split from the status filter. Hiding a column used
   to write the other six built-in keys into statusFilters, so hiding
   Backlog silently dropped every CUSTOM status in other categories.
   Display state is now its own field. The saved-view baseline also
   stopped deleting custom status filters on reopen.

3. The board/list right-click menu reads the shared catalog options, so
   every entry point that can set a status offers the same set.

4. Remaining presentation consumers resolve a category instead of
   guessing: gantt bars were keyed by IssueStatus and went colorless on
   any custom key; hover card, sidebar, search, tab strip, inbox row,
   create toast and the activity feed all resolve explicitly now.

5. Reorder is one atomic statement behind a new endpoint, and the client
   submits ACTIVE rows only. The per-row PATCH loop half-committed when
   an archived row rejected mid-sequence.

Also: one catalog observer per card instead of three, and the stale
tasks/todo.md draft is gone.

MUL-6243

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

* fix(issues): atomic reorder, category behavior sweep, rollout-safe grouping

Second review round.

1. Reorder is now one transaction holding the catalog's SHARED lock. The
   old shape validated ids, THEN ran the UPDATE — an archive committing
   in that window left the UPDATE silently skipping the archived row
   while the earlier rows were rewritten, and still returned 200. The
   authoritative active set is read under the lock and the payload must
   match it EXACTLY: a partial order would assign positions from the
   array index and collide with the rows it left out. Affected rows are
   checked against the payload before commit.

2. Swept the Web behavior consumers that still compared raw keys, so a
   custom category actually inherits behavior: gantt hide-completed,
   batch backlog parking, done/cancelled strikethrough, the mark-done
   button, sub-issue progress, search cancelled-demotion and mention
   demotion/dimming. issueBehavesAs is the one predicate they share, and
   it fails toward showing more and asking more. Two glyph sites (gantt
   row, swimlane lane) were still painting custom in_review as Todo.

3. Rollout safety. status_category is a contract this PR introduces, and
   the default-off creation flag does not guard the request path, so a
   new Web build would have 400'd against any un-upgraded pod — for every
   workspace. The client now sends it only once the catalog confirms the
   workspace HAS a custom status, which can only be true if the fleet
   already serves this version. Everyone else keeps the exact request
   they made before.

   That also uncovered a real defect: plain status grouping 500'd on a
   workspace holding custom statuses, because the descriptor rejected any
   non-built-in value and failed the whole response.

4. One catalog read per resolve instead of three, and the common path
   reads none at all. Pinned with observable scan-count tests rather than
   an assertion that the SQL string matches.

5. A custom-key filter no longer routes on a guess. categoryOf answers
   todo for anything unloaded, so a saved qa filter paged the todo column
   while the query still restricted status=qa — empty on every cold load,
   permanent if the catalog fetch failed. Unresolved custom keys now
   contribute no column until the catalog is authoritative; facet counts
   drop unknown keys rather than inflating todo.

MUL-6243

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

* fix(issues): swimlane category axis, catalog tri-state, tests with teeth

Third review round.

1. Swimlane was only half-converted. The compound spec sent
   secondary=status_category unconditionally — the same rolling-deploy
   hole the board/list branches had — and the client row matcher then
   compared the raw key, so a qa row the server correctly folded into the
   in_review cell was thrown away again. Both now follow the same
   hasCustomStatuses switch, and the matcher knows which axis it is on.

2. The cold-load fix did not actually make the surface wait. Returning an
   empty column set is indistinguishable from "narrow to nothing", so the
   surface fetched nothing and rendered an empty board with no spinner;
   on catalog failure that was permanent, with no retry. Resolution is
   now pending / resolved / error: pending and error suspend fetching AND
   hold loading, error surfaces isStatusCatalogError. Fetching is
   suspended rather than left to fall back to all columns, which would
   briefly show the UNFILTERED board to someone opening a saved qa view.
   The group contract also joins the branch cursor identity, so a status
   cursor cannot ride into a status_category request.

3. Two tests were false-green and are replaced:
   - The reorder race archived BEFORE calling reorder, so the active-set
     check caught it even with the lock deleted. It now reproduces the
     real interleaving (reorder parked on the lock, archive commits, then
     reorder proceeds) and fails without the shared lock — with a 200,
     which is the actual hole.
   - The catalog read count read pg_stat_user_tables, whose counters flush
     asynchronously, and asserted delta <= 1 — vacuous at zero. It now
     counts through an injected querier and asserts exactly 1 read and 0
     category-expansion reads.

Also: plain status grouping 500'd on a workspace holding custom statuses
(the descriptor rejected non-built-in values and failed the whole
response) — that is the path the Table view and every client fallback
use. And the chat mention items dropped status_category, so a custom done
status in Current/Recent rendered as an active Todo.

MUL-6243

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

* fix(issues): give the status catalog failure a real user path

Fourth review round. One group, both halves of it.

A failed status catalog suspends row fetching, because a custom status
filter cannot be routed to a column without it. That left isLoading and
isEmpty both false, so IssueSurface fell straight through to the content
branch and rendered an empty view with no explanation and no way out —
the controller's isStatusCatalogError had no consumer.

The catalog now carries its own `retry` (React Query's refetch) rather
than just a failure flag, the controller re-exposes it, and IssueSurface
renders an explicit error state ahead of loading/empty/content. Component
test covers the full loop: catalog rejects, error appears with no row
requests issued, clicking retry succeeds, fetching resumes.

The blocking condition is also narrowed. A BACKGROUND refetch can fail
while the last successful catalog is still cached, and error-before-loaded
meant that perfectly serviceable snapshot got a retry screen in front of
it. Only a failure with no data behind it blocks now; otherwise the last
successful catalog keeps resolving.

That rule lives in status-behavior.test.ts, where it can be asserted
directly. The component test asserts the wiring — it was originally
written to claim the rule too, but reverting the narrowing did not fail
it, so the claim moved to the layer that actually holds it.

MUL-6243

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

* fix(issues): accept custom status keys in table filters.statuses

Filtering a board, list or table by a custom status 400'd the whole
request: `filters.statuses` carries the exact keys the user picked, and
the table query still validated that list against the 7 built-ins. Every
surface errored out instead of narrowing — the one thing the filter work
in this PR exists to do.

The predicate is a parameterized `status = ANY(...)`, so an unknown key
simply matches nothing; the check is now non-empty and length-bounded.

Found while writing the post-deploy verification steps, which is where
the gap became obvious: "filter by QA" was step one and it 400s.

MUL-6243

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

---------

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

495 lines
16 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.
//
// 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.
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"`
}
// Reported alongside a status so the UI can say how many issues still carry an
// archived one. NOT an archive precondition: archiving never requires migrating
// issues off the status.
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 listActiveCustomIssueStatusEntries = `-- name: ListActiveCustomIssueStatusEntries :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 category = $2::text
AND is_system = FALSE
AND archived_at IS NULL
ORDER BY position, key
`
type ListActiveCustomIssueStatusEntriesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Category string `json:"category"`
}
// 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.
func (q *Queries) ListActiveCustomIssueStatusEntries(ctx context.Context, arg ListActiveCustomIssueStatusEntriesParams) ([]IssueStatus, error) {
rows, err := q.db.Query(ctx, listActiveCustomIssueStatusEntries, arg.WorkspaceID, arg.Category)
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 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 listIssueStatusKeysByCategories = `-- name: ListIssueStatusKeysByCategories :many
SELECT key FROM issue_status
WHERE workspace_id = $1::uuid
AND category = ANY($2::text[])
`
type ListIssueStatusKeysByCategoriesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Categories []string `json:"categories"`
}
// 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.
func (q *Queries) ListIssueStatusKeysByCategories(ctx context.Context, arg ListIssueStatusKeysByCategoriesParams) ([]string, error) {
rows, err := q.db.Query(ctx, listIssueStatusKeysByCategories, arg.WorkspaceID, arg.Categories)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, err
}
items = append(items, key)
}
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 reorderIssueStatusEntries = `-- name: ReorderIssueStatusEntries :execrows
UPDATE issue_status s
SET position = v.ordinality::int,
updated_at = now()
FROM unnest($2::uuid[]) WITH ORDINALITY AS v(id, ordinality)
WHERE s.id = v.id
AND s.workspace_id = $1::uuid
AND s.is_system = FALSE
AND s.archived_at IS NULL
`
type ReorderIssueStatusEntriesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Ids []pgtype.UUID `json:"ids"`
}
// 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.
//
// Positions start at 1 because the category's built-in is seeded at 0 and can
// never move (is_system rows are excluded here, as they are in every write).
// Archived rows are excluded too: they are frozen, and letting one into the
// write sequence is exactly what made a drag past an archived row half-commit.
func (q *Queries) ReorderIssueStatusEntries(ctx context.Context, arg ReorderIssueStatusEntriesParams) (int64, error) {
result, err := q.db.Exec(ctx, reorderIssueStatusEntries, arg.WorkspaceID, arg.Ids)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
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
}