Files
multica/server/cmd/multica/cmd_label.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

253 lines
6.4 KiB
Go

package main
import (
"context"
"fmt"
"net/url"
"os"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
// ---------------------------------------------------------------------------
// Label commands — workspace-scoped CRUD for issue labels.
// ---------------------------------------------------------------------------
var labelCmd = &cobra.Command{
Use: "label",
Short: "Work with issue labels",
}
var labelListCmd = &cobra.Command{
Use: "list",
Short: "List labels in the workspace",
RunE: runLabelList,
}
var labelGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get label details",
Args: exactArgs(1),
RunE: runLabelGet,
}
var labelCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new label",
RunE: runLabelCreate,
}
var labelUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a label",
Args: exactArgs(1),
RunE: runLabelUpdate,
}
var labelDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete a label",
Args: exactArgs(1),
RunE: runLabelDelete,
}
func init() {
labelCmd.AddCommand(labelListCmd)
labelCmd.AddCommand(labelGetCmd)
labelCmd.AddCommand(labelCreateCmd)
labelCmd.AddCommand(labelUpdateCmd)
labelCmd.AddCommand(labelDeleteCmd)
labelListCmd.Flags().String("output", "table", "Output format: table or json")
labelGetCmd.Flags().String("output", "json", "Output format: table or json")
labelCreateCmd.Flags().String("name", "", "Label name (required)")
labelCreateCmd.Flags().String("color", "", "Hex color like #3b82f6 (required)")
labelCreateCmd.Flags().String("output", "json", "Output format: table or json")
labelUpdateCmd.Flags().String("name", "", "New name")
labelUpdateCmd.Flags().String("color", "", "New hex color")
labelUpdateCmd.Flags().String("output", "json", "Output format: table or json")
labelDeleteCmd.Flags().String("output", "json", "Output format: table or json")
}
func runLabelList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
params := url.Values{}
if client.WorkspaceID != "" {
params.Set("workspace_id", client.WorkspaceID)
}
path := "/api/labels"
if len(params) > 0 {
path += "?" + params.Encode()
}
var result map[string]any
if err := client.GetJSON(ctx, path, &result); err != nil {
return fmt.Errorf("list labels: %w", err)
}
labelsRaw, _ := result["labels"].([]any)
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, labelsRaw)
}
headers := []string{"ID", "NAME", "COLOR", "CREATED"}
rows := make([][]string, 0, len(labelsRaw))
for _, raw := range labelsRaw {
l, ok := raw.(map[string]any)
if !ok {
continue
}
created := strVal(l, "created_at")
if len(created) >= 10 {
created = created[:10]
}
rows = append(rows, []string{
truncateID(strVal(l, "id")),
strVal(l, "name"),
strVal(l, "color"),
created,
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runLabelGet(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var label map[string]any
if err := client.GetJSON(ctx, "/api/labels/"+args[0], &label); err != nil {
return fmt.Errorf("get label: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "table" {
headers := []string{"ID", "NAME", "COLOR", "CREATED"}
created := strVal(label, "created_at")
if len(created) >= 10 {
created = created[:10]
}
rows := [][]string{{
truncateID(strVal(label, "id")),
strVal(label, "name"),
strVal(label, "color"),
created,
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
return cli.PrintJSON(os.Stdout, label)
}
func runLabelCreate(cmd *cobra.Command, _ []string) error {
name, _ := cmd.Flags().GetString("name")
color, _ := cmd.Flags().GetString("color")
if name == "" {
return fmt.Errorf("--name is required")
}
if color == "" {
return fmt.Errorf("--color is required (e.g. #3b82f6)")
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
body := map[string]any{"name": name, "color": color}
var result map[string]any
if err := client.PostJSON(ctx, "/api/labels", body, &result); err != nil {
return fmt.Errorf("create label: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "table" {
headers := []string{"ID", "NAME", "COLOR"}
rows := [][]string{{
truncateID(strVal(result, "id")),
strVal(result, "name"),
strVal(result, "color"),
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
return cli.PrintJSON(os.Stdout, result)
}
func runLabelUpdate(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
body := map[string]any{}
if v, _ := cmd.Flags().GetString("name"); v != "" {
body["name"] = v
}
if v, _ := cmd.Flags().GetString("color"); v != "" {
body["color"] = v
}
if len(body) == 0 {
return fmt.Errorf("nothing to update — provide --name and/or --color")
}
var result map[string]any
if err := client.PutJSON(ctx, "/api/labels/"+args[0], body, &result); err != nil {
return fmt.Errorf("update label: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "table" {
headers := []string{"ID", "NAME", "COLOR"}
rows := [][]string{{
truncateID(strVal(result, "id")),
strVal(result, "name"),
strVal(result, "color"),
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
return cli.PrintJSON(os.Stdout, result)
}
func runLabelDelete(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := client.DeleteJSON(ctx, "/api/labels/"+args[0]); err != nil {
return fmt.Errorf("delete label: %w", err)
}
// JSON consumers get machine-readable output; humans get natural language.
if output, _ := cmd.Flags().GetString("output"); output == "json" {
return cli.PrintJSON(os.Stdout, map[string]any{"id": args[0], "deleted": true})
}
fmt.Fprintf(os.Stdout, "Label %s deleted.\n", args[0])
return nil
}