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

384 lines
11 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"sort"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
// buildMetadataFilterQueryParam converts repeated `--metadata key=value`
// flags into the canonical JSON object passed as the `metadata` query
// parameter to /api/issues. Values are typed by the same rules as
// `metadata set --value` without --type — JSON-parsed when possible, else
// taken as a literal string. Duplicate keys are rejected; AND semantics
// require distinct keys.
func buildMetadataFilterQueryParam(pairs []string) (string, error) {
if len(pairs) == 0 {
return "", nil
}
out := make(map[string]any, len(pairs))
for _, pair := range pairs {
idx := strings.IndexByte(pair, '=')
if idx <= 0 {
return "", fmt.Errorf("--metadata %q must be in key=value form", pair)
}
key := pair[:idx]
raw := pair[idx+1:]
if _, dup := out[key]; dup {
return "", fmt.Errorf("--metadata key %q given more than once; combine into a single filter", key)
}
encoded, err := parseMetadataValue(raw, "")
if err != nil {
return "", fmt.Errorf("--metadata %s: %w", key, err)
}
var v any
if err := json.Unmarshal(encoded, &v); err != nil {
return "", fmt.Errorf("--metadata %s: encode value: %w", key, err)
}
out[key] = v
}
buf, err := json.Marshal(out)
if err != nil {
return "", fmt.Errorf("encode metadata filter: %w", err)
}
return string(buf), nil
}
// multica issue metadata {list|get|set|delete} — KV map attached to each issue
// for agent pipeline state. See server/internal/handler/issue_metadata.go for
// the constraints (key regex, 50-key cap, primitive-only values, 8KB blob).
var issueMetadataCmd = &cobra.Command{
Use: "metadata",
Short: "Manage per-issue metadata (KV)",
}
var issueMetadataListCmd = &cobra.Command{
Use: "list <issue-id>",
Short: "List all metadata keys on an issue",
Args: exactArgs(1),
RunE: runIssueMetadataList,
}
var issueMetadataGetCmd = &cobra.Command{
Use: "get <issue-id>",
Short: "Get a single metadata key value",
Args: exactArgs(1),
RunE: runIssueMetadataGet,
}
var issueMetadataSetCmd = &cobra.Command{
Use: "set <issue-id>",
Short: "Set a single metadata key value",
Long: `Set a single metadata key value. The value is JSON-parsed by default:
--value true / --value false → bool
--value 3 / --value 3.14 → number
--value waiting → string
Use --type to force a specific type. Quote like '"42"' (JSON-escaped) to force
a string when the bare value would otherwise sniff as a number or bool.`,
Args: exactArgs(1),
RunE: runIssueMetadataSet,
}
var issueMetadataDeleteCmd = &cobra.Command{
Use: "delete <issue-id>",
Short: "Delete a single metadata key",
Args: exactArgs(1),
RunE: runIssueMetadataDelete,
}
func init() {
issueMetadataCmd.AddCommand(issueMetadataListCmd)
issueMetadataCmd.AddCommand(issueMetadataGetCmd)
issueMetadataCmd.AddCommand(issueMetadataSetCmd)
issueMetadataCmd.AddCommand(issueMetadataDeleteCmd)
issueMetadataListCmd.Flags().String("output", "table", "Output format: table or json")
issueMetadataGetCmd.Flags().String("output", "json", "Output format: table or json")
issueMetadataGetCmd.Flags().String("key", "", "Metadata key (required)")
issueMetadataSetCmd.Flags().String("output", "table", "Output format: table or json")
issueMetadataSetCmd.Flags().String("key", "", "Metadata key (required)")
issueMetadataSetCmd.Flags().String("value", "", "Metadata value (required)")
issueMetadataSetCmd.Flags().String("type", "", "Force value type: string, number, or bool (default: auto-infer via JSON parsing)")
issueMetadataDeleteCmd.Flags().String("output", "table", "Output format: table or json")
issueMetadataDeleteCmd.Flags().String("key", "", "Metadata key (required)")
issueCmd.AddCommand(issueMetadataCmd)
}
// parseMetadataValue converts a CLI --value flag (and optional --type) into
// the JSON-encoded payload sent to the server. Default sniffing: if the
// raw text parses as a JSON bool / number / string, that type is used; any
// other JSON shape (null, array, object) is rejected even under default
// because the server would reject it too.
//
// forcedType non-empty overrides sniffing: "string" wraps verbatim as a
// JSON string; "number" parses strictly as a number; "bool" requires the
// literal text "true" or "false".
func parseMetadataValue(raw, forcedType string) (json.RawMessage, error) {
switch forcedType {
case "string":
buf, err := json.Marshal(raw)
if err != nil {
return nil, fmt.Errorf("encode string value: %w", err)
}
return buf, nil
case "number":
if _, err := strconv.ParseFloat(raw, 64); err != nil {
return nil, fmt.Errorf("value %q is not a valid number", raw)
}
return json.RawMessage(raw), nil
case "bool":
if raw != "true" && raw != "false" {
return nil, fmt.Errorf("value %q is not a valid bool (expected true or false)", raw)
}
return json.RawMessage(raw), nil
case "":
// auto-infer below
default:
return nil, fmt.Errorf("unknown --type %q (expected string, number, or bool)", forcedType)
}
// Auto-infer: try JSON parse, fall back to string.
var v any
if err := json.Unmarshal([]byte(raw), &v); err == nil {
switch v.(type) {
case string, bool, float64:
return json.RawMessage(raw), nil
}
}
buf, err := json.Marshal(raw)
if err != nil {
return nil, fmt.Errorf("encode string value: %w", err)
}
return buf, nil
}
func runIssueMetadataList(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
issueRef, err := resolveIssueRef(ctx, client, args[0])
if err != nil {
return fmt.Errorf("resolve issue: %w", err)
}
var result map[string]any
if err := client.GetJSON(ctx, "/api/issues/"+issueRef.ID+"/metadata", &result); err != nil {
// Best-effort degradation: when the server does not expose the
// per-issue metadata endpoint (self-hosted backends running an
// older build, missing migration, or routing issues that surface
// as 404), an agent's bootstrap "metadata list" must not fail
// the entire run. Emit an empty map and exit 0 so the agent
// still gets the empty-metadata signal it would have gotten on
// a fresh issue. Other status codes (auth, server errors) keep
// real error semantics, and metadata get/set/delete are
// unaffected — those callers still need to know when something
// went wrong.
var httpErr *cli.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
output, _ := cmd.Flags().GetString("output")
empty := map[string]any{}
if output == "json" {
return cli.PrintJSON(os.Stdout, empty)
}
printMetadataTable(empty)
return nil
}
return fmt.Errorf("list metadata: %w", err)
}
metadata, _ := result["metadata"].(map[string]any)
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, metadata)
}
printMetadataTable(metadata)
return nil
}
func runIssueMetadataGet(cmd *cobra.Command, args []string) error {
key, _ := cmd.Flags().GetString("key")
if key == "" {
return fmt.Errorf("--key is required")
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
issueRef, err := resolveIssueRef(ctx, client, args[0])
if err != nil {
return fmt.Errorf("resolve issue: %w", err)
}
var result map[string]any
if err := client.GetJSON(ctx, "/api/issues/"+issueRef.ID+"/metadata", &result); err != nil {
return fmt.Errorf("get metadata: %w", err)
}
metadata, _ := result["metadata"].(map[string]any)
value, present := metadata[key]
if !present {
return fmt.Errorf("key %q not found on issue", key)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, value)
}
headers := []string{"KEY", "VALUE", "TYPE"}
rows := [][]string{{key, formatMetadataValue(value), metadataValueType(value)}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runIssueMetadataSet(cmd *cobra.Command, args []string) error {
key, _ := cmd.Flags().GetString("key")
if key == "" {
return fmt.Errorf("--key is required")
}
if !cmd.Flags().Changed("value") {
return fmt.Errorf("--value is required")
}
rawValue, _ := cmd.Flags().GetString("value")
forcedType, _ := cmd.Flags().GetString("type")
value, err := parseMetadataValue(rawValue, forcedType)
if err != nil {
return err
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
issueRef, err := resolveIssueRef(ctx, client, args[0])
if err != nil {
return fmt.Errorf("resolve issue: %w", err)
}
body := map[string]any{"value": value}
var result map[string]any
path := "/api/issues/" + issueRef.ID + "/metadata/" + key
if err := client.PutJSON(ctx, path, body, &result); err != nil {
return fmt.Errorf("set metadata: %w", err)
}
metadata, _ := result["metadata"].(map[string]any)
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, metadata)
}
printMetadataTable(metadata)
return nil
}
func runIssueMetadataDelete(cmd *cobra.Command, args []string) error {
key, _ := cmd.Flags().GetString("key")
if key == "" {
return fmt.Errorf("--key is required")
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
issueRef, err := resolveIssueRef(ctx, client, args[0])
if err != nil {
return fmt.Errorf("resolve issue: %w", err)
}
path := "/api/issues/" + issueRef.ID + "/metadata/" + key
if err := client.DeleteJSON(ctx, path); err != nil {
return fmt.Errorf("delete metadata: %w", err)
}
// Refresh the metadata so the user sees the result.
var result map[string]any
output, _ := cmd.Flags().GetString("output")
if err := client.GetJSON(ctx, "/api/issues/"+issueRef.ID+"/metadata", &result); err != nil {
if output == "json" {
return cli.PrintJSON(os.Stdout, map[string]any{"deleted": true})
}
fmt.Fprintln(os.Stdout, "Key deleted.")
return nil
}
metadata, _ := result["metadata"].(map[string]any)
if output == "json" {
return cli.PrintJSON(os.Stdout, metadata)
}
printMetadataTable(metadata)
return nil
}
func printMetadataTable(metadata map[string]any) {
headers := []string{"KEY", "VALUE", "TYPE"}
keys := make([]string, 0, len(metadata))
for k := range metadata {
keys = append(keys, k)
}
sort.Strings(keys)
rows := make([][]string, 0, len(keys))
for _, k := range keys {
v := metadata[k]
rows = append(rows, []string{k, formatMetadataValue(v), metadataValueType(v)})
}
cli.PrintTable(os.Stdout, headers, rows)
}
func formatMetadataValue(v any) string {
switch x := v.(type) {
case string:
return x
case bool:
if x {
return "true"
}
return "false"
case float64:
// JSON numbers come back as float64; render integers without a
// trailing ".0" for ergonomics.
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'f', -1, 64)
default:
buf, _ := json.Marshal(v)
return string(buf)
}
}
func metadataValueType(v any) string {
switch v.(type) {
case string:
return "string"
case bool:
return "bool"
case float64:
return "number"
default:
return "unknown"
}
}