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 全綠。
This commit is contained in:
2026-09-10 09:41:53 +08:00
parent 5583a00bb5
commit 927855d3ca
6 changed files with 403 additions and 72 deletions
+82 -57
View File
@@ -97,8 +97,9 @@ var commands = map[string]*command{
// Run 剖析引數並分派到對應子命令,回傳行程結束碼。
//
// 引數結構:teai [全域選項] <命令> [命令參數]。全域選項須在命令之前;
// 無引數或要求說明(-h/--help)時印出用法;未知命令或無效選項回 ExitUsage。
// 引數結構: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()}
@@ -107,7 +108,7 @@ func Run(stdout, stderr io.Writer, args []string) int {
return int(ExitOK)
}
rest, err := parseGlobals(&env.Globals, args)
rest, err := extractGlobals(&env.Globals, args)
if err != nil {
fmt.Fprintf(stderr, "teai: %v\n\n", err)
printUsage(stderr)
@@ -126,6 +127,11 @@ func Run(stdout, stderr io.Writer, args []string) int {
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)
@@ -135,7 +141,7 @@ func Run(stdout, stderr io.Writer, args []string) int {
}
}
// globalFlags 列出全域選項的長名稱與是否需要值。
// globalFlags 列出全域選項的長名稱(目前全部都需要值)。
var globalFlags = map[string]bool{
"--url": true,
"--token": true,
@@ -144,88 +150,104 @@ var globalFlags = map[string]bool{
"--timeout": true,
}
// parseGlobals 從 args 前端取走全域選項,回傳剩餘引數(命令與其參數)。
// 遇到第一個非選項引數即停;不認得的選項或缺少值都回 ErrUsage。
func parseGlobals(g *Globals, args []string) ([]string, error) {
// 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 == "--" {
return args[i+1:], nil
rest = append(rest, args[i+1:]...)
return rest, nil
}
if !strings.HasPrefix(arg, "-") || arg == "-" {
return args[i:], nil
if arg == "-" || !strings.HasPrefix(arg, "-") {
rest = append(rest, arg)
i++
continue
}
name, inline, hasInline := strings.Cut(arg, "=")
// -h/--help/-v/--version 不是全域選項;交回 Run 的分派處理。
switch name {
case "-h", "--help", "-v", "--version":
return args[i:], nil
rest = append(rest, args[i:]...)
return rest, nil
}
need, ok := globalFlags[name]
if !ok {
return nil, &ErrUsage{Msg: fmt.Sprintf("unknown global option %q", name)}
if _, ok := globalFlags[name]; !ok {
// 非全域選項:屬於子命令,原樣交回(順序保留)。
rest = append(rest, arg)
i++
continue
}
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
if err := applyGlobal(g, name, value); err != nil {
return nil, err
}
i++
}
return nil, nil
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 執行已註冊的子命令,把錯誤轉成結束碼並輸出。
// 旗標剖析交由各命令自行處理(不同命令有不同旗標,如 pulls --mine);
// 全域選項允許出現在命令之後(README 範例:teai next --output table),
// 先從 args 剝離併入 Globals 再分派。
// 全域選項已在 Run 的 extractGlobals 抽取完畢(允許出現在命令之後,
// README 範例:teai next --output table);這裡把剩餘引數直接交給
// 命令的 flagset 解析。
func dispatch(env *Env, name string, args []string) int {
cmd := commands[name]
g := env.Globals
rest, err := parseGlobals(&g, args)
if err != nil {
fmt.Fprintf(env.Err, "teai: %v\n\n", err)
printUsage(env.Err)
return int(ExitUsage)
}
env.Globals = g
if err := cmd.run(env, rest); err != nil {
if err := cmd.run(env, args); err != nil {
fmt.Fprintf(env.Err, "teai %s: %v\n", name, err)
return int(exitCodeFor(err))
}
@@ -251,6 +273,9 @@ func exitCodeFor(err error) ExitCode {
// 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
}