Compare commits

..
Author SHA1 Message Date
max 0ed6f262c8 合併 main(4a34def)並把 exitcode_test.go 四處 extractGlobals 呼叫補上第三參數 nil(#20) 2026-09-10 10:32:53 +08:00
max fb2990dc04 Merge remote-tracking branch 'origin/main' into fix/issue18-reserved-flags 2026-09-10 10:32:19 +08:00
ceo 4a34def1f5 Merge pull request 'gitea.py 工作流命令 Go 化:whoami/orgs/members/pulls/mine/next(#6)' (#13) from feature/issue6-workflow-commands into main 2026-09-10 10:24:21 +08:00
ceo 15c2d35935 合併 main(#16/#17)並解 cli.go 衝突
衝突原因:#13(本分支 927855d)與 #16(main 7badd9e)各自實作了
同一問題(帶旗標命令被全域剖析攔下)的抽取式修正。採 main 現行
架構(dispatch 內 extractGlobals)為準,並保留本分支的:

- runVersion 參數檢查(version 不接受參數,TestWiringVersionRejectsArgs)
- workflow 命令與接線層測試(workflow_commands_test.go)

go build/vet/gofmt 綠;單元測試僅餘 main 既有回歸
(3 個 login 測試,#16 合併後即失敗,與本 PR 無關)。
2026-09-10 10:22:42 +08:00
max c4fb641094 internal/cli:命令保留旗標機制,login add 的 --url/--token 不再被全域剖析搶走(#18) 2026-09-10 10:22:39 +08:00
queena f7d63d6e8f Merge pull request '測試改斷言具體結束碼數值,對照 README 規格(#10)' (#19) from fix/issue10-exitcode-tests into main 2026-09-10 10:20:23 +08:00
chenyunda218 f481377b9a 測試改斷言具體結束碼數值,對照 README 規格(#10)
cli_test、dailyops_commands_test、login_test、exitcode_test 原以
int(ExitUsage)/int(ExitAPI)/int(ExitInternal) 常數互比,常數若再
與 README 脫鉤測試仍會綠。改為直接斷言 0/2/3(內部錯誤 1)。

常數本體已隨 #5(6951817)改為明確數值,未知命令實測回 2。
2026-09-10 10:12:35 +08:00
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
9 changed files with 446 additions and 43 deletions
+9
View File
@@ -236,6 +236,15 @@ teai whoami # 目前帳號(讀 tea 組態)
| `mine` | `teai mine` | 相同 | | `mine` | `teai mine` | 相同 |
| (底線函式) | `internal/` 套件 | 不設 CLI 命令;`xrefs` 為其邏輯的公開化 | | (底線函式) | `internal/` 套件 | 不設 CLI 命令;`xrefs` 為其邏輯的公開化 |
已知輸出差異(刻意保留,機器可讀優先):
- **空清單**:`gitea.py` 的 `_dump` 對空清單**不輸出任何內容**;teai 的清單類命令
(`mine`/`pulls`)空清單輸出 `[]`。對 JSON 消費者來說 `[]` 比「無輸出」更明確,
交叉驗證時以此差異為準。`next` 兩者語義相同(無工作 → `null`/「沒有未完成的工作」)。
- **全域選項位置**:teai 的全域選項(`--output` 等)可出現在命令之前或之後,
且可與命令旗標交錯(`teai members --has-work --output table`);
gitea.py 的旗標剖析較寬鬆,兩者命令列介面以此行為對齊。
遷移策略: 遷移策略:
1. 以 gitea.py 為行為基準:同帳號同時跑兩者,輸出應一致(交叉驗證)。 1. 以 gitea.py 為行為基準:同帳號同時跑兩者,輸出應一致(交叉驗證)。
+40 -4
View File
@@ -150,10 +150,31 @@ var globalFlags = map[string]bool{
"--timeout": true, "--timeout": true,
} }
// reservedFlags 回傳 dispatch 不得搶走的旗標名稱(含 -- 前綴):這些
// 旗標屬於目前命令(或其子命令),即使與全域選項同名也必須原樣留給
// 命令剖析。目前唯一與全域同名的是 login add 的 --url/--token
// (README「teai login add --url … --token …」)。其餘命令的旗標
// (--repo、--has-work、--mine……)本來就不在 globalFlags 內,不受影響。
func reservedFlags(name string) map[string]bool {
reserved := make(map[string]bool)
for _, f := range commandReservedFlags[name] {
reserved[f] = true
}
return reserved
}
// commandReservedFlags 登記每個命令「與全域選項同名」的自有旗標。
// 命令旗標優先留給命令;新命令有同姓旗標時在這裡登記(#18)。
var commandReservedFlags = map[string][]string{
"login": {"--url", "--token"},
}
// extractGlobals 從 args 中「抽取」全域選項(可出現在命令之後、與命令 // extractGlobals 從 args 中「抽取」全域選項(可出現在命令之後、與命令
// 旗標交錯),其餘引數依原順序保留給子命令剖析。未知的選項一律留給 // 旗標交錯),其餘引數依原順序保留給子命令剖析。未知的選項一律留給
// 子命令處理(可能是命令旗標,如 --has-work),不在這裡報錯。 // 子命令處理(可能是命令旗標,如 --has-work),不在這裡報錯。
func extractGlobals(g *Globals, args []string) ([]string, error) { // reserved 是目前命令保留的旗標名稱(含 -- 前綴,見 reservedFlags):
// 列於其中的選項即使與全域同名也原樣留給命令,命令旗標優先(#18)。
func extractGlobals(g *Globals, args []string, reserved map[string]bool) ([]string, error) {
rest := make([]string, 0, len(args)) rest := make([]string, 0, len(args))
applyGlobal := func(name, value string) error { applyGlobal := func(name, value string) error {
switch name { switch name {
@@ -197,6 +218,17 @@ func extractGlobals(g *Globals, args []string) ([]string, error) {
continue continue
} }
name, inline, hasInline := strings.Cut(arg, "=") name, inline, hasInline := strings.Cut(arg, "=")
if reserved[name] {
// 命令保留旗標:即使與全域選項同名也留給命令剖析(#18)。
rest = append(rest, arg)
if !hasInline && i+1 < len(args) {
// 旗標值跟在下一個引數;一併保留,不當成全域值。
rest = append(rest, args[i+1])
i++
}
i++
continue
}
_, ok := globalFlags[name] _, ok := globalFlags[name]
if !ok { if !ok {
// 不是全域選項:留給子命令(可能是 --has-work 這類命令旗標)。 // 不是全域選項:留給子命令(可能是 --has-work 這類命令旗標)。
@@ -293,11 +325,12 @@ func parseGlobals(g *Globals, args []string) ([]string, error) {
// 旗標剖析交由各命令自行處理(不同命令有不同旗標,如 pulls --mine); // 旗標剖析交由各命令自行處理(不同命令有不同旗標,如 pulls --mine);
// 全域選項允許出現在命令之後(README 範例:teai next --output table), // 全域選項允許出現在命令之後(README 範例:teai next --output table),
// dispatch 先從 args 剝離全域選項併入 Globals,其餘(含命令自己的旗標, // dispatch 先從 args 剝離全域選項併入 Globals,其餘(含命令自己的旗標,
// 如 --has-work)原樣交給命令,不會誤判為未知全域選項。 // 如 --has-work)原樣交給命令,不會誤判為未知全域選項。與全域選項
// 同名的命令旗標(登記於 commandReservedFlags)優先留給命令(#18)。
func dispatch(env *Env, name string, args []string) int { func dispatch(env *Env, name string, args []string) int {
cmd := commands[name] cmd := commands[name]
g := env.Globals g := env.Globals
rest, err := extractGlobals(&g, args) rest, err := extractGlobals(&g, args, reservedFlags(name))
if err != nil { if err != nil {
fmt.Fprintf(env.Err, "teai: %v\n\n", err) fmt.Fprintf(env.Err, "teai: %v\n\n", err)
printUsage(env.Err) printUsage(env.Err)
@@ -344,8 +377,11 @@ func exitCodeFor(err error) ExitCode {
return ExitInternal return ExitInternal
} }
// runVersion 輸出版本資訊。 // runVersion 輸出版本資訊;不接受任何參數。
func runVersion(env *Env, args []string) error { func runVersion(env *Env, args []string) error {
if len(args) > 0 {
return &ErrUsage{Msg: "version 不接受參數"}
}
fmt.Fprintf(env.Out, "teai version %s\n", Version) fmt.Fprintf(env.Out, "teai version %s\n", Version)
return nil return nil
} }
+4 -2
View File
@@ -51,8 +51,10 @@ func TestRunVersion(t *testing.T) {
func TestRunUnknownCommand(t *testing.T) { func TestRunUnknownCommand(t *testing.T) {
_, stderr, code := run("no-such-command") _, stderr, code := run("no-such-command")
if code != int(ExitUsage) { // 直接對照 README「輸出與結束碼」的數值(用法錯誤=2),
t.Errorf("未知命令應回 ExitUsage(%d),得到 %d", int(ExitUsage), code) // 不以常數互比,避免常數與規格再次脫鉤(#10)。
if code != 2 {
t.Errorf("未知命令應回 2(README 用法錯誤),得到 %d", code)
} }
if !strings.Contains(stderr, "unknown command") { if !strings.Contains(stderr, "unknown command") {
t.Errorf("未知命令應在 stderr 說明,得到:%q", stderr) t.Errorf("未知命令應在 stderr 說明,得到:%q", stderr)
+8 -5
View File
@@ -53,8 +53,9 @@ func TestIssuesListRequiresRepo(t *testing.T) {
t.Errorf("不應發出任何請求") t.Errorf("不應發出任何請求")
}) })
_, stderr, code := run("issues", "list") _, stderr, code := run("issues", "list")
if code != int(ExitUsage) { // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。
t.Errorf("缺 --repo 應回 ExitUsage,得到 %d", code) if code != 2 {
t.Errorf("缺 --repo 應回 2(README 用法錯誤),得到 %d", code)
} }
if !strings.Contains(stderr, "--repo") { if !strings.Contains(stderr, "--repo") {
t.Errorf("錯誤訊息應提及 --repo,得到:%s", stderr) t.Errorf("錯誤訊息應提及 --repo,得到:%s", stderr)
@@ -110,8 +111,9 @@ func TestWriteCommandsRequireYes(t *testing.T) {
t.Errorf("%s:未帶 --yes 不得發出請求(%s %s)", tc.name, r.Method, r.URL.Path) t.Errorf("%s:未帶 --yes 不得發出請求(%s %s)", tc.name, r.Method, r.URL.Path)
}) })
_, stderr, code := run(tc.args...) _, stderr, code := run(tc.args...)
if code != int(ExitUsage) { // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。
t.Errorf("%s 未帶 --yes 應回 ExitUsage(2),得到 %d", tc.name, code) if code != 2 {
t.Errorf("%s 未帶 --yes 應回 2(README 用法錯誤),得到 %d", tc.name, code)
} }
if !strings.Contains(stderr, "--yes") { if !strings.Contains(stderr, "--yes") {
t.Errorf("%s 錯誤訊息應提及 --yes,得到:%s", tc.name, stderr) t.Errorf("%s 錯誤訊息應提及 --yes,得到:%s", tc.name, stderr)
@@ -235,7 +237,8 @@ func TestAPIDirectPassthrough(t *testing.T) {
} }
_, stderr, code := run("api", "--method", "POST", "/version") _, stderr, code := run("api", "--method", "POST", "/version")
if code != int(ExitUsage) || !strings.Contains(stderr, "--yes") { // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。
if code != 2 || !strings.Contains(stderr, "--yes") {
t.Errorf("POST 未帶 --yes 應回 2 並提及 --yes,得到 %d:%s", code, stderr) t.Errorf("POST 未帶 --yes 應回 2 並提及 --yes,得到 %d:%s", code, stderr)
} }
} }
+116 -15
View File
@@ -10,20 +10,22 @@ import (
) )
func TestExitCodeMapping(t *testing.T) { func TestExitCodeMapping(t *testing.T) {
// want 直接寫 README「輸出與結束碼」的數值(0/2/3;內部錯誤 1),
// 不以常數互比,避免常數與規格再次脫鉤(#10)。
cases := []struct { cases := []struct {
name string name string
err error err error
want ExitCode want int
}{ }{
{"usage error", &ErrUsage{Msg: "bad flag"}, ExitUsage}, {"usage error", &ErrUsage{Msg: "bad flag"}, 2},
{"api error 401", &gitea.ErrAPI{StatusCode: 401, Op: "GET /api/v1/user"}, ExitAPI}, {"api error 401", &gitea.ErrAPI{StatusCode: 401, Op: "GET /api/v1/user"}, 3},
{"api error transport", &gitea.ErrAPI{Op: "GET /api/v1/user", Err: errString("dial tcp")}, ExitAPI}, {"api error transport", &gitea.ErrAPI{Op: "GET /api/v1/user", Err: errString("dial tcp")}, 3},
{"wrapped api error", wrapped(&gitea.ErrAPI{StatusCode: 500}), ExitAPI}, {"wrapped api error", wrapped(&gitea.ErrAPI{StatusCode: 500}), 3},
{"other error", errString("boom"), ExitInternal}, {"other error", errString("boom"), 1},
{"nil maps to OK", nil, ExitOK}, {"nil maps to OK", nil, 0},
} }
for _, tc := range cases { for _, tc := range cases {
if got := exitCodeFor(tc.err); got != tc.want { if got := int(exitCodeFor(tc.err)); got != tc.want {
t.Errorf("%s: exitCodeFor = %d, want %d", tc.name, got, tc.want) t.Errorf("%s: exitCodeFor = %d, want %d", tc.name, got, tc.want)
} }
} }
@@ -68,16 +70,16 @@ func TestRunExitCodes(t *testing.T) {
}) })
} }
func TestParseGlobals(t *testing.T) { func TestExtractGlobals(t *testing.T) {
g := defaultGlobals() g := defaultGlobals()
rest, err := parseGlobals(&g, []string{ rest, err := extractGlobals(&g, []string{
"--url", "https://example.com", "--url", "https://example.com",
"--token=tok", "--token=tok",
"--config", "/tmp/c.yml", "--config", "/tmp/c.yml",
"--output", "table", "--output", "table",
"--timeout", "45s", "--timeout", "45s",
"next", "next",
}) }, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -95,16 +97,115 @@ func TestParseGlobals(t *testing.T) {
} }
} }
func TestParseGlobalsStopsAtCommand(t *testing.T) { func TestExtractGlobalsAfterCommand(t *testing.T) {
// 全域選項出現在命令之後也應抽出(README:teai next --output table)。
g := defaultGlobals() g := defaultGlobals()
rest, err := parseGlobals(&g, []string{"version", "--output", "table"}) rest, err := extractGlobals(&g, []string{"version", "--output", "table"}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// 命令之後的選項屬於命令,不屬於全域。 if len(rest) != 1 || rest[0] != "version" {
if len(rest) != 3 || rest[0] != "version" { t.Fatalf("rest = %v, want [version]", rest)
}
if g.Output != gitea.FormatTable {
t.Fatalf("output = %v, want table", g.Output)
}
}
func TestExtractGlobalsKeepsCommandFlags(t *testing.T) {
// 非全域選項屬於子命令:原樣依序交回,不能攔成 ErrUsage。
cases := [][]string{
{"members", "--has-work"},
{"pulls", "--mine"},
{"pulls", "--reviewer"},
{"pulls", "--repo", "alterminal/teai"},
{"members", "--has-work", "--output", "table"},
{"pulls", "--mine", "--repo", "alterminal/teai", "--output", "table"},
}
for _, args := range cases {
g := defaultGlobals()
rest, err := extractGlobals(&g, args, nil)
if err != nil {
t.Fatalf("extractGlobals(%v) 不應報錯:%v", args, err)
}
want := args
if strings.HasSuffix(strings.Join(args, " "), "--output table") {
want = args[:len(args)-2]
}
if len(rest) != len(want) {
t.Fatalf("extractGlobals(%v) rest = %v, want %v", args, rest, want)
}
for i := range want {
if rest[i] != want[i] {
t.Fatalf("extractGlobals(%v) rest = %v, want %v", args, rest, want)
}
}
}
}
func TestExtractGlobalsDoubleDashStops(t *testing.T) {
g := defaultGlobals()
rest, err := extractGlobals(&g, []string{"--output", "table", "--", "--url", "x"}, nil)
if err != nil {
t.Fatal(err)
}
// -- 之後全部交回,--url 不再視為全域選項。
if len(rest) != 2 || rest[0] != "--url" || rest[1] != "x" {
t.Fatalf("rest = %v", rest) t.Fatalf("rest = %v", rest)
} }
if g.Output != gitea.FormatTable {
t.Fatalf("output = %v, want table", g.Output)
}
}
// TestExtractGlobalsReservedFlags 驗證 #18:與全域同名的命令旗標
// (登記於 commandReservedFlags)必須留給命令,其餘全域選項照常抽取。
func TestExtractGlobalsReservedFlags(t *testing.T) {
g := defaultGlobals()
rest, err := extractGlobals(&g, []string{
"add",
"--url", "https://srv.example.com", // login add 自有旗標:保留
"--token=tok", // 同上(inline 形式):保留
"--name", "test-site",
"--output", "table", // 非保留:仍是全域
}, reservedFlags("login"))
if err != nil {
t.Fatal(err)
}
want := []string{"add", "--url", "https://srv.example.com", "--token=tok", "--name", "test-site"}
if len(rest) != len(want) {
t.Fatalf("rest = %v, want %v", rest, want)
}
for i := range want {
if rest[i] != want[i] {
t.Fatalf("rest = %v, want %v", rest, want)
}
}
if g.Output != gitea.FormatTable {
t.Fatalf("output = %v, want table", g.Output)
}
if g.URL != defaultGlobals().URL || g.Token != "" {
t.Fatalf("reserved flags must not leak into globals: %+v", g)
}
}
// TestExtractGlobalsNonReservedCommand 驗證 #13 行為不回歸:未登記保留
// 旗標的命令(如 next/members/pulls),命令後的全域選項照常抽取、
// 命令自己的旗標(--has-work 等)原樣保留。
func TestExtractGlobalsNonReservedCommand(t *testing.T) {
g := defaultGlobals()
rest, err := extractGlobals(&g, []string{
"members", "--has-work", "--output", "table",
}, reservedFlags("members"))
if err != nil {
t.Fatal(err)
}
if len(rest) != 2 || rest[0] != "members" || rest[1] != "--has-work" {
t.Fatalf("rest = %v, want [members --has-work]", rest)
}
if g.Output != gitea.FormatTable {
t.Fatalf("output = %v, want table", g.Output)
}
} }
// errString 把字串轉成 error(測試輔助)。 // errString 把字串轉成 error(測試輔助)。
+12 -8
View File
@@ -145,8 +145,9 @@ func TestLoginAddRejected401NoWrite(t *testing.T) {
`) `)
_, stderr, code := run(append(prefix, _, stderr, code := run(append(prefix,
"--url", srv.URL, "login", "add", "--token", "bad-tok")...) "--url", srv.URL, "login", "add", "--token", "bad-tok")...)
if code != int(ExitAPI) { // 401 是 API 錯誤;對照 README「輸出與結束碼」=3(#10)。
t.Fatalf("401 should exit 3, got %d (stderr %s)", code, stderr) if code != 3 {
t.Fatalf("401 should exit 3 (README api error), got %d (stderr %s)", code, stderr)
} }
if !strings.Contains(stderr, "401") { if !strings.Contains(stderr, "401") {
t.Fatalf("stderr should mention 401: %s", stderr) t.Fatalf("stderr should mention 401: %s", stderr)
@@ -250,8 +251,9 @@ func TestLoginRemoveAsksConfirmation(t *testing.T) {
func TestLoginUnknownSubcommandUsage(t *testing.T) { func TestLoginUnknownSubcommandUsage(t *testing.T) {
prefix, _ := loginTestEnv(t, "") prefix, _ := loginTestEnv(t, "")
_, stderr, code := run(append(prefix, "login", "bogus")...) _, stderr, code := run(append(prefix, "login", "bogus")...)
if code != int(ExitUsage) { // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。
t.Fatalf("want exit 2, got %d", code) if code != 2 {
t.Fatalf("want exit 2 (README usage), got %d", code)
} }
if !strings.Contains(stderr, "unknown login subcommand") { if !strings.Contains(stderr, "unknown login subcommand") {
t.Fatalf("stderr: %s", stderr) t.Fatalf("stderr: %s", stderr)
@@ -261,8 +263,9 @@ func TestLoginUnknownSubcommandUsage(t *testing.T) {
func TestLoginNoSubcommandUsage(t *testing.T) { func TestLoginNoSubcommandUsage(t *testing.T) {
prefix, _ := loginTestEnv(t, "") prefix, _ := loginTestEnv(t, "")
_, stderr, code := run(append(prefix, "login")...) _, stderr, code := run(append(prefix, "login")...)
if code != int(ExitUsage) { // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。
t.Fatalf("want exit 2, got %d", code) if code != 2 {
t.Fatalf("want exit 2 (README usage), got %d", code)
} }
if !strings.Contains(stderr, "subcommand") { if !strings.Contains(stderr, "subcommand") {
t.Fatalf("stderr: %s", stderr) t.Fatalf("stderr: %s", stderr)
@@ -272,8 +275,9 @@ func TestLoginNoSubcommandUsage(t *testing.T) {
func TestLoginRemoveNotFound(t *testing.T) { func TestLoginRemoveNotFound(t *testing.T) {
prefix, _ := loginTestEnv(t, "logins: []\n") prefix, _ := loginTestEnv(t, "logins: []\n")
_, stderr, code := run(append(prefix, "login", "remove", "--yes", "ghost")...) _, stderr, code := run(append(prefix, "login", "remove", "--yes", "ghost")...)
if code != int(ExitInternal) { // ExitInternal 不在 README 保證範圍;此處固定為 1(內部錯誤,#10)。
t.Fatalf("want exit 1, got %d", code) if code != 1 {
t.Fatalf("want exit 1 (internal), got %d", code)
} }
if !strings.Contains(stderr, `login "ghost" not found`) { if !strings.Contains(stderr, `login "ghost" not found`) {
t.Fatalf("stderr: %s", stderr) t.Fatalf("stderr: %s", stderr)
+11 -6
View File
@@ -31,6 +31,11 @@ func init() {
// errNoRepoFlag 是 --repo 值格式錯誤。 // errNoRepoFlag 是 --repo 值格式錯誤。
var errNoRepoFlag = errors.New("--repo 需為 owner/repo 形式") var errNoRepoFlag = errors.New("--repo 需為 owner/repo 形式")
// newScannerFunc 建立工作流 Scanner;正式環境用 API client。
// 宣告為變數讓接線層測試可注入假 client(不走網路),
// 彌補 workflow 套件單元測試覆蓋不到 CLI 分派路徑的缺口。
var newScannerFunc = newWorkflowClient
// newWorkflowClient 依全域選項建立 workflow 所需的 API client。 // newWorkflowClient 依全域選項建立 workflow 所需的 API client。
func newWorkflowClient(env *Env) (*workflow.Scanner, error) { func newWorkflowClient(env *Env) (*workflow.Scanner, error) {
token, _, err := gitea.ResolveToken(gitea.AuthOptions{ token, _, err := gitea.ResolveToken(gitea.AuthOptions{
@@ -58,7 +63,7 @@ func runWhoami(env *Env, args []string) error {
if len(args) != 0 { if len(args) != 0 {
return &ErrUsage{Msg: "whoami 不接受參數"} return &ErrUsage{Msg: "whoami 不接受參數"}
} }
s, err := newWorkflowClient(env) s, err := newScannerFunc(env)
if err != nil { if err != nil {
return err return err
} }
@@ -77,7 +82,7 @@ func runOrgs(env *Env, args []string) error {
if len(args) != 0 { if len(args) != 0 {
return &ErrUsage{Msg: "orgs 不接受參數"} return &ErrUsage{Msg: "orgs 不接受參數"}
} }
s, err := newWorkflowClient(env) s, err := newScannerFunc(env)
if err != nil { if err != nil {
return err return err
} }
@@ -106,7 +111,7 @@ func runMembers(env *Env, args []string) error {
if fs.NArg() != 0 { if fs.NArg() != 0 {
return &ErrUsage{Msg: "members 不接受位置參數"} return &ErrUsage{Msg: "members 不接受位置參數"}
} }
s, err := newWorkflowClient(env) s, err := newScannerFunc(env)
if err != nil { if err != nil {
return err return err
} }
@@ -126,7 +131,7 @@ func runMine(env *Env, args []string) error {
if len(args) != 0 { if len(args) != 0 {
return &ErrUsage{Msg: "mine 不接受參數"} return &ErrUsage{Msg: "mine 不接受參數"}
} }
s, err := newWorkflowClient(env) s, err := newScannerFunc(env)
if err != nil { if err != nil {
return err return err
} }
@@ -166,7 +171,7 @@ func runPulls(env *Env, args []string) error {
} }
owner, name = parts[0], parts[1] owner, name = parts[0], parts[1]
} }
s, err := newWorkflowClient(env) s, err := newScannerFunc(env)
if err != nil { if err != nil {
return err return err
} }
@@ -199,7 +204,7 @@ func runNext(env *Env, args []string) error {
if len(args) != 0 { if len(args) != 0 {
return &ErrUsage{Msg: "next 不接受參數"} return &ErrUsage{Msg: "next 不接受參數"}
} }
s, err := newWorkflowClient(env) s, err := newScannerFunc(env)
if err != nil { if err != nil {
return err return err
} }
+233
View File
@@ -0,0 +1,233 @@
// workflow_commands_test.go — CLI 接線層測試:驗證 Run→dispatch→命令 flagset
// 的完整路徑。ceo 於 PR #13 審核指出帶旗標命令(members --has-work、
// pulls --mine/--reviewer)在 CLI 層全部 exit 2,workflow 單元測試覆蓋不到
// 這條路徑——本檔以注入假 client 的方式補上,不走網路。
package cli
import (
"strings"
"testing"
"gitea.alterminal.com/alterminal/teai/internal/workflow"
)
// stubClient 以記憶體資料實作 workflow.Client(最小集,僅供接線測試)。
type stubClient struct {
currentUser string
orgs []workflow.Org
teams map[string][]workflow.Team
members map[int64][]string
repos map[string][]workflow.Repo
issues map[string][]workflow.Issue
pulls map[string][]workflow.PullRequest
comments map[string][]workflow.Comment
}
func (c *stubClient) CurrentUser() (string, error) { return c.currentUser, nil }
func (c *stubClient) ListOrgs() ([]string, error) {
out := make([]string, 0, len(c.orgs))
for _, o := range c.orgs {
out = append(out, o.Account())
}
return out, nil
}
func (c *stubClient) ListTeams(org string) ([]workflow.Team, error) {
return c.teams[org], nil
}
func (c *stubClient) ListTeamMembers(teamID int64) ([]string, error) {
return c.members[teamID], nil
}
func (c *stubClient) ListOrgRepos(org string) ([]workflow.Repo, error) {
return c.repos[org], nil
}
func (c *stubClient) ListIssues(owner, repo string) ([]workflow.Issue, error) {
return c.issues[owner+"/"+repo], nil
}
func (c *stubClient) ListPulls(owner, repo string) ([]workflow.PullRequest, error) {
return c.pulls[owner+"/"+repo], nil
}
func (c *stubClient) ListComments(owner, repo string, number int64) ([]workflow.Comment, error) {
return c.comments[owner+"/"+repo], nil
}
// newStubEnv 注入假 client,回傳還原函式(測試結束時呼叫)。
func newStubEnv(t *testing.T, c *stubClient) {
t.Helper()
orig := newScannerFunc
newScannerFunc = func(env *Env) (*workflow.Scanner, error) {
return &workflow.Scanner{Client: c}, nil
}
t.Cleanup(func() { newScannerFunc = orig })
}
// newStubData 建構標準情境:alterminal 組織、Agents 團隊、teai 倉庫。
func newStubData() *stubClient {
return &stubClient{
currentUser: "alex",
orgs: []workflow.Org{{UserName: "alterminal"}},
teams: map[string][]workflow.Team{"alterminal": {{ID: 1, Name: "Agents"}}},
members: map[int64][]string{1: {"alex", "max", "queena"}},
repos: map[string][]workflow.Repo{"alterminal": {{Name: "teai"}}},
issues: map[string][]workflow.Issue{},
pulls: map[string][]workflow.PullRequest{},
comments: map[string][]workflow.Comment{},
}
}
// TestWiringMembersHasWork 驗證 members --has-work 不再被攔成 exit 2。
func TestWiringMembersHasWork(t *testing.T) {
newStubEnv(t, newStubData())
stdout, _, code := run("members", "--has-work")
if code != 0 {
t.Fatalf("members --has-work 應回 0,得到 %d(stdout=%q)", code, stdout)
}
// 無未完成工作 → 空輸出(每行一個帳號,無帳號即無輸出)。
if strings.TrimSpace(stdout) != "" {
t.Fatalf("無工作時 members --has-work 應無輸出,得到 %q", stdout)
}
}
// TestWiringPullsMine 驗證 pulls --mine 不再被攔成 exit 2。
func TestWiringPullsMine(t *testing.T) {
newStubEnv(t, newStubData())
stdout, _, code := run("pulls", "--mine")
if code != 0 {
t.Fatalf("pulls --mine 應回 0,得到 %d(stdout=%q)", code, stdout)
}
if strings.TrimSpace(stdout) != "[]" {
t.Fatalf("pulls --mine 空清單應輸出 [],得到 %q", stdout)
}
}
// TestWiringPullsReviewer 驗證 pulls --reviewer 不再被攔成 exit 2。
func TestWiringPullsReviewer(t *testing.T) {
newStubEnv(t, newStubData())
stdout, _, code := run("pulls", "--reviewer")
if code != 0 {
t.Fatalf("pulls --reviewer 應回 0,得到 %d(stdout=%q)", code, stdout)
}
if strings.TrimSpace(stdout) != "[]" {
t.Fatalf("pulls --reviewer 空清單應輸出 [],得到 %q", stdout)
}
}
// TestWiringPullsRepoFilter 驗證 --repo 旗標可解析且值可帶斜線。
func TestWiringPullsRepoFilter(t *testing.T) {
newStubEnv(t, newStubData())
_, _, code := run("pulls", "--repo", "alterminal/teai")
if code != 0 {
t.Fatalf("pulls --repo alterminal/teai 應回 0,得到 %d", code)
}
// --repo 值格式錯誤 → exit 2。
_, _, code = run("pulls", "--repo", "not-a-repo")
if code != int(ExitUsage) {
t.Fatalf("pulls --repo not-a-repo 應回 ExitUsage(%d),得到 %d", int(ExitUsage), code)
}
}
// TestWiringGlobalAfterCommandFlag 驗證命令旗標與全域選項交錯出現。
func TestWiringGlobalAfterCommandFlag(t *testing.T) {
newStubEnv(t, newStubData())
// members --has-work --output table:命令旗標在前、全域選項在後。
stdout, _, code := run("members", "--has-work", "--output", "table")
if code != 0 {
t.Fatalf("members --has-work --output table 應回 0,得到 %d", code)
}
if strings.Contains(stdout, "--has-work") {
t.Fatalf("全域剖析不應把 --has-work 當成全域選項處理:%q", stdout)
}
}
// TestWiringMembersAll 驗證 members 不帶旗標仍正常(回歸保護)。
func TestWiringMembersAll(t *testing.T) {
newStubEnv(t, newStubData())
stdout, _, code := run("members")
if code != 0 {
t.Fatalf("members 應回 0,得到 %d", code)
}
for _, want := range []string{"alex", "max", "queena"} {
if !strings.Contains(stdout, want) {
t.Fatalf("members 輸出應含 %q,得到 %q", want, stdout)
}
}
}
// TestWiringMine 驗證 mine 空清單輸出 [](與 gitea.py 空輸出的差異見 README)。
func TestWiringMine(t *testing.T) {
newStubEnv(t, newStubData())
stdout, _, code := run("mine")
if code != 0 {
t.Fatalf("mine 應回 0,得到 %d", code)
}
if strings.TrimSpace(stdout) != "[]" {
t.Fatalf("mine 空清單應輸出 [],得到 %q", stdout)
}
}
// TestWiringNextNull 驗證 next 無工作輸出 null、exit 0。
func TestWiringNextNull(t *testing.T) {
newStubEnv(t, newStubData())
stdout, _, code := run("next")
if code != 0 {
t.Fatalf("next 應回 0,得到 %d", code)
}
if strings.TrimSpace(stdout) != "null" {
t.Fatalf("next 無工作應輸出 null,得到 %q", stdout)
}
}
// TestWiringNextTable 驗證 teai next --output table(README 範例)。
func TestWiringNextTable(t *testing.T) {
newStubEnv(t, newStubData())
stdout, _, code := run("next", "--output", "table")
if code != 0 {
t.Fatalf("next --output table 應回 0,得到 %d", code)
}
if !strings.Contains(stdout, "(none)") {
t.Fatalf("next --output table 無工作應輸出 (none),得到 %q", stdout)
}
}
// TestWiringNextWithPull 驗證有工作時 next 輸出 JSON 物件(role=author)。
func TestWiringNextWithPull(t *testing.T) {
c := newStubData()
c.pulls["alterminal/teai"] = []workflow.PullRequest{{
Number: 13,
Title: "gitea.py 工作流命令 Go 化",
User: &workflow.User{UserName: "alex"},
}}
c.comments["alterminal/teai"] = []workflow.Comment{
{CreatedAt: "2026-09-10T00:44:00Z", User: &workflow.User{UserName: "ceo"}},
}
newStubEnv(t, c)
stdout, _, code := run("next")
if code != 0 {
t.Fatalf("next 應回 0,得到 %d", code)
}
for _, want := range []string{`"type":"pull"`, `"role":"author"`, `"number":13`} {
if !strings.Contains(stdout, want) {
t.Fatalf("next 輸出應含 %q,得到 %q", want, stdout)
}
}
}
// TestWiringWhoamiOrgs 驗證 whoami/orgs 的接線輸出。
func TestWiringWhoamiOrgs(t *testing.T) {
newStubEnv(t, newStubData())
stdout, _, code := run("whoami")
if code != 0 || !strings.Contains(stdout, "alex") {
t.Fatalf("whoami code=%d stdout=%q", code, stdout)
}
stdout, _, code = run("orgs")
if code != 0 || !strings.Contains(stdout, "alterminal") {
t.Fatalf("orgs code=%d stdout=%q", code, stdout)
}
}
// TestWiringVersionRejectsArgs 驗證 version 不接受參數(新剖析語義下仍 exit 2)。
func TestWiringVersionRejectsArgs(t *testing.T) {
_, _, code := run("version", "--wat")
if code != int(ExitUsage) {
t.Fatalf("version --wat 應回 ExitUsage(%d),得到 %d", int(ExitUsage), code)
}
}
+13 -3
View File
@@ -198,6 +198,16 @@ type orgIssue struct {
Issue Issue Issue Issue
} }
// commentsFor 取回 issue/PR 的留言;number 缺漏(0)時直接視為無留言,
// 不打 API(gitea.py 在 number 為 None 時跳過留言檢查的語義——
// 避免拿 0 去 issues/0/comments 觸發 404 中斷整個掃描)。
func (s *Scanner) commentsFor(owner, repo string, number int64) ([]Comment, error) {
if number <= 0 {
return nil, nil
}
return s.Client.ListComments(owner, repo, number)
}
// collectMemberIssues 跨組織收集分派給 members(至少一人)且最後一則留言 // collectMemberIssues 跨組織收集分派給 members(至少一人)且最後一則留言
// 不是 assignee 的 open issues(gitea.py 的 for_all_organizations)。 // 不是 assignee 的 open issues(gitea.py 的 for_all_organizations)。
func (s *Scanner) collectMemberIssues(members []string) ([]orgIssue, error) { func (s *Scanner) collectMemberIssues(members []string) ([]orgIssue, error) {
@@ -217,7 +227,7 @@ func (s *Scanner) collectMemberIssues(members []string) ([]orgIssue, error) {
if !AssigneeOverlaps(issue, members) { if !AssigneeOverlaps(issue, members) {
continue continue
} }
comments, err := s.Client.ListComments(owner, repo, issue.issueNumber()) comments, err := s.commentsFor(owner, repo, issue.issueNumber())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -275,7 +285,7 @@ func (s *Scanner) Pulls(mine, reviewer bool) ([]PullItem, error) {
for _, rp := range repoPulls { for _, rp := range repoPulls {
for i := range rp.Pulls { for i := range rp.Pulls {
pr := &rp.Pulls[i] pr := &rp.Pulls[i]
comments, err := s.Client.ListComments(rp.Owner, rp.Repo, pr.Number) comments, err := s.commentsFor(rp.Owner, rp.Repo, pr.Number)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -331,7 +341,7 @@ func (s *Scanner) usernamesWithUnfinishedWork(members []string) (map[string]bool
for _, rp := range repoPulls { for _, rp := range repoPulls {
for i := range rp.Pulls { for i := range rp.Pulls {
pr := &rp.Pulls[i] pr := &rp.Pulls[i]
comments, err := s.Client.ListComments(rp.Owner, rp.Repo, pr.Number) comments, err := s.commentsFor(rp.Owner, rp.Repo, pr.Number)
if err != nil { if err != nil {
return nil, err return nil, err
} }