diff --git a/cmd/cmdutil/globals.go b/cmd/cmdutil/globals.go index 859992a2..7d095cfb 100644 --- a/cmd/cmdutil/globals.go +++ b/cmd/cmdutil/globals.go @@ -1,5 +1,7 @@ package cmdutil +import "errors" + // Global flags shared across all commands. var ( Owner string @@ -7,3 +9,14 @@ var ( Format string Debug bool ) + +// ErrSilent 是 sentinel error:表示错误已经被上层处理过(如已按 envelope 格式 +// 输出到 stdout),调用方(cmd.Execute)只需返回非零退出码,不要再把消息 +// 打印到 stderr。 +// +// 使用方式: +// +// if TryPrintError(err, format) { +// return cmdutil.ErrSilent +// } +var ErrSilent = errors.New("silent error: already reported via envelope") diff --git a/cmd/root.go b/cmd/root.go index 0ed4c669..81724ab9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" @@ -47,6 +48,11 @@ var versionCmd = &cobra.Command{ func Execute() error { if err := rootCmd.Execute(); err != nil { + // ErrSilent 表示错误已经按 envelope 格式输出到 stdout(如 API 错误), + // 这里只需保留非零退出码,不需要再 stderr 重复打印。 + if errors.Is(err, cmdutil.ErrSilent) { + return err + } fmt.Fprintln(os.Stderr, err) return err } diff --git a/internal/output/formatter.go b/internal/output/formatter.go index dd0b59c1..f523da8d 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -71,6 +71,11 @@ func printTable(w io.Writer, envelope *Envelope) error { case []interface{}: return printSliceTable(w, data) case map[string]interface{}: + // GitLink API 经常返回 {"count":N, "items":[...]} 这种"包装列表" + // 优先解包内层 slice of maps,按列表渲染 + if unwrapped := unwrapSingleListField(data); unwrapped != nil { + return printSliceTable(w, unwrapped) + } // For maps with nested structures, prefer JSON if hasComplexValues(data) { return printJSON(w, envelope) @@ -82,6 +87,59 @@ func printTable(w io.Writer, envelope *Envelope) error { } } +// unwrapSingleListField 检测 map 是否为"包装列表"结构: +// 至少包含一个 []interface{}(元素为 map,或为空数组)字段。 +// 若是,返回该 slice(用于按列表渲染表格);否则返回 nil。 +// +// 优先选择已知列表字段名(projects/webhooks/issues 等), +// 若 map 中只有一个 slice of maps 字段,也直接使用。 +func unwrapSingleListField(m map[string]interface{}) []interface{} { + knownListFields := []string{ + "projects", "webhooks", "issues", "users", "pull_requests", + "builds", "releases", "branches", "teams", "members", + "orgs", "items", "records", "results", "wikis", "search", + } + + // 1. 优先选择已知字段名(即使数组为空,也接受 — 空数组也是合法列表) + for _, name := range knownListFields { + if s, ok := m[name].([]interface{}); ok { + if isSliceOfMaps(s) { + return s + } + } + } + + // 2. 兜底:检测是否只有一个 slice of maps 字段 + var listField string + var listValue []interface{} + for k, v := range m { + s, ok := v.([]interface{}) + if !ok { + continue + } + if !isSliceOfMaps(s) { + continue + } + if listField != "" { + // 多个列表字段,无法自动解包 + return nil + } + listField = k + listValue = s + } + return listValue +} + +// isSliceOfMaps 判断 slice 是否为 map 元素的列表 +// 空 slice 也算合法列表(用于在 printSliceTable 中触发 "No results" 输出) +func isSliceOfMaps(s []interface{}) bool { + if len(s) == 0 { + return true // 空数组视为列表,printSliceTable 会输出 "No results" + } + _, ok := s[0].(map[string]interface{}) + return ok +} + func hasComplexValues(m map[string]interface{}) bool { for _, v := range m { switch v.(type) { diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go new file mode 100644 index 00000000..18890f3b --- /dev/null +++ b/internal/output/formatter_test.go @@ -0,0 +1,81 @@ +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestPrintTable_WrappedEmptyList(t *testing.T) { + env := &Envelope{OK: true, Data: map[string]interface{}{ + "count": 0, + "projects": []interface{}{}, + }} + var buf bytes.Buffer + if err := PrintTo(&buf, env, "table"); err != nil { + t.Fatalf("PrintTo failed: %v", err) + } + got := buf.String() + if !strings.Contains(got, "No results") { + t.Errorf("expected 'No results', got: %q", got) + } +} + +func TestPrintTable_WrappedList(t *testing.T) { + env := &Envelope{OK: true, Data: map[string]interface{}{ + "count": 2, + "projects": []interface{}{ + map[string]interface{}{"id": 1.0, "name": "alpha"}, + map[string]interface{}{"id": 2.0, "name": "beta"}, + }, + }} + var buf bytes.Buffer + if err := PrintTo(&buf, env, "table"); err != nil { + t.Fatalf("PrintTo failed: %v", err) + } + got := buf.String() + if !strings.Contains(got, "alpha") || !strings.Contains(got, "beta") { + t.Errorf("expected alpha/beta in output, got: %q", got) + } + if !strings.Contains(got, "id") || !strings.Contains(got, "name") { + t.Errorf("expected header id/name, got: %q", got) + } +} + +func TestUnwrapSingleListField_KnownName(t *testing.T) { + m := map[string]interface{}{ + "count": 2.0, + "projects": []interface{}{map[string]interface{}{"id": 1.0}}, + } + got := unwrapSingleListField(m) + if got == nil || len(got) != 1 { + t.Fatalf("expected slice len=1, got %v", got) + } +} + +func TestUnwrapSingleListField_MultipleUnknownListsReturnsNil(t *testing.T) { + // 两个未知名字的 list 字段 — 无法自动选择,返回 nil + m := map[string]interface{}{ + "foo_list": []interface{}{map[string]interface{}{"id": 1.0}}, + "bar_list": []interface{}{map[string]interface{}{"id": 2.0}}, + } + if got := unwrapSingleListField(m); got != nil { + t.Errorf("expected nil for multiple unknown list fields, got len=%d", len(got)) + } +} + +func TestUnwrapSingleListField_KnownNamePreferred(t *testing.T) { + // 已知 name 优先 — 即使有其他 list 字段也用 known name + m := map[string]interface{}{ + "projects": []interface{}{map[string]interface{}{"id": 1.0}}, + "users": []interface{}{map[string]interface{}{"id": 2.0}}, + } + got := unwrapSingleListField(m) + if got == nil || len(got) != 1 { + t.Fatalf("expected projects slice len=1, got %v", got) + } + first := got[0].(map[string]interface{}) + if first["id"] != 1.0 { + t.Errorf("expected projects[0].id=1, got %v", first["id"]) + } +} diff --git a/shortcuts/common/error_print.go b/shortcuts/common/error_print.go new file mode 100644 index 00000000..bbb8a5e6 --- /dev/null +++ b/shortcuts/common/error_print.go @@ -0,0 +1,68 @@ +package common + +import ( + "errors" + + "github.com/gitlink-org/gitlink-cli/internal/client" + clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" + "github.com/gitlink-org/gitlink-cli/internal/output" +) + +// TryPrintError 尝试把 error 转换为 envelope 格式并输出到 stdout。 +// 返回 true 表示已识别并处理;false 表示该 error 类型不被识别,应由调用方走原有路径。 +// +// 设计意图:让 shortcut 的错误处理路径与 api 命令对齐, +// 使 `--format json` 输出可被 jq 解析的标准 envelope: +// +// {"ok":false, "error":{"code":N, "message":"...", "suggestion":"..."}} +// +// 支持的 error 类型: +// - *client.APIError : HTTP 错误(404/403/401 等),code = HTTP 状态码 +// - *clierrors.CLIError : 业务级错误(输入/认证/网络等),code 由 kind 映射 +// +// 注:放在 common 包(而非 output 包)以避免与 client 包形成导入循环。 +func TryPrintError(err error, format string) bool { + if err == nil { + return false + } + + var apiErr *client.APIError + if errors.As(err, &apiErr) { + env := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion) + _ = output.Print(env, format) + return true + } + + var cliErr *clierrors.CLIError + if errors.As(err, &cliErr) { + env := output.ErrorEnvelope(kindToCode(cliErr.Kind), cliErr.Message, cliErr.Suggestion) + _ = output.Print(env, format) + return true + } + + return false +} + +// kindToCode 把 CLIError.Kind 映射到近似的 HTTP 状态码,用于 envelope.error.code 字段 +func kindToCode(kind clierrors.ErrorKind) int { + switch kind { + case clierrors.KindAuth: + return 401 + case clierrors.KindInput: + return 400 + case clierrors.KindNotFound: + return 404 + case clierrors.KindForbidden: + return 403 + case clierrors.KindNetwork: + return 503 + case clierrors.KindServer: + return 500 + case clierrors.KindConfig: + return 500 + case clierrors.KindGit: + return 500 + default: + return 500 + } +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 5fa9398e..9872e0b6 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -6,6 +6,8 @@ import ( "strconv" "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" ) // MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand. @@ -55,7 +57,16 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { } } - return s.Run(ctx) + err = s.Run(ctx) + if err != nil { + // 当错误为已识别的 API/CLI 错误时,按 envelope 格式输出到 stdout, + // 让 `--format json` 输出可被 jq 解析的标准结构。 + // 已识别后返回 ErrSilent:保留非零退出码,但 cmd.Execute 不会再 stderr 重复输出。 + if TryPrintError(err, ctx.Format) { + return cmdutil.ErrSilent + } + } + return err }, } diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 10fcbe59..7e10c596 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -5,6 +5,7 @@ import ( "net/url" "strings" + clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -99,7 +100,10 @@ func Shortcuts() []*common.Shortcut { events := parseEvents(ctx.Arg("events")) if len(events) == 0 { - return fmt.Errorf("no valid events specified. Supported events: %s", strings.Join(supportedEvents, ", ")) + return clierrors.InputError( + "no valid events specified", + fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")), + ) } payload := map[string]interface{}{ @@ -180,7 +184,10 @@ func Shortcuts() []*common.Shortcut { if events := ctx.Arg("events"); events != "" { validEvents := parseEvents(events) if len(validEvents) == 0 { - return fmt.Errorf("no valid events specified. Supported events: %s", strings.Join(supportedEvents, ", ")) + return clierrors.InputError( + "no valid events specified", + fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")), + ) } payload["events"] = validEvents } @@ -258,7 +265,10 @@ func Shortcuts() []*common.Shortcut { eventType := ctx.Arg("event") if !isEventSupported(eventType) { - return fmt.Errorf("unsupported event type: %s. Supported events: %s", eventType, strings.Join(supportedEvents, ", ")) + return clierrors.InputError( + fmt.Sprintf("unsupported event type: %s", eventType), + fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")), + ) } env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil) diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index 93e70314..caaeb425 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -163,6 +163,17 @@ func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (s return string(decoded), nil } +// fetchWikiPage 按 pageName 查询 wiki 页面,返回完整的 envelope。 +// 调用方负责处理错误(包括 404)和后续的 sub_url 重试逻辑。 +func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*output.Envelope, error) { + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("projectId", projectID) + q.Set("pageName", pageName) + return callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q) +} + func resolveContent(ctx *common.RuntimeContext) (string, error) { if content := ctx.Arg("content"); content != "" { return content, nil @@ -480,12 +491,15 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - q := url.Values{} - q.Set("owner", ctx.Owner) - q.Set("repo", ctx.Repo) - q.Set("projectId", projectID) - q.Set("pageName", title) - env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q) + env, err := fetchWikiPage(ctx, projectID, title) + if err != nil { + // GitLink 后端命名规则: 创建 wiki 时会自动给 sub_url 追加 ".-" 后缀, + // 而 wiki +list 返回的 title 不带后缀。若 title 不带后缀且首次查询失败, + // 自动用 title + ".-" 重试一次。 + if !strings.HasSuffix(title, ".-") { + env, err = fetchWikiPage(ctx, projectID, title+".-") + } + } if err != nil { return fmt.Errorf("查看 Wiki 页面失败: %w", err) }