fix(output): 表头取所有行字段并集并固定核心列顺序

printSliceTable 原先只从第一行取表头,导致列时有时无;改为取所有行并集。新增 protected/last_commit/commit_time 到优先表头,固定分支核心列顺序。
This commit is contained in:
刘焱 2026-06-10 11:27:52 +08:00
parent f9efae89a3
commit f248ed9d04
2 changed files with 108 additions and 13 deletions

View File

@ -150,15 +150,19 @@ func printSliceTable(w io.Writer, items []interface{}) error {
return nil
}
// Collect headers from first item
first, ok := items[0].(map[string]interface{})
if !ok {
// If the first item isn't a map, fall back to JSON (unchanged behavior).
if _, ok := items[0].(map[string]interface{}); !ok {
data, _ := json.MarshalIndent(items, "", " ")
fmt.Fprintln(w, string(data))
return nil
}
headers := collectKeys(first)
// Headers are the UNION of every row's keys, so a column appears whenever
// any row carries the field. GitLink payloads (e.g. branch lists) do not
// always include every key on every item; taking headers from items[0]
// alone made columns like `protected` appear or vanish depending on which
// item happened to rank first.
headers := collectKeysUnion(items)
omitted := 0
if len(headers) > maxTableColumns {
omitted = len(headers) - maxTableColumns
@ -240,20 +244,24 @@ func writeMapRow(tw *tabwriter.Writer, key string, v interface{}, depth int) {
// columns a user actually scans.
var preferredHeaders = []string{
"id", "name", "login", "title", "subject", "number",
"status", "state", "type", "time_ago", "created_time",
"created_at", "updated_at",
"status", "state", "protected", "last_commit", "commit_time",
"type", "time_ago", "created_time", "created_at", "updated_at",
}
func collectKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
// orderKeySet returns the keys of keySet ordered by preferredHeaders: priority
// fields come first in their defined order, the rest follow in set-iteration
// order. Keys absent from preferredHeaders still appear, just after the
// priority fields.
func orderKeySet(keySet map[string]bool) []string {
keys := make([]string, 0, len(keySet))
seen := map[string]bool{}
for _, k := range preferredHeaders {
if _, ok := m[k]; ok {
keys = append(keys, k)
seen[k] = true
for _, p := range preferredHeaders {
if keySet[p] {
keys = append(keys, p)
seen[p] = true
}
}
for k := range m {
for k := range keySet {
if !seen[k] {
keys = append(keys, k)
}
@ -261,6 +269,32 @@ func collectKeys(m map[string]interface{}) []string {
return keys
}
// collectKeysUnion merges the keys of every map in items into a single
// priority-ordered header list. Used by printSliceTable so a column shows
// whenever any row has the field — preventing columns from disappearing when
// the first row happens to omit a key (e.g. `protected` on GitLink branches).
func collectKeysUnion(items []interface{}) []string {
keySet := map[string]bool{}
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
for k := range m {
keySet[k] = true
}
}
return orderKeySet(keySet)
}
func collectKeys(m map[string]interface{}) []string {
keySet := make(map[string]bool, len(m))
for k := range m {
keySet[k] = true
}
return orderKeySet(keySet)
}
func formatValue(v interface{}) string {
if v == nil {
return ""

View File

@ -305,3 +305,64 @@ func TestFormatNestedMap_LoginAndSha(t *testing.T) {
t.Errorf("expected sha to be truncated, got %q", got)
}
}
// TestPrintSliceTable_HeaderUnionAcrossRows 验证当各行字段集合不一致时
// GitLink 分支列表的典型情况:并非每个分支都返回 protected 字段),
// 表头应取所有行的字段并集,而不是仅取第一行。否则 protected 列会因落在
// 第一位的样本恰好缺失而「时有时无」。
func TestPrintSliceTable_HeaderUnionAcrossRows(t *testing.T) {
items := []interface{}{
map[string]interface{}{"name": "develop"}, // 第一行缺 protected
map[string]interface{}{"name": "main", "protected": false}, // 第二行带 protected
}
var buf bytes.Buffer
if err := printSliceTable(&buf, items); err != nil {
t.Fatalf("printSliceTable returned error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "protected") {
t.Errorf("expected header to include \"protected\" from row union (not just first row); got:\n%s", out)
}
if !strings.Contains(out, "develop") || !strings.Contains(out, "main") {
t.Errorf("expected both rows present in output; got:\n%s", out)
}
}
// TestPrintSliceTable_ProtectedSurvivesColumnCap 验证当列数超过上限时,
// protected是否保护分支作为优先字段会被保留、不被截断丢弃——否则在
// 字段较多的分支列表里这一列仍可能消失。
func TestPrintSliceTable_ProtectedSurvivesColumnCap(t *testing.T) {
row := map[string]interface{}{}
cols := []string{"a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "id", "name", "protected"}
for _, c := range cols {
row[c] = "v"
}
var buf bytes.Buffer
if err := printSliceTable(&buf, []interface{}{row}); err != nil {
t.Fatalf("printSliceTable returned error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "protected") {
t.Errorf("expected priority field \"protected\" to survive the column cap; got:\n%s", out)
}
}
// TestOrderKeySet_BranchColumnOrderIsStable 守护分支核心列的固定顺序
// name / protected / last_commit / commit_time。当前若 last_commit /
// commit_time 不在 preferredHeadersorderKeySet 会把这两个非优先键按 map
// 遍历序追加,导致列顺序随机——重复调用必暴露不一致。
func TestOrderKeySet_BranchColumnOrderIsStable(t *testing.T) {
want := []string{"name", "protected", "last_commit", "commit_time"}
keys := map[string]bool{"name": true, "protected": true, "last_commit": true, "commit_time": true}
for i := 0; i < 100; i++ {
got := orderKeySet(keys)
if len(got) != len(want) {
t.Fatalf("iteration %d: got %d keys %v, want %v", i, len(got), got, want)
}
for j, w := range want {
if got[j] != w {
t.Fatalf("iteration %d: got[%d]=%q, want %q (full %v)", i, j, got[j], w, got)
}
}
}
}