Files
teai/internal/cli/cli.go
T
chenyunda218 927855d3ca cli:修正帶旗標命令被全域剖析攔下的阻斷問題(#13 審核)
- parseGlobals 改為抽取式 extractGlobals:只取走已知全域選項,非全域
  選項(--has-work/--mine/--reviewer/--repo)原樣交回子命令 flagset,
  任何順序(含與全域選項交錯)皆可解析;-- 之後停止抽取。
- runVersion 補參數檢查:version --wat 仍回 exit 2。
- workflow:commentsFor 對 number<=0 視為無留言,不打 API(對齊 gitea.py
  在 number 缺漏時跳過留言檢查,避免 issues/0/comments 404 中斷掃描)。
- 新增 internal/cli/workflow_commands_test.go:以注入假 client 的接線層
  測試補上 workflow 單元測試覆蓋不到的分派路徑(members --has-work、
  pulls --mine/--reviewer/--repo、全域選項交錯、next null/table)。
- README:註明與 gitea.py 的已知輸出差異(空清單 [] vs 無輸出;
  全域選項可出現在命令前後)。

實機交叉驗證(alex):members --has-work、pulls --mine/--reviewer、
pulls --repo、next、mine 兩者輸出一致;go vet/test 全綠。
2026-09-10 09:41:53 +08:00

305 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 套件 cli 提供 teai 的命令列架構:全域選項剖析、子命令分派與輸出。
//
// 設計目標是讓之後新增子命令時只需註冊一個 command 結構,
// 不需要改動分派邏輯;也讓核心功能可以被測試(Run 接受 io.Writer)。
//
// 結束碼(見 README「輸出與結束碼」):
//
// 0 成功(含「沒有工作」→ null/[])
// 2 用法錯誤(未知命令/參數)
// 3 API 錯誤(連線失敗、401、403、5xx)
package cli
import (
"errors"
"fmt"
"io"
"sort"
"strings"
"time"
"gitea.alterminal.com/alterminal/teai/internal/gitea"
)
// Version 是目前開發中的版本號。採用語意化版本;正式發佈前以 0 開頭。
var Version = "0.1.0-dev"
// ExitCode 是 Run 回傳的行程結束碼。
type ExitCode int
// 結束碼定義,對應 README「輸出與結束碼」表格。
const (
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
usage string
run func(env *Env, args []string) error
}
// 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 是已註冊的子命令表。新增功能時在這裡註冊即可。
var commands = map[string]*command{
"version": {
name: "version",
usage: "顯示版本資訊",
run: runVersion,
},
}
// Run 剖析引數並分派到對應子命令,回傳行程結束碼。
//
// 引數結構:teai [全域選項] <命令> [命令參數]。全域選項可出現在命令
// 之前或之後(README 範例:teai next --output table);無引數或要求說明
// (-h/--help)時印出用法;未知命令或無效選項回 ExitUsage。
func Run(stdout, stderr io.Writer, args []string) int {
env := &Env{Out: stdout, Err: stderr, Globals: defaultGlobals()}
if len(args) == 0 {
printUsage(env.Out)
return int(ExitOK)
}
rest, err := extractGlobals(&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", rest[1:])
default:
name := rest[0]
if strings.HasPrefix(name, "-") {
fmt.Fprintf(stderr, "teai: unknown global option %q\n\n", name)
printUsage(stderr)
return int(ExitUsage)
}
if _, ok := commands[name]; !ok {
fmt.Fprintf(stderr, "teai: unknown command %q\n\n", name)
printUsage(stderr)
return int(ExitUsage)
}
return dispatch(env, name, rest[1:])
}
}
// globalFlags 列出全域選項的長名稱(目前全部都需要值)。
var globalFlags = map[string]bool{
"--url": true,
"--token": true,
"--config": true,
"--output": true,
"--timeout": true,
}
// extractGlobals 從 args 中抽出全域選項(可出現在命令之前或之後),
// 回傳其餘引數(命令與其參數,順序保留)。
//
// 採「抽取式」剖析:只取走已知的全域選項;任何其他引數——位置參數或
// 子命令自己的旗標(如 members 的 --has-work、pulls 的 --mine/--reviewer/
// --repo)——原樣依序交回,由子命令的 flagset 解析。因此
//
// teai members --has-work --output table
// teai pulls --mine --repo alterminal/teai
// teai next --output table
//
// 都能正確分派(全域與命令旗標可交錯出現)。
//
// 停止條件:遇到 --(剝除它,其後全部交回,不再抽取)或
// -h/--help/-v/--version(不是全域選項,交回 Run 的分派處理)。
// 全域選項缺少值或值無效時回 ErrUsage。
func extractGlobals(g *Globals, args []string) ([]string, error) {
rest := make([]string, 0, len(args))
i := 0
for i < len(args) {
arg := args[i]
if arg == "--" {
rest = append(rest, args[i+1:]...)
return rest, nil
}
if arg == "-" || !strings.HasPrefix(arg, "-") {
rest = append(rest, arg)
i++
continue
}
name, inline, hasInline := strings.Cut(arg, "=")
switch name {
case "-h", "--help", "-v", "--version":
rest = append(rest, args[i:]...)
return rest, nil
}
if _, ok := globalFlags[name]; !ok {
// 非全域選項:屬於子命令,原樣交回(順序保留)。
rest = append(rest, arg)
i++
continue
}
var value string
if hasInline {
value = inline
} else {
if i+1 >= len(args) {
return nil, &ErrUsage{Msg: fmt.Sprintf("global option %q requires a value", name)}
}
i++
value = args[i]
}
if err := applyGlobal(g, name, value); err != nil {
return nil, err
}
i++
}
return rest, nil
}
// applyGlobal 套用單一全域選項的值;值無效時回 ErrUsage。
func applyGlobal(g *Globals, name, value string) error {
switch name {
case "--url":
if strings.TrimSpace(value) == "" {
return &ErrUsage{Msg: "--url must not be empty"}
}
g.URL = value
case "--token":
g.Token = value
case "--config":
if strings.TrimSpace(value) == "" {
return &ErrUsage{Msg: "--config must not be empty"}
}
g.ConfigPath = value
case "--output":
f, err := gitea.ParseFormat(value)
if err != nil {
return &ErrUsage{Msg: err.Error()}
}
g.Output = f
case "--timeout":
d, err := time.ParseDuration(value)
if err != nil || d <= 0 {
return &ErrUsage{Msg: fmt.Sprintf("invalid --timeout %q (want e.g. 30s)", value)}
}
g.Timeout = d
}
return nil
}
// dispatch 執行已註冊的子命令,把錯誤轉成結束碼並輸出。
// 全域選項已在 Run 的 extractGlobals 抽取完畢(允許出現在命令之後,
// README 範例:teai next --output table);這裡把剩餘引數直接交給
// 命令的 flagset 解析。
func dispatch(env *Env, name string, args []string) int {
cmd := commands[name]
if err := cmd.run(env, args); err != nil {
fmt.Fprintf(env.Err, "teai %s: %v\n", name, err)
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 {
if len(args) != 0 {
return &ErrUsage{Msg: "version 不接受參數"}
}
fmt.Fprintf(env.Out, "teai version %s\n", Version)
return nil
}
// printUsage 印出用法、全域選項與已註冊的子命令清單(依名稱排序)。
func printUsage(w io.Writer) {
fmt.Fprintf(w, "teai — Gitea CLI 輔助工具(tea + AI)\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)
}
sort.Strings(names)
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更多資訊:https://gitea.alterminal.com/alterminal/teai\n")
}