From acf8cce68c9a34af9dfd9342c0bac788b520128e Mon Sep 17 00:00:00 2001 From: max Date: Thu, 10 Sep 2026 08:20:02 +0800 Subject: [PATCH] =?UTF-8?q?internal/gitea=EF=BC=9AAPI=20=E7=94=A8=E6=88=B6?= =?UTF-8?q?=E7=AB=AF=E3=80=81=E8=AA=8D=E8=AD=89=E8=88=87=E8=BC=B8=E5=87=BA?= =?UTF-8?q?=E5=9F=BA=E7=A4=8E=EF=BC=88#5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/gitea:僅標準庫的 HTTP 用戶端;ListAll 以 limit=50 逐頁抓滿 - 認證選序 --token → TEAI_TOKEN → tea 組態(--config/TEA_CONFIG/預設路徑); token 不入輸出、日誌與錯err誤訊息 - 共用輸出 --output json|table(預設 json)、--timeout(預設 30s) - CLI 結束碼 0/2/3:用法錯誤 → 2、API 錯誤(*gitea.ErrAPI)→ 3 - 單元測試:認證選序、tea 組態解析、分颁頁、逾時/401/5xx 錯誤映射、輸出 實作 issue #5 --- internal/cli/cli.go | 192 ++++++++++++++++++++++-- internal/cli/exitcode_test.go | 123 ++++++++++++++++ internal/gitea/auth.go | 268 ++++++++++++++++++++++++++++++++++ internal/gitea/auth_test.go | 181 +++++++++++++++++++++++ internal/gitea/client.go | 183 +++++++++++++++++++++++ internal/gitea/client_test.go | 195 +++++++++++++++++++++++++ internal/gitea/output.go | 228 +++++++++++++++++++++++++++++ internal/gitea/output_test.go | 88 +++++++++++ 8 files changed, 1442 insertions(+), 16 deletions(-) create mode 100644 internal/cli/exitcode_test.go create mode 100644 internal/gitea/auth.go create mode 100644 internal/gitea/auth_test.go create mode 100644 internal/gitea/client.go create mode 100644 internal/gitea/client_test.go create mode 100644 internal/gitea/output.go create mode 100644 internal/gitea/output_test.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 1117788..7b5040a 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,14 +1,25 @@ -// 套件 cli 提供 teai 的命令列架構:引數剖析、子命令分派與輸出。 +// 套件 cli 提供 teai 的命令列架構:全域選項剖析、子命令分派與輸出。 // // 設計目標是讓之後新增子命令時只需註冊一個 command 結構, // 不需要改動分派邏輯;也讓核心功能可以被測試(Run 接受 io.Writer)。 +// +// 結束碼(見 README「輸出與結束碼」): +// +// 0 成功(含「沒有工作」→ null/[]) +// 2 用法錯誤(未知命令/參數) +// 3 API 錯誤(連線失敗、401、403、5xx) package cli import ( + "errors" "flag" "fmt" "io" "sort" + "strings" + "time" + + "gitea.alterminal.com/alterminal/teai/internal/gitea" ) // Version 是目前開發中的版本號。採用語意化版本;正式發佈前以 0 開頭。 @@ -17,13 +28,25 @@ var Version = "0.1.0-dev" // ExitCode 是 Run 回傳的行程結束碼。 type ExitCode int -// 常見的結束碼定義。0 表示成功,其餘對應常見的命令列錯誤情境。 +// 結束碼定義,對應 README「輸出與結束碼」表格。 const ( - ExitOK ExitCode = iota // 成功 - ExitUsage // 引數或子命令錯誤 - ExitInternal // 內部錯誤(不該發生) + ExitOK ExitCode = 0 // 成功 + ExitUsage ExitCode = 2 // 用法錯誤(未知命令/參數) + ExitAPI ExitCode = 3 // API 錯誤(連線失敗、401、403、5xx) + + // ExitInternal 僅供「不該發生」的內部錯誤;不在 README 保證範圍。 + ExitInternal ExitCode = 1 ) +// ErrUsage 表示命令用法錯誤(缺參數、參數格式不對),對應 ExitUsage。 +type ErrUsage struct { + // Msg 是給使用者看的說明。 + Msg string +} + +// Error 實作 error 介面。 +func (e *ErrUsage) Error() string { return e.Msg } + // command 定義一個子命令:名稱、一行說明與實作。 type command struct { name string @@ -31,12 +54,37 @@ type command struct { run func(env *Env, args []string) error } -// Env 聚集一次執行所需的輸出目標,便於測試時替換。 +// Globals 是一次執行的全域選項(README「全域介面」)。 +type Globals struct { + // URL 是 Gitea 站點網址。 + URL string + // Token 來自 --token 旗標;空字串表示未提供(依序退回環境變數與 tea 組態)。 + Token string + // ConfigPath 是 tea 組態路徑(--config,等同 TEA_CONFIG)。 + ConfigPath string + // Output 是輸出格式。 + Output gitea.Format + // Timeout 是 HTTP 逾時。 + Timeout time.Duration +} + +// defaultGlobals 回傳預設全域選項。 +func defaultGlobals() Globals { + return Globals{ + URL: "https://gitea.alterminal.com", + Output: gitea.FormatJSON, + Timeout: gitea.DefaultTimeout, + } +} + +// Env 聚集一次執行所需的輸出目標與全域選項,便於測試時替換。 type Env struct { // Out 是一般輸出(命令結果)。 Out io.Writer // Err 是診斷輸出(錯誤、警告)。 Err io.Writer + // Globals 是剖析後的全域選項。 + Globals Globals } // commands 是已註冊的子命令表。新增功能時在這裡註冊即可。 @@ -50,32 +98,120 @@ var commands = map[string]*command{ // Run 剖析引數並分派到對應子命令,回傳行程結束碼。 // -// 無引數或要求說明(-h/--help)時印出用法;未知子命令回 ExitUsage。 +// 引數結構:teai [全域選項] <命令> [命令參數]。全域選項須在命令之前; +// 無引數或要求說明(-h/--help)時印出用法;未知命令或無效選項回 ExitUsage。 func Run(stdout, stderr io.Writer, args []string) int { - env := &Env{Out: stdout, Err: stderr} + env := &Env{Out: stdout, Err: stderr, Globals: defaultGlobals()} if len(args) == 0 { printUsage(env.Out) return int(ExitOK) } - switch args[0] { + rest, err := parseGlobals(&env.Globals, args) + if err != nil { + fmt.Fprintf(stderr, "teai: %v\n\n", err) + printUsage(stderr) + return int(ExitUsage) + } + if len(rest) == 0 { + printUsage(env.Out) + return int(ExitOK) + } + + switch rest[0] { case "-h", "--help", "help": printUsage(env.Out) return int(ExitOK) case "-v", "--version", "version": - return dispatch(env, "version", args[1:]) + return dispatch(env, "version", rest[1:]) default: - name := args[0] + name := rest[0] if _, ok := commands[name]; !ok { fmt.Fprintf(stderr, "teai: unknown command %q\n\n", name) printUsage(stderr) return int(ExitUsage) } - return dispatch(env, name, args[1:]) + return dispatch(env, name, rest[1:]) } } +// globalFlags 列出全域選項的長名稱與是否需要值。 +var globalFlags = map[string]bool{ + "--url": true, + "--token": true, + "--config": true, + "--output": true, + "--timeout": true, +} + +// parseGlobals 從 args 前端取走全域選項,回傳剩餘引數(命令與其參數)。 +// 遇到第一個非選項引數即停;不認得的選項或缺少值都回 ErrUsage。 +func parseGlobals(g *Globals, args []string) ([]string, error) { + i := 0 + for i < len(args) { + arg := args[i] + if arg == "--" { + return args[i+1:], nil + } + if !strings.HasPrefix(arg, "-") || arg == "-" { + return args[i:], nil + } + name, inline, hasInline := strings.Cut(arg, "=") + // -h/--help/-v/--version 不是全域選項;交回 Run 的分派處理。 + switch name { + case "-h", "--help", "-v", "--version": + return args[i:], nil + } + need, ok := globalFlags[name] + if !ok { + return nil, &ErrUsage{Msg: fmt.Sprintf("unknown global option %q", name)} + } + var value string + if hasInline { + value = inline + } else { + if !need { + // 目前所有全域選項都需要值;保留機制給未來的布林選項。 + value = "" + } + if i+1 >= len(args) { + return nil, &ErrUsage{Msg: fmt.Sprintf("global option %q requires a value", name)} + } + i++ + value = args[i] + } + switch name { + case "--url": + if strings.TrimSpace(value) == "" { + return nil, &ErrUsage{Msg: "--url must not be empty"} + } + g.URL = value + case "--token": + g.Token = value + case "--config": + if strings.TrimSpace(value) == "" { + return nil, &ErrUsage{Msg: "--config must not be empty"} + } + g.ConfigPath = value + case "--output": + f, err := gitea.ParseFormat(value) + if err != nil { + return nil, &ErrUsage{Msg: err.Error()} + } + g.Output = f + case "--timeout": + d, err := time.ParseDuration(value) + if err != nil || d <= 0 { + return nil, &ErrUsage{Msg: fmt.Sprintf("invalid --timeout %q (want e.g. 30s)", value)} + } + g.Timeout = d + } + i++ + } + return nil, nil +} + // dispatch 執行已註冊的子命令,把錯誤轉成結束碼並輸出。 func dispatch(env *Env, name string, args []string) int { cmd := commands[name] @@ -86,21 +222,45 @@ func dispatch(env *Env, name string, args []string) int { } if err := cmd.run(env, fs.Args()); err != nil { fmt.Fprintf(env.Err, "teai %s: %v\n", name, err) - return int(ExitInternal) + return int(exitCodeFor(err)) } return int(ExitOK) } +// exitCodeFor 把命令錯誤映射到結束碼:用法錯誤 → 2,API 錯誤 → 3, +// 其他(內部)→ 1。 +func exitCodeFor(err error) ExitCode { + if err == nil { + return ExitOK + } + var usage *ErrUsage + if errors.As(err, &usage) { + return ExitUsage + } + var api *gitea.ErrAPI + if errors.As(err, &api) { + return ExitAPI + } + return ExitInternal +} + // runVersion 輸出版本資訊。 func runVersion(env *Env, args []string) error { fmt.Fprintf(env.Out, "teai version %s\n", Version) return nil } -// printUsage 印出用法與已註冊的子命令清單(依名稱排序)。 +// printUsage 印出用法、全域選項與已註冊的子命令清單(依名稱排序)。 func printUsage(w io.Writer) { fmt.Fprintf(w, "teai — Gitea CLI 輔助工具(tea + AI)\n\n") - fmt.Fprintf(w, "用法:\n teai [命令] [參數]\n\n命令:\n") + fmt.Fprintf(w, "用法:\n teai [全域選項] <命令> [參數]\n\n") + fmt.Fprintf(w, "全域選項:\n") + fmt.Fprintf(w, " --url Gitea 站點(預設 https://gitea.alterminal.com)\n") + fmt.Fprintf(w, " --token API token;未給則依序嘗試 TEAI_TOKEN、tea 登入組態\n") + fmt.Fprintf(w, " --config tea 組態檔路徑,等同 TEA_CONFIG\n") + fmt.Fprintf(w, " --output 輸出格式 json|table(預設 json)\n") + fmt.Fprintf(w, " --timeout HTTP 逾時(預設 30s)\n\n") + fmt.Fprintf(w, "命令:\n") names := make([]string, 0, len(commands)) for name := range commands { names = append(names, name) @@ -109,6 +269,6 @@ func printUsage(w io.Writer) { for _, name := range names { fmt.Fprintf(w, " %-10s %s\n", name, commands[name].usage) } - fmt.Fprintf(w, "\n說明:\n -h, --help 顯示說明\n -v, --version 顯示版本\n") + fmt.Fprintf(w, "\n說明:\n -h, --help 顯示說明\n -v, --version 顯示版本\n") fmt.Fprintf(w, "\n更多資訊:https://gitea.alterminal.com/alterminal/teai\n") } diff --git a/internal/cli/exitcode_test.go b/internal/cli/exitcode_test.go new file mode 100644 index 0000000..1e449d0 --- /dev/null +++ b/internal/cli/exitcode_test.go @@ -0,0 +1,123 @@ +// exitcode_test.go 驗證結束碼映射:用法錯誤 → 2、API 錯誤 → 3、其他 → 1, +// 以及全域選項剖析(README「輸出與結束碼」「全域介面」)。 +package cli + +import ( + "strings" + "testing" + + "gitea.alterminal.com/alterminal/teai/internal/gitea" +) + +func TestExitCodeMapping(t *testing.T) { + cases := []struct { + name string + err error + want ExitCode + }{ + {"usage error", &ErrUsage{Msg: "bad flag"}, ExitUsage}, + {"api error 401", &gitea.ErrAPI{StatusCode: 401, Op: "GET /api/v1/user"}, ExitAPI}, + {"api error transport", &gitea.ErrAPI{Op: "GET /api/v1/user", Err: errString("dial tcp")}, ExitAPI}, + {"wrapped api error", wrapped(&gitea.ErrAPI{StatusCode: 500}), ExitAPI}, + {"other error", errString("boom"), ExitInternal}, + {"nil maps to OK", nil, ExitOK}, + } + for _, tc := range cases { + if got := exitCodeFor(tc.err); got != tc.want { + t.Errorf("%s: exitCodeFor = %d, want %d", tc.name, got, tc.want) + } + } +} + +func TestRunExitCodes(t *testing.T) { + t.Run("unknown command exits 2", func(t *testing.T) { + _, _, code := run("no-such") + if code != 2 { + t.Fatalf("code = %d, want 2", code) + } + }) + t.Run("unknown global option exits 2", func(t *testing.T) { + _, _, code := run("--wat", "version") + if code != 2 { + t.Fatalf("code = %d, want 2", code) + } + }) + t.Run("missing option value exits 2", func(t *testing.T) { + _, _, code := run("--output") + if code != 2 { + t.Fatalf("code = %d, want 2", code) + } + }) + t.Run("invalid output value exits 2", func(t *testing.T) { + _, _, code := run("--output=yaml", "version") + if code != 2 { + t.Fatalf("code = %d, want 2", code) + } + }) + t.Run("invalid timeout exits 2", func(t *testing.T) { + _, _, code := run("--timeout=soon", "version") + if code != 2 { + t.Fatalf("code = %d, want 2", code) + } + }) + t.Run("version still works with globals", func(t *testing.T) { + stdout, _, code := run("--output=table", "--timeout=10s", "version") + if code != 0 || !strings.Contains(stdout, "teai version ") { + t.Fatalf("code = %d stdout = %q", code, stdout) + } + }) +} + +func TestParseGlobals(t *testing.T) { + g := defaultGlobals() + rest, err := parseGlobals(&g, []string{ + "--url", "https://example.com", + "--token=tok", + "--config", "/tmp/c.yml", + "--output", "table", + "--timeout", "45s", + "next", + }) + if err != nil { + t.Fatal(err) + } + if len(rest) != 1 || rest[0] != "next" { + t.Fatalf("rest = %v, want [next]", rest) + } + if g.URL != "https://example.com" || g.Token != "tok" || g.ConfigPath != "/tmp/c.yml" { + t.Fatalf("globals = %+v", g) + } + if g.Output != gitea.FormatTable { + t.Fatalf("output = %v, want table", g.Output) + } + if g.Timeout.String() != "45s" { + t.Fatalf("timeout = %s", g.Timeout) + } +} + +func TestParseGlobalsStopsAtCommand(t *testing.T) { + g := defaultGlobals() + rest, err := parseGlobals(&g, []string{"version", "--output", "table"}) + if err != nil { + t.Fatal(err) + } + // 命令之後的選項屬於命令,不屬於全域。 + if len(rest) != 3 || rest[0] != "version" { + t.Fatalf("rest = %v", rest) + } +} + +// errString 把字串轉成 error(測試輔助)。 +type errStr string + +func (e errStr) Error() string { return string(e) } + +func errString(s string) error { return errStr(s) } + +// wrapped 包一層 error(測試 errors.As 穿透)。 +type wrappedErr struct{ inner error } + +func (w wrappedErr) Error() string { return "wrapped: " + w.inner.Error() } +func (w wrappedErr) Unwrap() error { return w.inner } + +func wrapped(inner error) error { return wrappedErr{inner} } diff --git a/internal/gitea/auth.go b/internal/gitea/auth.go new file mode 100644 index 0000000..4d5df4f --- /dev/null +++ b/internal/gitea/auth.go @@ -0,0 +1,268 @@ +// auth.go — 認證來源的解析與選序。 +// +// 選序(README「全域介面」):--token 旗標 → TEAI_TOKEN 環境變數 → +// tea 登入組態(TEA_CONFIG 指定的路徑,或 ~/.config/tea/config.yml)。 +// token 一律只在記憶體傳遞,不寫入輸出、日誌或錯誤訊息。 +package gitea + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strings" +) + +// TokenSource 標記 token 的來源,供測試與診斷(不含 token 本身)。 +type TokenSource int + +const ( + // SourceNone 表示沒有任何可用 token(匿名存取)。 + SourceNone TokenSource = iota + // SourceFlag 是 --token 旗標。 + SourceFlag + // SourceEnv 是 TEAI_TOKEN 環境變數。 + SourceEnv + // SourceTeaConfig 是 tea 登入組態。 + SourceTeaConfig +) + +// String 回傳來源名稱(診斷用)。 +func (s TokenSource) String() string { + switch s { + case SourceFlag: + return "flag" + case SourceEnv: + return "env" + case SourceTeaConfig: + return "tea-config" + default: + return "none" + } +} + +// EnvLookup 是環境變數查詢介面(測試可替換)。 +type EnvLookup func(key string) (string, bool) + +// ReadFileFunc 是檔案讀取介面(測試可替換)。 +type ReadFileFunc func(path string) ([]byte, error) + +// osEnv / osReadFile 是正式環境的預設實作。 +func osEnv(key string) (string, bool) { + v, ok := os.LookupEnv(key) + return v, ok +} + +func osReadFile(path string) ([]byte, error) { + // 假設路徑已被 ExpandConfigPath 展開為絕對路徑。 + return os.ReadFile(path) +} + +// AuthOptions 聚合認證解析所需的輸入。 +type AuthOptions struct { + // FlagToken 是 --token 旗標值;空字串表示未提供。 + FlagToken string + // SiteURL 是目標站點(用來比對 tea 組態中的 login url)。 + SiteURL string + // Env 是環境變數查詢;nil 時用 os.LookupEnv。 + Env EnvLookup + // ConfigPath 是 tea 組態的明確路徑(TEA_CONFIG 或 --config)。 + // 空字串表示用預設 ~/.config/tea/config.yml。 + ConfigPath string + // Home 是使用者家目錄(展開 ~ 用);空字串表示不展開。 + Home string + // ReadFile 是檔案讀取函式;nil 時用 os.ReadFile。 + ReadFile ReadFileFunc +} + +// ResolveToken 依選序解析 token:flag → env(TEAI_TOKEN) → tea 組態。 +// +// 找不到任何 token 時回傳 ("", SourceNone, nil)——查詢公開端點不需要 +// token,是否為錯由呼叫端依情境決定。組態檔存在但解析失敗(格式錯誤) +// 時回傳錯誤,因為這通常代表組態被改壞了,靜默降級為匿名會更難查。 +func ResolveToken(opts AuthOptions) (string, TokenSource, error) { + if t := strings.TrimSpace(opts.FlagToken); t != "" { + return t, SourceFlag, nil + } + env := opts.Env + if env == nil { + env = osEnv + } + if t, ok := env("TEAI_TOKEN"); ok && strings.TrimSpace(t) != "" { + return strings.TrimSpace(t), SourceEnv, nil + } + path := opts.ConfigPath + if path == "" { + // --config 未給時,先看 TEA_CONFIG 環境變數。 + if p, ok := env("TEA_CONFIG"); ok && strings.TrimSpace(p) != "" { + path = strings.TrimSpace(p) + } + } + if path == "" { + home := opts.Home + if home == "" { + h, err := os.UserHomeDir() + if err != nil { + return "", SourceNone, nil + } + home = h + } + if home == "" { + return "", SourceNone, nil + } + path = filepath.Join(home, ".config", "tea", "config.yml") + } + if path == "" { + return "", SourceNone, nil + } + readFile := opts.ReadFile + if readFile == nil { + readFile = osReadFile + } + data, err := readFile(path) + if err != nil { + // 組態不存在或讀不到:視為沒有這個來源,不是錯誤。 + return "", SourceNone, nil + } + login, err := parseTeaConfig(data, opts.SiteURL) + if err != nil { + return "", SourceNone, fmt.Errorf("gitea: parse tea config %s: %w", path, err) + } + if login.Token == "" { + return "", SourceNone, nil + } + return login.Token, SourceTeaConfig, nil +} + +// teaLogin 是 tea 組態中一筆登入資料(只取 teai 需要的欄位)。 +type teaLogin struct { + Name string + URL string + Token string + Default bool +} + +// parseTeaConfig 從 tea 的 config.yml 內容挑出最匹配 siteURL 的登入: +// url 主機相同者優先,其中 default: true 最優,否則取第一筆; +// 都不相同時退而取 default: true,再退第一筆。 +// +// 僅解析 tea 實際寫出的 YAML 子集:頂層 `logins:` 清單,項目為 +// `key: value` 的平面映射。不處理錨點、多文件、巢狀清單——tea 不會寫出 +// 這些,遇到也不該默默誤讀。 +func parseTeaConfig(data []byte, siteURL string) (teaLogin, error) { + var logins []teaLogin + inLogins := false + cur := -1 // 目前項目在 logins 的索引;-1 表示尚未開始任何項目 + for lineNo, raw := range strings.Split(string(data), "\n") { + line := strings.TrimRight(raw, "\r") + // 忽略註解行(行的第一個非空白字元是 #)。 + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + indent := len(line) - len(strings.TrimLeft(line, " ")) + if indent == 0 { + inLogins = strings.HasPrefix(trimmed, "logins:") + cur = -1 + continue + } + if !inLogins { + continue + } + if strings.HasPrefix(trimmed, "- ") || trimmed == "-" { + // 新的清單項目;`- name: x` 形式同行帶鍵值。 + logins = append(logins, teaLogin{}) + cur = len(logins) - 1 + rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")) + if rest != "" { + if err := applyLoginField(&logins[cur], rest, lineNo+1); err != nil { + return teaLogin{}, err + } + } + continue + } + if cur < 0 { + // logins: 底下卻不是清單項目——不是預期的結構。 + return teaLogin{}, fmt.Errorf("line %d: expected list item under logins:", lineNo+1) + } + if err := applyLoginField(&logins[cur], trimmed, lineNo+1); err != nil { + return teaLogin{}, err + } + } + if len(logins) == 0 { + return teaLogin{}, nil + } + wantHost := hostOf(siteURL) + // 第一輪:主機相符者,default 優先,否則第一筆。 + var fallback = -1 // 主機相符的非 default + var anyDefault = -1 // 全域 default + for i, l := range logins { + if wantHost != "" && hostOf(l.URL) == wantHost { + if l.Default { + return l, nil + } + if fallback < 0 { + fallback = i + } + } + if l.Default && anyDefault < 0 { + anyDefault = i + } + } + if fallback >= 0 { + return logins[fallback], nil + } + if anyDefault >= 0 { + return logins[anyDefault], nil + } + return logins[0], nil +} + +// applyLoginField 把 `key: value` 套用到 login;value 的引號會剝除。 +func applyLoginField(l *teaLogin, kv string, lineNo int) error { + key, value, ok := strings.Cut(kv, ":") + if !ok { + return fmt.Errorf("line %d: expected key: value, got %q", lineNo, kv) + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + value = unquoteYAML(value) + switch key { + case "name": + l.Name = value + case "url": + l.URL = value + case "token": + l.Token = value + case "default": + l.Default = value == "true" + } + return nil +} + +// unquoteYAML 剝除 YAML 的單/雙引號。 +func unquoteYAML(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} + +// hostOf 取 URL 的主機部分(連埠);解析失敗回空字串。 +func hostOf(rawURL string) string { + s := strings.TrimSpace(rawURL) + if s == "" { + return "" + } + // 補配置讓 url.Parse 能取 Host。 + if !strings.Contains(s, "://") { + s = "https://" + s + } + u, err := url.Parse(s) + if err != nil { + return "" + } + return strings.ToLower(u.Host) +} diff --git a/internal/gitea/auth_test.go b/internal/gitea/auth_test.go new file mode 100644 index 0000000..553584b --- /dev/null +++ b/internal/gitea/auth_test.go @@ -0,0 +1,181 @@ +// auth_test.go 驗證認證來源選序(--token → TEAI_TOKEN → tea 組態) +// 與 tea 組態解析。 +package gitea + +import ( + "errors" + "testing" +) + +// fakeEnv 依 map 回傳環境變數。 +func fakeEnv(m map[string]string) EnvLookup { + return func(key string) (string, bool) { + v, ok := m[key] + return v, ok + } +} + +// fakeReadFile 依 map 回傳檔案內容;不存在時回 os.ErrNotExist 類錯誤。 +func fakeReadFile(m map[string]string) ReadFileFunc { + return func(path string) ([]byte, error) { + if data, ok := m[path]; ok { + return []byte(data), nil + } + return nil, errors.New("file does not exist") + } +} + +const sampleTeaConfig = `logins: + - name: other.example.com + url: https://other.example.com + token: other-token + default: false + - name: gitea.alterminal.com + url: https://gitea.alterminal.com + token: site-token + default: true +preferences: + editor: false +` + +func TestResolveTokenOrdering(t *testing.T) { + base := AuthOptions{ + SiteURL: "https://gitea.alterminal.com", + Env: fakeEnv(nil), + ReadFile: fakeReadFile(map[string]string{ + "/cfg/config.yml": sampleTeaConfig, + }), + ConfigPath: "/cfg/config.yml", + } + + t.Run("flag wins over env and config", func(t *testing.T) { + opts := base + opts.FlagToken = "flag-token" + opts.Env = fakeEnv(map[string]string{"TEAI_TOKEN": "env-token"}) + tok, src, err := ResolveToken(opts) + if err != nil || tok != "flag-token" || src != SourceFlag { + t.Fatalf("want flag-token/flag, got %q/%v (err %v)", tok, src, err) + } + }) + + t.Run("env wins over config", func(t *testing.T) { + opts := base + opts.Env = fakeEnv(map[string]string{"TEAI_TOKEN": "env-token"}) + tok, src, err := ResolveToken(opts) + if err != nil || tok != "env-token" || src != SourceEnv { + t.Fatalf("want env-token/env, got %q/%v (err %v)", tok, src, err) + } + }) + + t.Run("tea config used when flag and env absent", func(t *testing.T) { + opts := base + tok, src, err := ResolveToken(opts) + if err != nil || tok != "site-token" || src != SourceTeaConfig { + t.Fatalf("want site-token/tea-config, got %q/%v (err %v)", tok, src, err) + } + }) + + t.Run("none when all sources absent", func(t *testing.T) { + opts := base + opts.ConfigPath = "/cfg/missing.yml" + tok, src, err := ResolveToken(opts) + if err != nil || tok != "" || src != SourceNone { + t.Fatalf("want \"\"/none, got %q/%v (err %v)", tok, src, err) + } + }) + + t.Run("blank env var is skipped", func(t *testing.T) { + opts := base + opts.Env = fakeEnv(map[string]string{"TEAI_TOKEN": " "}) + tok, src, err := ResolveToken(opts) + if err != nil || tok != "site-token" || src != SourceTeaConfig { + t.Fatalf("want site-token/tea-config, got %q/%v (err %v)", tok, src, err) + } + }) + + t.Run("blank flag is skipped", func(t *testing.T) { + opts := base + opts.FlagToken = " " + opts.Env = fakeEnv(map[string]string{"TEAI_TOKEN": "env-token"}) + tok, src, err := ResolveToken(opts) + if err != nil || tok != "env-token" || src != SourceEnv { + t.Fatalf("want env-token/env, got %q/%v (err %v)", tok, src, err) + } + }) + + t.Run("TEA_CONFIG points config path", func(t *testing.T) { + opts := base + opts.ConfigPath = "" // 不用 --config,改用 TEA_CONFIG + opts.Env = fakeEnv(map[string]string{ + "TEA_CONFIG": "/cfg/config.yml", + }) + tok, src, err := ResolveToken(opts) + if err != nil || tok != "site-token" || src != SourceTeaConfig { + t.Fatalf("want site-token/tea-config via TEA_CONFIG, got %q/%v (err %v)", tok, src, err) + } + }) + + t.Run("explicit --config beats TEA_CONFIG", func(t *testing.T) { + opts := base // ConfigPath = /cfg/config.yml(有 site-token) + opts.Env = fakeEnv(map[string]string{ + "TEA_CONFIG": "/cfg/other.yml", + }) + read := fakeReadFile(map[string]string{ + "/cfg/config.yml": sampleTeaConfig, + "/cfg/other.yml": "logins:\n - name: x\n url: https://gitea.alterminal.com\n token: other\n default: true\n", + }) + opts.ReadFile = read + tok, src, err := ResolveToken(opts) + if err != nil || tok != "site-token" || src != SourceTeaConfig { + t.Fatalf("want site-token from explicit config, got %q/%v (err %v)", tok, src, err) + } + }) +} + +func TestParseTeaConfigPicksMatchingLogin(t *testing.T) { + t.Run("host match preferred over default", func(t *testing.T) { + cfg := `logins: + - name: other + url: https://other.example.com + token: wrong-token + default: true + - name: target + url: https://gitea.alterminal.com + token: right-token + default: false +` + login, err := parseTeaConfig([]byte(cfg), "https://gitea.alterminal.com") + if err != nil { + t.Fatalf("parse error: %v", err) + } + if login.Token != "right-token" { + t.Fatalf("host-matched login should win, got token %q", login.Token) + } + }) + + t.Run("quoted token unquoted", func(t *testing.T) { + cfg := "logins:\n - name: t\n url: https://gitea.alterminal.com\n token: \"quoted-token\"\n default: true\n" + login, err := parseTeaConfig([]byte(cfg), "https://gitea.alterminal.com") + if err != nil { + t.Fatalf("parse error: %v", err) + } + if login.Token != "quoted-token" { + t.Fatalf("want quoted-token (unquoted), got %q", login.Token) + } + }) + + t.Run("malformed login entry errors", func(t *testing.T) { + cfg := "logins:\n - name: t\n url: https://gitea.alterminal.com\n oops\n" + if _, err := parseTeaConfig([]byte(cfg), ""); err == nil { + t.Fatal("malformed line should error") + } + }) + + t.Run("no logins yields empty", func(t *testing.T) { + cfg := "preferences:\n editor: false\n" + login, err := parseTeaConfig([]byte(cfg), "") + if err != nil || login.Token != "" { + t.Fatalf("want empty login, got %+v (err %v)", login, err) + } + }) +} diff --git a/internal/gitea/client.go b/internal/gitea/client.go new file mode 100644 index 0000000..0f8af58 --- /dev/null +++ b/internal/gitea/client.go @@ -0,0 +1,183 @@ +// 套件 gitea 提供存取 Gitea API 的 HTTP 用戶端基礎: +// 認證、分頁、逾時與錯誤映射。 +// +// 僅使用標準庫(net/http 等),離線可建置。token 由呼叫端注入, +// 本套件不負責認證來源的解析(見 auth.go),也不會把 token 寫進 +// 任何輸出或錯誤訊息。 +package gitea + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strconv" + "strings" + "time" +) + +// ErrAPI 表示與 Gitea API 溝通失敗(連線、認證、權限或伺服器錯誤)。 +// 行為上對應結束碼 3(API 錯誤)。 +type ErrAPI struct { + // StatusCode 是 HTTP 回應狀態碼;0 表示請求未完成(連線失敗等)。 + StatusCode int + // Op 是失敗的步驟描述(如 "GET /api/v1/user"),僅供診斷。 + Op string + // Err 是底層錯誤。 + Err error +} + +// Error 實作 error 介面;不包含 token 或完整回應內容。 +func (e *ErrAPI) Error() string { + if e.StatusCode == 0 { + return fmt.Sprintf("gitea: %s: %v", e.Op, e.Err) + } + return fmt.Sprintf("gitea: %s: HTTP %d", e.Op, e.StatusCode) +} + +// Unwrap 支援 errors.Is / errors.As。 +func (e *ErrAPI) Unwrap() error { return e.Err } + +// Client 是 Gitea API 的最小用戶端。零值不可用,請用 New。 +type Client struct { + // baseURL 是 API 根(如 https://gitea.alterminal.com/api/v1), + // 不帶尾斜線。 + baseURL *url.URL + // token 是 API token;空字串表示匿名存取(公開端點)。 + token string + // hc 是底層 HTTP 用戶端(帶逾時)。 + hc *http.Client +} + +// DefaultTimeout 是未指定逾時時的預設值。 +const DefaultTimeout = 30 * time.Second + +// New 建立用戶端。baseURL 為站點網址(如 https://gitea.alterminal.com), +// 函式內部自行補上 /api/v1 路徑。timeout <= 0 時採 DefaultTimeout。 +func New(baseURL, token string, timeout time.Duration) (*Client, error) { + u, err := url.Parse(strings.TrimSuffix(baseURL, "/")) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("gitea: invalid base URL %q", baseURL) + } + u = u.JoinPath("api", "v1") + if timeout <= 0 { + timeout = DefaultTimeout + } + return &Client{ + baseURL: u, + token: token, + hc: &http.Client{Timeout: timeout}, + }, nil +} + +// MaxLimit 是 Gitea 清單端點單次回傳上限(服務端硬上限 50)。 +const MaxLimit = 50 + +// getJSON 對 path(相對 baseURL,以 / 開頭)發 GET,把 JSON 回應解到 out。 +// 失敗時回傳 *ErrAPI。rawQuery 為組好的查詢字串(見 ListOptions.query)。 +func (c *Client) getJSON(ctx context.Context, path, rawQuery string, out any) error { + u := *c.baseURL + u.Path += path + u.RawQuery = rawQuery + op := "GET " + u.Path + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return &ErrAPI{Op: op, Err: err} + } + req.Header.Set("Accept", "application/json") + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + resp, err := c.hc.Do(req) + if err != nil { + return &ErrAPI{Op: op, Err: err} + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + // 把內容讀掉再丟棄,讓連線可重用;錯誤訊息不含回應內容。 + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return &ErrAPI{Op: op, StatusCode: resp.StatusCode} + } + if out == nil { + return nil + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return &ErrAPI{Op: op, Err: fmt.Errorf("decode response: %w", err)} + } + return nil +} + +// GetJSON 是 getJSON 的公開介面,供非清單端點(如 /user)使用。 +func (c *Client) GetJSON(ctx context.Context, path string, out any) error { + return c.getJSON(ctx, path, "", out) +} + +// ListOptions 描述清單端點的分頁參數。 +type ListOptions struct { + // Limit 是單頁筆數;<= 0 或 > MaxLimit 時設為 MaxLimit。 + Limit int + // Page 是頁碼(1 起);僅供單頁抓取,ListAll 一律從第 1 頁開始。 + Page int + // Extra 是額外查詢參數(如 state、type)。 + Extra url.Values +} + +// query 把分頁參數組成查詢字串。 +func (o ListOptions) query() string { + q := url.Values{} + limit := o.Limit + if limit <= 0 || limit > MaxLimit { + limit = MaxLimit + } + q.Set("limit", strconv.Itoa(limit)) + if o.Page > 1 { + q.Set("page", strconv.Itoa(o.Page)) + } + for k, vs := range o.Extra { + for _, v := range vs { + q.Set(k, v) + } + } + return q.Encode() +} + +// ListAll 逐頁抓取清單端點直到抓完,把結果存進 out(指向 slice 的指標, +// 初始須為空 slice)。每頁固定使用 limit=MaxLimit(Gitea 上限 50); +// opts.Extra 照常帶入。頁數在服務端回傳未滿頁或空頁時結束。 +func (c *Client) ListAll(ctx context.Context, path string, opts ListOptions, out any) error { + rv := reflect.ValueOf(out) + if rv.Kind() != reflect.Pointer || rv.IsNil() { + return fmt.Errorf("gitea: ListAll: out must be non-nil pointer to slice") + } + sv := rv.Elem() + if sv.Kind() != reflect.Slice { + return fmt.Errorf("gitea: ListAll: out must be non-nil pointer to slice") + } + if sv.Len() != 0 { + return fmt.Errorf("gitea: ListAll: out slice must be empty") + } + for page := 1; ; page++ { + opts := opts + opts.Limit = MaxLimit + opts.Page = page + pageOut := reflect.MakeSlice(sv.Type(), 0, MaxLimit) + pp := reflect.New(sv.Type()) + pp.Elem().Set(pageOut) + if err := c.getJSON(ctx, path, opts.query(), pp.Interface()); err != nil { + return err + } + got := pp.Elem() + n := got.Len() + if n == 0 { + return nil + } + sv.Set(reflect.AppendSlice(sv, got)) + if n < MaxLimit { + return nil + } + } +} diff --git a/internal/gitea/client_test.go b/internal/gitea/client_test.go new file mode 100644 index 0000000..7fb4989 --- /dev/null +++ b/internal/gitea/client_test.go @@ -0,0 +1,195 @@ +// client_test.go 用 httptest 伺服器驗證用戶端行為:分頁(limit=50)、 +// 認證表頭、逾時與錯誤映射(連線失敗、401、5xx → *ErrAPI)。 +package gitea + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +// newTestClient 對 srv 建立用戶端。 +func newTestClient(srvURL, token string, timeout time.Duration) (*Client, error) { + return New(srvURL, token, timeout) +} + +func TestNewRejectsInvalidBaseURL(t *testing.T) { + for _, bad := range []string{"", "not a url", "http://"} { + if _, err := New(bad, "", 0); err == nil { + t.Errorf("New(%q) should fail", bad) + } + } +} + +func TestClientSendsTokenHeader(t *testing.T) { + var gotAuth atomic.Value + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth.Store(r.Header.Get("Authorization")) + fmt.Fprint(w, `{"login":"max"}`) + })) + defer srv.Close() + + c, err := newTestClient(srv.URL, "secret-token", time.Second) + if err != nil { + t.Fatal(err) + } + var out map[string]any + if err := c.GetJSON(context.Background(), "/user", &out); err != nil { + t.Fatal(err) + } + if got := gotAuth.Load(); got != "token secret-token" { + t.Fatalf("Authorization header = %v, want token secret-token", got) + } +} + +func TestErrAPIMessageHasNoToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + c, err := newTestClient(srv.URL, "secret-token", time.Second) + if err != nil { + t.Fatal(err) + } + err = c.GetJSON(context.Background(), "/user", nil) + var apiErr *ErrAPI + if !errors.As(err, &apiErr) { + t.Fatalf("want *ErrAPI, got %v", err) + } + if apiErr.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", apiErr.StatusCode) + } + if got := apiErr.Error(); got == "" || contains(got, "secret-token") { + t.Fatalf("error message must not leak token, got %q", got) + } +} + +func TestListAllPaginatesWithLimitFifty(t *testing.T) { + var calls int32 + var limits []int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + limit := int32(0) + fmt.Sscanf(r.URL.Query().Get("limit"), "%d", &limit) + limits = append(limits, limit) + page := 1 + fmt.Sscanf(r.URL.Query().Get("page"), "%d", &page) + // 第 1、2 頁各滿 50 筆,第 3 頁 10 筆後結束。 + total := 110 + start := (page - 1) * 50 + end := start + 50 + if end > total { + end = total + } + w.Header().Set("Content-Type", "application/json") + if start >= total { + fmt.Fprint(w, "[]") + return + } + items := make([]int, 0, end-start) + for i := start; i < end; i++ { + items = append(items, i) + } + _ = json.NewEncoder(w).Encode(items) + })) + defer srv.Close() + + c, err := newTestClient(srv.URL, "", time.Second) + if err != nil { + t.Fatal(err) + } + var got []int + if err := c.ListAll(context.Background(), "/things", ListOptions{}, &got); err != nil { + t.Fatal(err) + } + if len(got) != 110 { + t.Fatalf("want 110 items, got %d", len(got)) + } + for i, v := range got { + if v != i { + t.Fatalf("item %d = %d, want %d", i, v, i) + } + } + if n := atomic.LoadInt32(&calls); n != 3 { + t.Fatalf("want 3 page requests, got %d", n) + } + for i, l := range limits { + if l != 50 { + t.Fatalf("page %d limit = %d, want 50", i+1, l) + } + } +} + +func TestListAllRejectsBadOut(t *testing.T) { + c, err := newTestClient("https://gitea.alterminal.com", "", time.Second) + if err != nil { + t.Fatal(err) + } + var notSlice int + if err := c.ListAll(context.Background(), "/x", ListOptions{}, ¬Slice); err == nil { + t.Fatal("non-slice out should error") + } + preloaded := []int{1} + if err := c.ListAll(context.Background(), "/x", ListOptions{}, &preloaded); err == nil { + t.Fatal("non-empty out should error") + } +} + +func TestClientTimeoutSurfacesAsErrAPI(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + fmt.Fprint(w, `{}`) + })) + defer srv.Close() + + c, err := newTestClient(srv.URL, "", 50*time.Millisecond) + if err != nil { + t.Fatal(err) + } + err = c.GetJSON(context.Background(), "/user", nil) + var apiErr *ErrAPI + if !errors.As(err, &apiErr) { + t.Fatalf("timeout should surface as *ErrAPI, got %v", err) + } + if apiErr.StatusCode != 0 { + t.Fatalf(" StatusCode should be 0 for transport error, got %d", apiErr.StatusCode) + } +} + +func TestClientServerFiveHundred(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + c, err := newTestClient(srv.URL, "", time.Second) + if err != nil { + t.Fatal(err) + } + err = c.GetJSON(context.Background(), "/user", nil) + var apiErr *ErrAPI + if !errors.As(err, &apiErr) || apiErr.StatusCode != 500 { + t.Fatalf("want *ErrAPI with 500, got %v", err) + } +} + +// contains 是簡單子字串檢查(避免只為此引入 strings 到測試以外的地方)。 +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/internal/gitea/output.go b/internal/gitea/output.go new file mode 100644 index 0000000..47e2453 --- /dev/null +++ b/internal/gitea/output.go @@ -0,0 +1,228 @@ +// output.go — 共用輸出:JSON(預設)與 table(人工抽查)。 +// +// 命令把結果包成 Values(欄位順序即輸出順序),再由 output 依格式輸出。 +// 這個設計讓同一份結果可以同時餵 JSON 與 table,不需要各自重組。 +package gitea + +import ( + "encoding/json" + "fmt" + "io" + "strings" +) + +// Values 是一組已排序的欄位值(依 Field 順序)。 +type Values []any + +// Format 是輸出格式。 +type Format int + +const ( + // FormatJSON 輸出 JSON(預設)。 + FormatJSON Format = iota + // FormatTable 輸出對齊的文字表格。 + FormatTable +) + +// ParseFormat 解析 --output 的值;不認得的值回傳錯誤。 +func ParseFormat(s string) (Format, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "json": + return FormatJSON, nil + case "table": + return FormatTable, nil + default: + return FormatJSON, fmt.Errorf("invalid output format %q (want json or table)", s) + } +} + +// Field 描述 table 輸出的一欄。 +type Field struct { + // Name 是欄位名(JSON 鍵與 table 表頭)。 + Name string + // ValueFmt 是值的格式化函式;nil 時用預設(fmt.Sprint + JSON 引號字串)。 + ValueFmt func(v any) string +} + +// Printer 依格式輸出結果。 +type Printer struct { + // Format 是輸出格式。 + Format Format + // Out 是輸出目標。 + Out io.Writer +} + +// PrintList 輸出清單。fields 定義欄位;rows 是每一列的欄位值。 +// JSON 模式輸出物件陣列;table 模式輸出對齊表格。 +func (p Printer) PrintList(fields []Field, rows []Values) error { + if p.Format == FormatTable { + return p.printTable(fields, rows) + } + w := p.Out + if len(rows) == 0 { + _, err := fmt.Fprintln(w, "[]") + return err + } + if _, err := fmt.Fprint(w, "["); err != nil { + return err + } + for i, row := range rows { + if i > 0 { + if _, err := fmt.Fprint(w, ","); err != nil { + return err + } + } + b, err := json.Marshal(mapOf(fields, row)) + if err != nil { + return err + } + if _, err := w.Write(b); err != nil { + return err + } + } + _, err := fmt.Fprintln(w, "]") + return err +} + +// PrintOne 輸出單一結果(物件);v 已是可 JSON 化的值。 +// table 模式輸出 key: value 行。 +func (p Printer) PrintOne(fields []Field, row Values) error { + if p.Format == FormatTable { + return p.printTable(fields, []Values{row}) + } + b, err := json.Marshal(mapOf(fields, row)) + if err != nil { + return err + } + _, err = fmt.Fprintln(p.Out, string(b)) + return err +} + +// PrintNull 輸出「沒有結果」的 JSON null(table 模式輸出提示行)。 +func (p Printer) PrintNull() error { + if p.Format == FormatTable { + _, err := fmt.Fprintln(p.Out, "(none)") + return err + } + _, err := fmt.Fprintln(p.Out, "null") + return err +} + +// printTable 印出對齊表格:表頭、分隔線、資料列。 +func (p Printer) printTable(fields []Field, rows []Values) error { + headers := make([]string, len(fields)) + cells := make([][]string, len(rows)) + for i, f := range fields { + headers[i] = f.Name + } + for r, row := range rows { + cells[r] = make([]string, len(fields)) + for i := range fields { + cells[r][i] = formatValue(fields[i], row[i]) + } + } + widths := make([]int, len(fields)) + for i, h := range headers { + widths[i] = runeLen(h) + } + for _, row := range cells { + for i, c := range row { + if l := runeLen(c); l > widths[i] { + widths[i] = l + } + } + } + var b strings.Builder + for i, h := range headers { + fmt.Fprintf(&b, "%-*s", widths[i], h) + if i < len(headers)-1 { + b.WriteString(" ") + } + } + b.WriteString("\n") + for i := range fields { + fmt.Fprintf(&b, "%s", strings.Repeat("─", widths[i])) + if i < len(fields)-1 { + b.WriteString(" ") + } + } + b.WriteString("\n") + for _, row := range cells { + for i, c := range row { + fmt.Fprintf(&b, "%-*s", widths[i], c) + if i < len(fields)-1 { + b.WriteString(" ") + _ = i + } + } + b.WriteString("\n") + } + _, err := fmt.Fprint(p.Out, b.String()) + return err +} + +// mapOf 把欄位與值組成 JSON 物件用的 map(保留欄位順序由 encoding/json +// 處理:map 會排序鍵,因此改用自組 slice 結構以保順序——見 marshalObject)。 +func mapOf(fields []Field, row Values) any { + if len(row) != len(fields) { + return nil + } + return orderedObject{fields, row} +} + +// orderedObject 以欄位順序輸出 JSON 物件(避免 map 排序破壞欄位序)。 +type orderedObject struct { + fields []Field + row Values +} + +// MarshalJSON 按欄位順序輸出。 +func (o orderedObject) MarshalJSON() ([]byte, error) { + var b strings.Builder + b.WriteByte('{') + for i, f := range o.fields { + if i > 0 { + b.WriteByte(',') + } + k, err := json.Marshal(f.Name) + if err != nil { + return nil, err + } + b.Write(k) + b.WriteByte(':') + v, err := json.Marshal(o.row[i]) + if err != nil { + return nil, err + } + b.Write(v) + } + b.WriteByte('}') + return []byte(b.String()), nil +} + +// formatValue 把欄位值格式化為 table 用字串;nil → 空字串。 +func formatValue(f Field, v any) string { + if v == nil { + return "" + } + if f.ValueFmt != nil { + return f.ValueFmt(v) + } + switch x := v.(type) { + case string: + return x + case fmt.Stringer: + return x.String() + default: + return fmt.Sprint(x) + } +} + +// runeLen 回傳字串的 rune 數(表格寬度用,CJK 後續可再調)。 +func runeLen(s string) int { + n := 0 + for range s { + n++ + } + return n +} diff --git a/internal/gitea/output_test.go b/internal/gitea/output_test.go new file mode 100644 index 0000000..23aeefa --- /dev/null +++ b/internal/gitea/output_test.go @@ -0,0 +1,88 @@ +// output_test.go 驗證共用輸出:JSON 物件順序、空清單、table 對齊與 +// 格式解析。 +package gitea + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +var demoFields = []Field{ + {Name: "type"}, + {Name: "number"}, +} + +func TestPrintListJSON(t *testing.T) { + var b bytes.Buffer + p := Printer{Format: FormatJSON, Out: &b} + err := p.PrintList(demoFields, []Values{{"issue", 5}, {"pull", 7}}) + if err != nil { + t.Fatal(err) + } + var got []map[string]any + if err := json.Unmarshal(b.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON %q: %v", b.String(), err) + } + if len(got) != 2 || got[0]["type"] != "issue" || got[1]["number"] != float64(7) { + t.Fatalf("unexpected payload: %v", got) + } +} + +func TestPrintListEmptyJSONArray(t *testing.T) { + var b bytes.Buffer + p := Printer{Format: FormatJSON, Out: &b} + if err := p.PrintList(demoFields, nil); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(b.String()) != "[]" { + t.Fatalf("want [], got %q", b.String()) + } +} + +func TestPrintNull(t *testing.T) { + var b bytes.Buffer + p := Printer{Format: FormatJSON, Out: &b} + if err := p.PrintNull(); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(b.String()) != "null" { + t.Fatalf("want null, got %q", b.String()) + } +} + +func TestPrintTable(t *testing.T) { + var b bytes.Buffer + p := Printer{Format: FormatTable, Out: &b} + if err := p.PrintList(demoFields, []Values{{"issue", 5}}); err != nil { + t.Fatal(err) + } + out := b.String() + for _, want := range []string{"type", "number", "issue", "5"} { + if !strings.Contains(out, want) { + t.Fatalf("table missing %q:\n%s", want, out) + } + } + if !strings.Contains(out, "─") { + t.Fatalf("table should have separator:\n%s", out) + } +} + +func TestParseFormat(t *testing.T) { + for in, want := range map[string]Format{ + "": FormatJSON, + "json": FormatJSON, + "JSON": FormatJSON, + "table": FormatTable, + " Table ": FormatTable, + } { + got, err := ParseFormat(in) + if err != nil || got != want { + t.Errorf("ParseFormat(%q) = %v, %v; want %v", in, got, err, want) + } + } + if _, err := ParseFormat("yaml"); err == nil { + t.Error("yaml should be rejected") + } +}