Files
multica/server/pkg/db/generated/issue_label.sql.go
Ayman Alkurdi e9d04ecfc1 feat(labels): ship issue labels (closes #1191) (#1233)
* feat(labels): add issue label CRUD + attach/detach handlers (#1191)

The issue_label and issue_to_label tables were scaffolded in 001_init.up.sql
but never wired to any code path. This commit ships the backend for #1191:

- Migration 048: adds created_at/updated_at timestamps + workspace-scoped
  case-insensitive unique index on label names
- sqlc queries for label CRUD + issue<->label attach/detach + batch list
  (ListLabelsByIssueIDs for board/list views)
- HTTP handlers: /api/labels CRUD, /api/issues/{id}/labels attach/detach
- Protocol events: label:{created,updated,deleted} + issue_labels:changed
- Handler tests covering CRUD, duplicate-name conflict, invalid-color,
  attach/detach idempotency, and cross-workspace isolation

* feat(cli): add label and issue label subcommands (#1191)

- multica label {list,get,create,update,delete}
- multica issue label {list,add,remove}

Both follow existing CLI conventions (JSON/table output, flag shapes)
and exercise the /api/labels endpoints shipped in the previous commit.

* feat(web): add labels UI — picker with inline create + management dialog (#1191)

Exposes the backend label feature to users via the existing issue-detail
sidebar.

- `@multica/core/types/label` — Label, CreateLabelRequest, UpdateLabelRequest,
  plus response envelopes
- `@multica/core/api/client` — 8 methods for label CRUD and issue↔label
  attach/detach
- `@multica/core/labels` — labelKeys, queryOptions, and mutation hooks with
  optimistic updates (matches the project/ module layout)
- WS event type literals extended for label:{created,updated,deleted} and
  issue_labels:changed

- `views/labels/label-chip.tsx` — colored pill; uses relative luminance
  (ITU-R BT.601) to pick #111827 or #f9fafb text so chips stay readable on
  both pastel and saturated backgrounds
- `views/issues/components/pickers/label-picker.tsx`
  - Multi-select combobox in the issue sidebar
  - When 0 labels: "Add label" trigger
  - When 1+ labels: the chips themselves are the trigger; × on each chip
    detaches without opening the picker
  - Inline create: typing a new name + Enter creates with a hash-derived
    color and attaches in one motion (matches Linear/GitHub)
  - "Manage labels…" footer opens a dialog containing the full workspace
    panel — users never leave the issue context to rename/recolor/delete
- `views/issues/components/labels-panel.tsx` — workspace labels manager.
  Single-row create form (color swatch + name + Add button). Each label
  row supports inline rename + recolor + delete (with confirm dialog).
  Color input uses the browser's native picker for full-gamut access —
  no preset palette clutter.

- `PropRow label="Labels"` added to the issue-detail sidebar below Project

Labels are issue metadata everyone uses — not admin configuration.
Putting them in Settings next to destructive workspace actions misframed
them; adding a top-level nav entry or a sibling tab to the Issues page
added surface area that wasn't earning its keep for a feature users
touch occasionally. Keeping management in a dialog launched from the
picker itself keeps users in their issue context and matches how GitHub
handles label editing from the label selector.
2026-04-27 14:23:42 +08:00

248 lines
6.2 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: issue_label.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const attachLabelToIssue = `-- name: AttachLabelToIssue :exec
INSERT INTO issue_to_label (issue_id, label_id)
SELECT $1::uuid, $2::uuid
WHERE EXISTS (
SELECT 1 FROM issue i
WHERE i.id = $1::uuid
AND i.workspace_id = $3::uuid
)
AND EXISTS (
SELECT 1 FROM issue_label l
WHERE l.id = $2::uuid
AND l.workspace_id = $3::uuid
)
ON CONFLICT DO NOTHING
`
type AttachLabelToIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
LabelID pgtype.UUID `json:"label_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Workspace-guarded INSERT: the WHERE EXISTS clauses ensure both the issue
// and the label belong to the given workspace. A future caller that forgets
// handler-level prechecks still cannot attach labels across workspaces.
func (q *Queries) AttachLabelToIssue(ctx context.Context, arg AttachLabelToIssueParams) error {
_, err := q.db.Exec(ctx, attachLabelToIssue, arg.IssueID, arg.LabelID, arg.WorkspaceID)
return err
}
const createLabel = `-- name: CreateLabel :one
INSERT INTO issue_label (workspace_id, name, color)
VALUES ($1, $2, $3)
RETURNING id, workspace_id, name, color, created_at, updated_at
`
type CreateLabelParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name string `json:"name"`
Color string `json:"color"`
}
func (q *Queries) CreateLabel(ctx context.Context, arg CreateLabelParams) (IssueLabel, error) {
row := q.db.QueryRow(ctx, createLabel, arg.WorkspaceID, arg.Name, arg.Color)
var i IssueLabel
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Color,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteLabel = `-- name: DeleteLabel :one
DELETE FROM issue_label
WHERE id = $1 AND workspace_id = $2
RETURNING id
`
type DeleteLabelParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// :one RETURNING id so the handler distinguishes pgx.ErrNoRows (→ 404) from
// infrastructure errors (→ 500), and avoids a TOCTOU precheck.
func (q *Queries) DeleteLabel(ctx context.Context, arg DeleteLabelParams) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, deleteLabel, arg.ID, arg.WorkspaceID)
var id pgtype.UUID
err := row.Scan(&id)
return id, err
}
const detachLabelFromIssue = `-- name: DetachLabelFromIssue :exec
DELETE FROM issue_to_label
WHERE issue_id = $1::uuid
AND label_id = $2::uuid
AND EXISTS (
SELECT 1 FROM issue i
WHERE i.id = $1::uuid
AND i.workspace_id = $3::uuid
)
`
type DetachLabelFromIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
LabelID pgtype.UUID `json:"label_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Workspace-guarded DELETE: only deletes if the issue is in the given
// workspace. Mirror of the attach query.
func (q *Queries) DetachLabelFromIssue(ctx context.Context, arg DetachLabelFromIssueParams) error {
_, err := q.db.Exec(ctx, detachLabelFromIssue, arg.IssueID, arg.LabelID, arg.WorkspaceID)
return err
}
const getLabel = `-- name: GetLabel :one
SELECT id, workspace_id, name, color, created_at, updated_at FROM issue_label
WHERE id = $1 AND workspace_id = $2
`
type GetLabelParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetLabel(ctx context.Context, arg GetLabelParams) (IssueLabel, error) {
row := q.db.QueryRow(ctx, getLabel, arg.ID, arg.WorkspaceID)
var i IssueLabel
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Color,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listLabels = `-- name: ListLabels :many
SELECT id, workspace_id, name, color, created_at, updated_at FROM issue_label
WHERE workspace_id = $1
ORDER BY LOWER(name) ASC
`
func (q *Queries) ListLabels(ctx context.Context, workspaceID pgtype.UUID) ([]IssueLabel, error) {
rows, err := q.db.Query(ctx, listLabels, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []IssueLabel{}
for rows.Next() {
var i IssueLabel
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Color,
&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 listLabelsByIssue = `-- name: ListLabelsByIssue :many
SELECT l.id, l.workspace_id, l.name, l.color, l.created_at, l.updated_at
FROM issue_label l
JOIN issue_to_label il ON il.label_id = l.id
WHERE il.issue_id = $1::uuid
AND l.workspace_id = $2::uuid
ORDER BY LOWER(l.name) ASC
`
type ListLabelsByIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Workspace filter at the SQL layer (mirrors GetProjectInWorkspace). Any caller
// that passes the wrong workspace gets an empty list rather than leaking labels.
func (q *Queries) ListLabelsByIssue(ctx context.Context, arg ListLabelsByIssueParams) ([]IssueLabel, error) {
rows, err := q.db.Query(ctx, listLabelsByIssue, arg.IssueID, arg.WorkspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []IssueLabel{}
for rows.Next() {
var i IssueLabel
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Color,
&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 updateLabel = `-- name: UpdateLabel :one
UPDATE issue_label SET
name = COALESCE($3, name),
color = COALESCE($4, color),
updated_at = now()
WHERE id = $1 AND workspace_id = $2
RETURNING id, workspace_id, name, color, created_at, updated_at
`
type UpdateLabelParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name pgtype.Text `json:"name"`
Color pgtype.Text `json:"color"`
}
func (q *Queries) UpdateLabel(ctx context.Context, arg UpdateLabelParams) (IssueLabel, error) {
row := q.db.QueryRow(ctx, updateLabel,
arg.ID,
arg.WorkspaceID,
arg.Name,
arg.Color,
)
var i IssueLabel
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Color,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}