mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 10:26:46 +08:00
* 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]>
368 lines
12 KiB
Go
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
|
|
}
|