mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 05:16:29 +02:00
* 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>
269 lines
6.8 KiB
Go
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
|
|
}
|