mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 05:46:58 +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.
253 lines
6.4 KiB
Go
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
|
|
}
|