From 3ed20b6aba1e9b7eb9256189e5a25c924dcf7c25 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 10 Sep 2026 15:35:16 +0800 Subject: [PATCH] =?UTF-8?q?internal/cli=EF=BC=9A=E5=85=81=E8=A8=B1?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E6=97=97=E6=A8=99=E8=88=87=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E5=8F=83=E6=95=B8=E4=BA=A4=E9=8C=AF=EF=BC=88#25=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseFlags 改為先分離旗標與位置參數、再剖析旗標串、最後以第二次 Parse 把位置參數設回 fs.Args;Go 標準 flag 遇位置參數即停止的問題 不再影響「位置參數在前、旗標在後」的用法(teai api --method、 issues/pulls view --comments 等照 README 範例可用了)。 - login.go/workflow_commands.go 直接呼叫 fs.Parse 的六處改走 parseFlags,行為一致(login default/remove 的名稱也可與旗標交錯)。 - errHelp(命令 -h/--help)在 exitCodeFor 視為成功,與其既有註解 宣稱一致;命令層 -h 結束碼由 1/2 修正為 0。 - 新增 interleaved_flags_test.go 回歸測試:api/issues view/ pulls view/stalled/login default/login remove 的交錯組合。 --- internal/cli/cli.go | 63 +++++++++- internal/cli/interleaved_flags_test.go | 160 +++++++++++++++++++++++++ internal/cli/login.go | 16 +-- internal/cli/workflow_commands.go | 8 +- 4 files changed, 231 insertions(+), 16 deletions(-) create mode 100644 internal/cli/interleaved_flags_test.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0eade16..da2eb54 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -348,24 +348,79 @@ func dispatch(env *Env, name string, args []string) int { // 用法,靜默成功結束即可。 var errHelp = errors.New("help requested") -// parseFlags 剖析命令旗標:-h/--help 回 errHelp(由 exitCodeFor 視為成功), -// 其他剖析錯誤回 ErrUsage。 +// parseFlags 剖析命令旗標,允許旗標與位置參數交錯(#25):Go 標準 +// flag 在遇到第一個非旗標參數後即停止剖析,其後的旗標會被當成位置 +// 參數。做法:先掃描 args 把「旗標(含其值)」與「位置參數」分離成 +// 兩串,再以旗標串呼叫 fs.Parse,最後以第二次 Parse 把位置參數設回 +// fs.Args(Parse 遇到 "--" 即停止並收下其餘引數,第一次剖析已設好 +// 的旗標值不受影響)。 +// -h/--help 回 errHelp(由 exitCodeFor 視為成功),其他剖析錯誤回 ErrUsage。 func parseFlags(fs *flag.FlagSet, args []string) error { - if err := fs.Parse(args); err != nil { + var flags, positional []string + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + // "--" 之後全部是位置參數(不再剖析旗標;同標準 flag)。 + positional = append(positional, args[i+1:]...) + break + } + if len(arg) < 2 || arg[0] != '-' { + positional = append(positional, arg) + continue + } + flags = append(flags, arg) + // 判斷旗標是否需要「下一個引數」當值(布林旗標與 inline + // 形式 --name=value 不需要)。未知或格式錯誤的旗標不消費 + // 下一個引數,交給 fs.Parse 產生標準錯誤訊息。 + numMinuses := 1 + if arg[1] == '-' { + numMinuses = 2 + } + name := arg[numMinuses:] + if name == "" || name[0] == '-' || name[0] == '=' { + continue // ---x/--=…:bad flag syntax,由 fs.Parse 報錯 + } + flagName, _, hasInline := strings.Cut(name, "=") + if !hasInline { + if fl := fs.Lookup(flagName); fl != nil && !isBoolFlag(fl) && i+1 < len(args) { + i++ + flags = append(flags, args[i]) + } + } + } + if err := fs.Parse(flags); err != nil { if errors.Is(err, flag.ErrHelp) { return errHelp } return &ErrUsage{Msg: err.Error()} } + if len(positional) > 0 { + if err := fs.Parse(append([]string{"--"}, positional...)); err != nil { + if errors.Is(err, flag.ErrHelp) { + return errHelp + } + return &ErrUsage{Msg: err.Error()} + } + } return nil } +// isBoolFlag 回傳旗標是否為布林型(不需要值;同 flag 包內部判斷)。 +func isBoolFlag(fl *flag.Flag) bool { + bf, ok := fl.Value.(interface{ IsBoolFlag() bool }) + return ok && bf.IsBoolFlag() +} + // exitCodeFor 把命令錯誤映射到結束碼:用法錯誤 → 2,API 錯誤 → 3, -// 其他(內部)→ 1。 +// 其他(內部)→ 1。errHelp(命令的 -h/--help)視為成功(#25: +// 與 parseFlags 註解宣稱一致,FlagSet 已印出用法說明)。 func exitCodeFor(err error) ExitCode { if err == nil { return ExitOK } + if err == errHelp { + return ExitOK + } var usage *ErrUsage if errors.As(err, &usage) { return ExitUsage diff --git a/internal/cli/interleaved_flags_test.go b/internal/cli/interleaved_flags_test.go new file mode 100644 index 0000000..d65171c --- /dev/null +++ b/internal/cli/interleaved_flags_test.go @@ -0,0 +1,160 @@ +// interleaved_flags_test.go — #25 的回歸測試:命令旗標放在位置參數 +// (path/編號/名稱)之後也必須被剖析,不得被當成位置參數。 +// 涵蓋 parseFlags 走的命令(issues/pulls view、api、stalled)與 +// 原本直接 fs.Parse 的命令(login default/remove、members)。 +package cli + +import ( + "fmt" + "net/http" + "os" + "strings" + "testing" +) + +// TestAPIFlagsAfterPath 驗證 #25 重現步驟一:teai api --method POST +// --data '{}' --yes 的旗標在 path 之後仍被剖析(POST 送達伺服器而非 exit 2)。 +func TestAPIFlagsAfterPath(t *testing.T) { + var gotMethod string + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + fmt.Fprint(w, `{}`) + }) + _, _, code := run("api", "/version", "--method", "POST", "--data", `{}`, "--yes") + if code != 0 { + t.Fatalf("api 旗標在 path 之後應回 0(#25),得到 %d", code) + } + if gotMethod != "POST" { + t.Fatalf("應送出 POST,得到 %s", gotMethod) + } +} + +// TestIssuesViewCommentsFlagAfterNumber 驗證 #25 重現步驟二: +// teai issues view --repo owner/repo <編號> --comments 的 --comments +// 在編號之後仍生效(有打 /comments 端點)。 +func TestIssuesViewCommentsFlagAfterNumber(t *testing.T) { + var commentsHit bool + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/comments"): + commentsHit = true + fmt.Fprint(w, `[]`) + case strings.HasSuffix(r.URL.Path, "/issues/8"): + fmt.Fprint(w, `{"number":8,"title":"t","state":"open","user":{"login":"ceo"},"body":"","html_url":"u","labels":[],"updated_at":"t"}`) + default: + fmt.Fprint(w, `[]`) + } + }) + stdout, _, code := run("issues", "view", "--repo", "alterminal/teai", "8", "--comments") + if code != 0 { + t.Fatalf("issues view 編號在前旗標在後應回 0(#25),得到 %d", code) + } + if !commentsHit { + t.Fatalf("--comments 在編號之後應觸發 /comments 請求;輸出:%s", stdout) + } +} + +// TestPullsViewCommentsFlagAfterNumber 同上,驗證 pulls view。 +func TestPullsViewCommentsFlagAfterNumber(t *testing.T) { + var commentsHit bool + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/comments"): + commentsHit = true + fmt.Fprint(w, `[]`) + case strings.HasSuffix(r.URL.Path, "/pulls/13"): + fmt.Fprint(w, `{"number":13,"title":"t","state":"open","user":{"login":"ceo"},"body":"","html_url":"u","labels":[],"updated_at":"t"}`) + default: + fmt.Fprint(w, `[]`) + } + }) + _, _, code := run("pulls", "view", "--repo", "alterminal/teai", "13", "--comments") + if code != 0 { + t.Fatalf("pulls view 編號在前旗標在後應回 0(#25),得到 %d", code) + } + if !commentsHit { + t.Fatal("--comments 在編號之後應觸發 /comments 請求") + } +} + +// TestStalledHoursFlagAfterPositional 驗證 stalled 即使混入位置參數 +// 也不誤剖析:--hours 在位置參數後仍被剖析,而 stalled 本身不接受 +// 位置參數 → 應回 2(用法錯誤)而非誤把 --hours 當位置參數。 +func TestStalledHoursFlagAfterPositional(t *testing.T) { + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[]`) + }) + _, stderr, code := run("stalled", "9", "--hours", "3") + if code != 2 { + t.Fatalf("stalled 帶位置參數應回 2,得到 %d", code) + } + if !strings.Contains(stderr, "位置參數") { + t.Fatalf("錯誤訊息應提及位置參數,得到:%s", stderr) + } + // 對照組:--hours 值非法(剖析層錯誤)應回 2,而非把旗標當位置參數。 + _, stderr, code = run("stalled", "--hours", "abc") + if code != 2 || !strings.Contains(stderr, "invalid value") { + t.Fatalf("stalled --hours abc 應回 2 且提及 invalid value,得到 %d:%s", code, stderr) + } + // 交錯但無多餘位置參數:--hours 值不會被誤收。 + _, stderr, code = run("stalled", "--hours") + if code != 2 || !strings.Contains(stderr, "flag needs an argument") { + t.Fatalf("stalled --hours 缺值應回 2 且提及 flag needs an argument,得到 %d:%s", code, stderr) + } +} + +// TestLoginDefaultNameOnly 驗證 login default <名稱>:位置參數在旗標 +// 之後(--config 在前)仍正確收取一個名稱。 +func TestLoginDefaultNameOnly(t *testing.T) { + cfg := `logins: + - name: a + url: https://a.example.com + token: ta + default: true + - name: b + url: https://b.example.com + token: tb + default: false +` + prefix, _ := loginTestEnv(t, cfg) + stdout, _, code := run(append(prefix, "login", "default", "b")...) + if code != 0 || !strings.Contains(stdout, `"name":"b"`) { + t.Fatalf("login default b 應回 0 並輸出 b:code=%d out=%s", code, stdout) + } + // 多給一個位置參數仍應報用法錯誤(不受交錯支援影響)。 + _, stderr, code := run(append(prefix, "login", "default", "b", "c")...) + if code != 2 || !strings.Contains(stderr, "exactly one") { + t.Fatalf("兩個名稱應回 2,得到 %d:%s", code, stderr) + } +} + +// TestMemberHelpExitZero 驗證命令的 -h 結束碼為 0(#25 順帶修正: +// errHelp 視為成功)。 +func TestMemberHelpExitZero(t *testing.T) { + _, _, code := run("members", "-h") + if code != 0 { + t.Fatalf("members -h 應回 0,得到 %d", code) + } +} + +// TestLoginRemoveFlagInterleaved 驗證 login remove <名稱> --yes: +// --yes 在名稱之後仍視為旗標(未帶時走確認流程會讀 stdin)。 +func TestLoginRemoveFlagInterleaved(t *testing.T) { + prefix, configPath := loginTestEnv(t, `logins: + - name: a + url: https://a.example.com + token: ta + default: true +`) + old := loginStdin + loginStdin = strings.NewReader("y\n") // 若 --yes 失效會讀到這行 + defer func() { loginStdin = old }() + _, _, code := run(append(prefix, "login", "remove", "a", "--yes")...) + if code != 0 { + t.Fatalf("login remove a --yes 應回 0,得到 %d", code) + } + data, _ := os.ReadFile(configPath) + if strings.Contains(string(data), "token: ta") { + t.Fatalf("--yes 在名稱之後應生效(直接刪除):\n%s", data) + } +} diff --git a/internal/cli/login.go b/internal/cli/login.go index e861702..e5b56a4 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -93,8 +93,8 @@ func printLoginList(env *Env, logins []gitea.LoginEntry) error { func runLoginList(env *Env, args []string) error { fs := flag.NewFlagSet("teai login list", flag.ContinueOnError) fs.SetOutput(env.Err) - if err := fs.Parse(args); err != nil { - return &ErrUsage{Msg: err.Error()} + if err := parseFlags(fs, args); err != nil { + return err } if fs.NArg() > 0 { return &ErrUsage{Msg: fmt.Sprintf("unexpected argument %q", fs.Arg(0))} @@ -121,8 +121,8 @@ func runLoginAdd(env *Env, args []string) error { nameFlag := fs.String("name", "", "登入名稱(省略時取 URL 主機)") tokenFlag := fs.String("token", "", "API token(省略時自 stdin 讀一行)") defaultFlag := fs.Bool("default", true, "設為預設登入") - if err := fs.Parse(args); err != nil { - return &ErrUsage{Msg: err.Error()} + if err := parseFlags(fs, args); err != nil { + return err } if fs.NArg() > 0 { return &ErrUsage{Msg: fmt.Sprintf("unexpected argument %q", fs.Arg(0))} @@ -224,8 +224,8 @@ func verifyToken(env *Env, siteURL, token string) (string, error) { func runLoginDefault(env *Env, args []string) error { fs := flag.NewFlagSet("teai login default", flag.ContinueOnError) fs.SetOutput(env.Err) - if err := fs.Parse(args); err != nil { - return &ErrUsage{Msg: err.Error()} + if err := parseFlags(fs, args); err != nil { + return err } if fs.NArg() != 1 { return &ErrUsage{Msg: "login default requires exactly one login name"} @@ -255,8 +255,8 @@ func runLoginRemove(env *Env, args []string) error { fs := flag.NewFlagSet("teai login remove", flag.ContinueOnError) fs.SetOutput(env.Err) yesFlag := fs.Bool("yes", false, "跳過確認") - if err := fs.Parse(args); err != nil { - return &ErrUsage{Msg: err.Error()} + if err := parseFlags(fs, args); err != nil { + return err } if fs.NArg() != 1 { return &ErrUsage{Msg: "login remove requires exactly one login name"} diff --git a/internal/cli/workflow_commands.go b/internal/cli/workflow_commands.go index ba9dd65..198818b 100644 --- a/internal/cli/workflow_commands.go +++ b/internal/cli/workflow_commands.go @@ -105,8 +105,8 @@ func runMembers(env *Env, args []string) error { fs := flag.NewFlagSet("teai members", flag.ContinueOnError) fs.SetOutput(env.Err) hasWork := fs.Bool("has-work", false, "只列出有未完成工作的成員") - if err := fs.Parse(args); err != nil { - return &ErrUsage{Msg: err.Error()} + if err := parseFlags(fs, args); err != nil { + return err } if fs.NArg() != 0 { return &ErrUsage{Msg: "members 不接受位置參數"} @@ -154,8 +154,8 @@ func runPulls(env *Env, args []string) error { mine := fs.Bool("mine", false, "限定作者是目前帳號") reviewer := fs.Bool("reviewer", false, "限定自己是 requested reviewer") repo := fs.String("repo", "", "限定單一倉庫(owner/repo)") - if err := fs.Parse(args); err != nil { - return &ErrUsage{Msg: err.Error()} + if err := parseFlags(fs, args); err != nil { + return err } if fs.NArg() != 0 { return &ErrUsage{Msg: "pulls 不接受位置參數"} -- 2.54.0