internal/gitea:API 用戶端、認證與輸出基礎(#5)
- internal/gitea:僅標準庫的 HTTP 用戶端;ListAll 以 limit=50 逐頁抓滿 - 認證選序 --token → TEAI_TOKEN → tea 組態(--config/TEA_CONFIG/預設路徑); token 不入輸出、日誌與錯誤訊息 - 共用輸出 --output json|table(預設 json)、--timeout(預設 30s) - CLI 結束碼 0/2/3:用法錯誤 → 2、API 錯誤(*gitea.ErrAPI)→ 3 - 單元測試:認證選序、tea 組態解析、分頁、逾時/401/5xx 錯誤映射、輸出 實作 issue #5
This commit is contained in:
+176
-16
@@ -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 <URL> Gitea 站點(預設 https://gitea.alterminal.com)\n")
|
||||
fmt.Fprintf(w, " --token <TOKEN> API token;未給則依序嘗試 TEAI_TOKEN、tea 登入組態\n")
|
||||
fmt.Fprintf(w, " --config <path> tea 組態檔路徑,等同 TEA_CONFIG\n")
|
||||
fmt.Fprintf(w, " --output <format> 輸出格式 json|table(預設 json)\n")
|
||||
fmt.Fprintf(w, " --timeout <dur> 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")
|
||||
}
|
||||
|
||||
@@ -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} }
|
||||
Reference in New Issue
Block a user