Files
multica/server/cmd/multica/cmd_label.go
LinYushen 28de8b8bde feat(cli): central error translation layer (PR1, MUL-3104) (#3892)
* feat(cli): add central error translation layer (PR1)

Introduce server/internal/cli/errors.go, a single user-facing error
translation layer that collapses raw transport errors, HTTP status
errors, and internal verb-wrapped chains into clear, localized messages.

- ErrorKind classification (network timeout/DNS/refused/TLS/offline,
  401/403/404/409/400+422/429/5xx, unknown)
- NetworkError wraps transport errors and strips the raw URL from the
  user-facing message; classifyNetworkError categorizes via errors.As/Is
  with string fallbacks
- HTTPError.Kind() maps status codes onto ErrorKind
- FormatError: bilingual output (English default, auto-switch to Chinese
  on a zh LC_ALL/LC_MESSAGES/LANG locale), validation errors surface the
  server message; --debug / MULTICA_DEBUG appends the full raw chain
- ExitCodeFor: tiered exit codes (network=2, auth=3, 404=4, validation=5,
  other=1)
- client.go: default HTTP timeout 15s -> 30s, overridable via
  MULTICA_HTTP_TIMEOUT; wrap every transport Do() error as *NetworkError
- main.go: route errors through FormatError + ExitCodeFor, add persistent
  --debug flag

Unit tests cover every ErrorKind, classification, language detection,
exit codes, server-message extraction, and timeout parsing.

Refs MUL-3104. PR1 of 3; PR2/PR3 (status-code copy refinement and
per-command customization) follow separately.

Co-authored-by: multica-agent <github@multica.ai>

* fix(cli): address review — unify command timeouts and classify all helper errors

Must-fix 1: command-level contexts no longer truncate MULTICA_HTTP_TIMEOUT.
Added cli.APITimeout/AtLeastAPITimeout/APIContext (budget = transport timeout
+ small grace, honoring MULTICA_HTTP_TIMEOUT) and replaced the hardcoded 15s
context.WithTimeout in every API command (14 files, 92 sites) with
cli.APIContext. The issue-create/comment path now uses APITimeout() with a
60s floor for attachment uploads.

Must-fix 2: all API helpers now return *HTTPError on status >= 400. Added a
shared newHTTPError(method, path, resp) and routed GetJSON, GetJSONWithHeaders,
PostJSON, PutJSON, PatchJSON, DeleteJSON, DeleteJSONWithBody, UploadFile,
UploadFileWithURL, DownloadFile (and HealthCheck) through it, so issue
update/status/metadata (PUT), comment list (GetJSONWithHeaders), project/label/
comment delete (DELETE) and agent/workspace/autopilot update (PUT/PATCH) all
get HTTPError.Kind() classification, friendly copy, and the tiered exit code
instead of the raw string + exit 1.

Tests: new errors_integration_test.go drives the real helpers against a fake
server and asserts FormatError copy + ExitCodeFor for 401/403/404/422/500
across all 10 helpers, plus a slow-server test proving the command context
does not cancel before the transport timeout. Updated the UploadFileWithURL
assertion to check for *HTTPError.

Refs MUL-3104, PR #3892.

Co-authored-by: multica-agent <github@multica.ai>

* fix(cli): make remaining fixed-timeout API commands honor MULTICA_HTTP_TIMEOUT

Closes out the timeout work: the last API command paths still used a
hardcoded context deadline that capped MULTICA_HTTP_TIMEOUT. Converted them
to cli.AtLeastAPITimeout(<original floor>) so the env override scales them up
while preserving each original lower bound:

- cmd_autopilot.go  autopilot trigger      30s -> AtLeastAPITimeout(30s)
- cmd_attachment.go attachment download    60s -> AtLeastAPITimeout(60s)
- cmd_agent.go      avatar upload           60s -> AtLeastAPITimeout(60s)
- cmd_skill.go      skill import / search    60s -> AtLeastAPITimeout(60s)
- cmd_runtime.go    runtime update         150s -> AtLeastAPITimeout(150s)
- cmd_login.go      workspace-creation poll 10s -> AtLeastAPITimeout(10s)

The login poll keeps a short 10s floor to stay responsive within its 5-minute
loop, but it is NOT a silent exception: AtLeastAPITimeout means it still scales
with MULTICA_HTTP_TIMEOUT. Documented in code and covered by a new subtest in
TestAPITimeoutRespectsEnv.

Refs MUL-3104, PR #3892.

Co-authored-by: multica-agent <github@multica.ai>

* style(cli): gofmt cmd_attachment.go to unblock backend CI

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 15:34:59 +08:00

269 lines
6.8 KiB
Go

package main
import (
"context"
"fmt"
"net/url"
"os"
"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")
labelListCmd.Flags().Bool("full-id", false, "Show full UUIDs in table output")
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 := cli.APIContext(context.Background())
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)
}
fullID, _ := cmd.Flags().GetBool("full-id")
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{
displayID(strVal(l, "id"), fullID),
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 := cli.APIContext(context.Background())
defer cancel()
labelRef, err := resolveLabelID(ctx, client, args[0])
if err != nil {
return fmt.Errorf("resolve label: %w", err)
}
var label map[string]any
if err := client.GetJSON(ctx, "/api/labels/"+labelRef.ID, &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{{
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 := cli.APIContext(context.Background())
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{{
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 := cli.APIContext(context.Background())
defer cancel()
labelRef, err := resolveLabelID(ctx, client, args[0])
if err != nil {
return fmt.Errorf("resolve label: %w", err)
}
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/"+labelRef.ID, 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{{
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 := cli.APIContext(context.Background())
defer cancel()
labelRef, err := resolveLabelID(ctx, client, args[0])
if err != nil {
return fmt.Errorf("resolve label: %w", err)
}
if err := client.DeleteJSON(ctx, "/api/labels/"+labelRef.ID); 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": labelRef.ID, "deleted": true})
}
fmt.Fprintf(os.Stdout, "Label %s deleted.\n", labelRef.Display)
return nil
}