mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-06 19:00:01 +02:00
* 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.
248 lines
6.2 KiB
Go
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
|
|
}
|