From 95a8a3787616534c08f920fe6378dd208a480624 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 23:29:15 +0000 Subject: [PATCH] =?UTF-8?q?fix(output):=20--format=20table=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=B5=84=E6=BA=90=E5=8C=85=E8=A3=B9=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=93=8D=E5=BA=94=EF=BC=88=E6=AD=A4=E5=89=8D=20list=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=A1=A8=E6=A0=BC=E4=BB=8E=E4=B8=8D=E7=94=9F?= =?UTF-8?q?=E6=95=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- doc/changes/table-list-rendering.md | 25 +++++++++++++++ internal/output/formatter.go | 47 +++++++++++++++++++++++++++++ internal/output/formatter_test.go | 38 +++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 doc/changes/table-list-rendering.md diff --git a/doc/changes/table-list-rendering.md b/doc/changes/table-list-rendering.md new file mode 100644 index 0000000..8cc287f --- /dev/null +++ b/doc/changes/table-list-rendering.md @@ -0,0 +1,25 @@ +# `--format table` 支持资源包裹列表响应 + +## 动机 + +平台所有分页列表端点的响应形状都是资源包裹 map: +`{"total_count": N, "<资源名>": [...]}`。此前 `printTable` 遇到含嵌套 +结构的 map 一律回落 JSON,导致 **所有 list 命令的 `--format table` +实际上从不渲染表格**,与 flag 文案承诺不符。 + +## 行为 + +- 检测「恰好一个数组值 + 其余全是标量」的 map(列表响应形状): + 先打印标量摘要行(如 `total_count: 7`,键排序),再把包裹数组 + 渲染为表格。 +- 含嵌套对象的 map(如 commit 详情)与既有行为一致回落 JSON。 + +## 生产实测 + +`branch +list --format table`(forgeplus):输出 `total_count: 7` +摘要行 + 7 行分支表格(此前输出整段 JSON)。 + +## 测试 + +formatter_test.go 新增 2 个单测:资源包裹列表渲染表格(含摘要行、 +不回落 JSON);嵌套对象 map 仍回落 JSON。 diff --git a/internal/output/formatter.go b/internal/output/formatter.go index dd0b59c..95ffa2b 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -6,6 +6,7 @@ import ( "io" "os" "reflect" + "sort" "strings" "text/tabwriter" @@ -71,6 +72,11 @@ func printTable(w io.Writer, envelope *Envelope) error { case []interface{}: return printSliceTable(w, data) case map[string]interface{}: + // Resource-wrapped list responses ({"total_count": N, "": [...]}) + // render the wrapped array as a table with the scalar fields as a summary. + if items, ok := unwrapListMap(w, data); ok { + return printSliceTable(w, items) + } // For maps with nested structures, prefer JSON if hasComplexValues(data) { return printJSON(w, envelope) @@ -82,6 +88,47 @@ func printTable(w io.Writer, envelope *Envelope) error { } } +// unwrapListMap detects a map containing exactly one array value while every +// other value is a scalar (the shape of the platform's paginated list +// responses). It prints the scalar fields as a summary line and returns the +// wrapped array for table rendering. +func unwrapListMap(w io.Writer, m map[string]interface{}) ([]interface{}, bool) { + var items []interface{} + arrays := 0 + for _, v := range m { + switch value := v.(type) { + case []interface{}: + arrays++ + items = value + case map[string]interface{}: + return nil, false + } + } + if arrays != 1 { + return nil, false + } + scalars := make([]string, 0, len(m)) + for _, k := range sortedKeys(m) { + if _, ok := m[k].([]interface{}); ok { + continue + } + scalars = append(scalars, fmt.Sprintf("%s: %s", k, formatValue(m[k]))) + } + if len(scalars) > 0 { + fmt.Fprintln(w, strings.Join(scalars, " ")) + } + return items, true +} + +func sortedKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + 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 index 80430b9..9ccd313 100644 --- a/internal/output/formatter_test.go +++ b/internal/output/formatter_test.go @@ -244,3 +244,41 @@ func TestFormatValue(t *testing.T) { }) } } + +func TestPrintTableUnwrapsResourceWrappedList(t *testing.T) { + env := SuccessEnvelope(map[string]interface{}{ + "total_count": 2, + "tags": []interface{}{ + map[string]interface{}{"name": "v1.0.0", "id": 1}, + map[string]interface{}{"name": "v1.1.0", "id": 2}, + }, + }, nil) + var buf bytes.Buffer + if err := PrintTo(&buf, env, "table"); err != nil { + t.Fatalf("PrintTo returned error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "total_count: 2") { + t.Fatalf("missing summary line: %s", out) + } + if !strings.Contains(out, "v1.0.0") || !strings.Contains(out, "v1.1.0") { + t.Fatalf("missing table rows: %s", out) + } + if strings.Contains(out, "\"ok\"") { + t.Fatalf("should not fall back to JSON: %s", out) + } +} + +func TestPrintTableKeepsJSONForNestedObjects(t *testing.T) { + env := SuccessEnvelope(map[string]interface{}{ + "commit": map[string]interface{}{"sha": "abc"}, + "files": []interface{}{}, + }, nil) + var buf bytes.Buffer + if err := PrintTo(&buf, env, "table"); err != nil { + t.Fatalf("PrintTo returned error: %v", err) + } + if !strings.Contains(buf.String(), "\"ok\"") { + t.Fatalf("nested object map should fall back to JSON: %s", buf.String()) + } +} -- 2.34.1