From 927855d3cad91099fdc3d4aee7408cc8c230f9ec Mon Sep 17 00:00:00 2001 From: chenyunda218 Date: Thu, 10 Sep 2026 09:41:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?cli=EF=BC=9A=E4=BF=AE=E6=AD=A3=E5=B8=B6?= =?UTF-8?q?=E6=97=97=E6=A8=99=E5=91=BD=E4=BB=A4=E8=A2=AB=E5=85=A8=E5=9F=9F?= =?UTF-8?q?=E5=89=96=E6=9E=90=E6=94=94=E4=B8=8B=E7=9A=84=E9=98=BB=E6=96=B7?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=EF=BC=88#13=20=E5=AF=A9=E6=A0=B8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 全綠。 --- README.md | 9 + internal/cli/cli.go | 139 +++++++++------ internal/cli/exitcode_test.go | 61 ++++++- internal/cli/workflow_commands.go | 17 +- internal/cli/workflow_commands_test.go | 233 +++++++++++++++++++++++++ internal/workflow/scan.go | 16 +- 6 files changed, 403 insertions(+), 72 deletions(-) create mode 100644 internal/cli/workflow_commands_test.go diff --git a/README.md b/README.md index ed1b242..e26510c 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,15 @@ teai whoami # 目前帳號(讀 tea 組態) | `mine` | `teai mine` | 相同 | | (底線函式) | `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 為行為基準:同帳號同時跑兩者,輸出應一致(交叉驗證)。 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a4f756f..5acc872 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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 } diff --git a/internal/cli/exitcode_test.go b/internal/cli/exitcode_test.go index 1e449d0..1c88e82 100644 --- a/internal/cli/exitcode_test.go +++ b/internal/cli/exitcode_test.go @@ -68,9 +68,9 @@ func TestRunExitCodes(t *testing.T) { }) } -func TestParseGlobals(t *testing.T) { +func TestExtractGlobals(t *testing.T) { g := defaultGlobals() - rest, err := parseGlobals(&g, []string{ + rest, err := extractGlobals(&g, []string{ "--url", "https://example.com", "--token=tok", "--config", "/tmp/c.yml", @@ -95,16 +95,65 @@ func TestParseGlobals(t *testing.T) { } } -func TestParseGlobalsStopsAtCommand(t *testing.T) { +func TestExtractGlobalsAfterCommand(t *testing.T) { + // 全域選項出現在命令之後也應抽出(README:teai next --output table)。 g := defaultGlobals() - rest, err := parseGlobals(&g, []string{"version", "--output", "table"}) + rest, err := extractGlobals(&g, []string{"version", "--output", "table"}) if err != nil { t.Fatal(err) } - // 命令之後的選項屬於命令,不屬於全域。 - if len(rest) != 3 || rest[0] != "version" { + if len(rest) != 1 || 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) + 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"}) + if err != nil { + t.Fatal(err) + } + // -- 之後全部交回,--url 不再視為全域選項。 + if len(rest) != 2 || rest[0] != "--url" || rest[1] != "x" { t.Fatalf("rest = %v", rest) } + if g.Output != gitea.FormatTable { + t.Fatalf("output = %v, want table", g.Output) + } } // errString 把字串轉成 error(測試輔助)。 diff --git a/internal/cli/workflow_commands.go b/internal/cli/workflow_commands.go index 8a69ccb..d393bb6 100644 --- a/internal/cli/workflow_commands.go +++ b/internal/cli/workflow_commands.go @@ -30,6 +30,11 @@ func init() { // errNoRepoFlag 是 --repo 值格式錯誤。 var errNoRepoFlag = errors.New("--repo 需為 owner/repo 形式") +// newScannerFunc 建立工作流 Scanner;正式環境用 API client。 +// 宣告為變數讓接線層測試可注入假 client(不走網路), +// 彌補 workflow 套件單元測試覆蓋不到 CLI 分派路徑的缺口。 +var newScannerFunc = newWorkflowClient + // newWorkflowClient 依全域選項建立 workflow 所需的 API client。 func newWorkflowClient(env *Env) (*workflow.Scanner, error) { token, _, err := gitea.ResolveToken(gitea.AuthOptions{ @@ -57,7 +62,7 @@ func runWhoami(env *Env, args []string) error { if len(args) != 0 { return &ErrUsage{Msg: "whoami 不接受參數"} } - s, err := newWorkflowClient(env) + s, err := newScannerFunc(env) if err != nil { return err } @@ -76,7 +81,7 @@ func runOrgs(env *Env, args []string) error { if len(args) != 0 { return &ErrUsage{Msg: "orgs 不接受參數"} } - s, err := newWorkflowClient(env) + s, err := newScannerFunc(env) if err != nil { return err } @@ -105,7 +110,7 @@ func runMembers(env *Env, args []string) error { if fs.NArg() != 0 { return &ErrUsage{Msg: "members 不接受位置參數"} } - s, err := newWorkflowClient(env) + s, err := newScannerFunc(env) if err != nil { return err } @@ -125,7 +130,7 @@ func runMine(env *Env, args []string) error { if len(args) != 0 { return &ErrUsage{Msg: "mine 不接受參數"} } - s, err := newWorkflowClient(env) + s, err := newScannerFunc(env) if err != nil { return err } @@ -165,7 +170,7 @@ func runPulls(env *Env, args []string) error { } owner, name = parts[0], parts[1] } - s, err := newWorkflowClient(env) + s, err := newScannerFunc(env) if err != nil { return err } @@ -198,7 +203,7 @@ func runNext(env *Env, args []string) error { if len(args) != 0 { return &ErrUsage{Msg: "next 不接受參數"} } - s, err := newWorkflowClient(env) + s, err := newScannerFunc(env) if err != nil { return err } diff --git a/internal/cli/workflow_commands_test.go b/internal/cli/workflow_commands_test.go new file mode 100644 index 0000000..5dd7bda --- /dev/null +++ b/internal/cli/workflow_commands_test.go @@ -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) + } +} diff --git a/internal/workflow/scan.go b/internal/workflow/scan.go index 5934c0e..9415ec4 100644 --- a/internal/workflow/scan.go +++ b/internal/workflow/scan.go @@ -198,6 +198,16 @@ type orgIssue struct { 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(至少一人)且最後一則留言 // 不是 assignee 的 open issues(gitea.py 的 for_all_organizations)。 func (s *Scanner) collectMemberIssues(members []string) ([]orgIssue, error) { @@ -217,7 +227,7 @@ func (s *Scanner) collectMemberIssues(members []string) ([]orgIssue, error) { if !AssigneeOverlaps(issue, members) { continue } - comments, err := s.Client.ListComments(owner, repo, issue.issueNumber()) + comments, err := s.commentsFor(owner, repo, issue.issueNumber()) if err != nil { return nil, err } @@ -275,7 +285,7 @@ func (s *Scanner) Pulls(mine, reviewer bool) ([]PullItem, error) { for _, rp := range repoPulls { for i := range rp.Pulls { 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 { return nil, err } @@ -331,7 +341,7 @@ func (s *Scanner) usernamesWithUnfinishedWork(members []string) (map[string]bool for _, rp := range repoPulls { for i := range rp.Pulls { 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 { return nil, err } From f481377b9a72dc1811410142f7fb3930e4b7593e Mon Sep 17 00:00:00 2001 From: chenyunda218 Date: Thu, 10 Sep 2026 10:12:35 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E6=B8=AC=E8=A9=A6=E6=94=B9=E6=96=B7?= =?UTF-8?q?=E8=A8=80=E5=85=B7=E9=AB=94=E7=B5=90=E6=9D=9F=E7=A2=BC=E6=95=B8?= =?UTF-8?q?=E5=80=BC=EF=BC=8C=E5=B0=8D=E7=85=A7=20README=20=E8=A6=8F?= =?UTF-8?q?=E6=A0=BC=EF=BC=88#10=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli_test、dailyops_commands_test、login_test、exitcode_test 原以 int(ExitUsage)/int(ExitAPI)/int(ExitInternal) 常數互比,常數若再 與 README 脫鉤測試仍會綠。改為直接斷言 0/2/3(內部錯誤 1)。 常數本體已隨 #5(6951817)改為明確數值,未知命令實測回 2。 --- internal/cli/cli_test.go | 6 ++++-- internal/cli/dailyops_commands_test.go | 13 ++++++++----- internal/cli/exitcode_test.go | 18 ++++++++++-------- internal/cli/login_test.go | 20 ++++++++++++-------- 4 files changed, 34 insertions(+), 23 deletions(-) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 200aded..4b3b60e 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -51,8 +51,10 @@ func TestRunVersion(t *testing.T) { func TestRunUnknownCommand(t *testing.T) { _, stderr, code := run("no-such-command") - if code != int(ExitUsage) { - t.Errorf("未知命令應回 ExitUsage(%d),得到 %d", int(ExitUsage), code) + // 直接對照 README「輸出與結束碼」的數值(用法錯誤=2), + // 不以常數互比,避免常數與規格再次脫鉤(#10)。 + if code != 2 { + t.Errorf("未知命令應回 2(README 用法錯誤),得到 %d", code) } if !strings.Contains(stderr, "unknown command") { t.Errorf("未知命令應在 stderr 說明,得到:%q", stderr) diff --git a/internal/cli/dailyops_commands_test.go b/internal/cli/dailyops_commands_test.go index b59e175..187270d 100644 --- a/internal/cli/dailyops_commands_test.go +++ b/internal/cli/dailyops_commands_test.go @@ -53,8 +53,9 @@ func TestIssuesListRequiresRepo(t *testing.T) { t.Errorf("不應發出任何請求") }) _, stderr, code := run("issues", "list") - if code != int(ExitUsage) { - t.Errorf("缺 --repo 應回 ExitUsage,得到 %d", code) + // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。 + if code != 2 { + t.Errorf("缺 --repo 應回 2(README 用法錯誤),得到 %d", code) } if !strings.Contains(stderr, "--repo") { 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) }) _, stderr, code := run(tc.args...) - if code != int(ExitUsage) { - t.Errorf("%s 未帶 --yes 應回 ExitUsage(2),得到 %d", tc.name, code) + // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。 + if code != 2 { + t.Errorf("%s 未帶 --yes 應回 2(README 用法錯誤),得到 %d", tc.name, code) } if !strings.Contains(stderr, "--yes") { t.Errorf("%s 錯誤訊息應提及 --yes,得到:%s", tc.name, stderr) @@ -235,7 +237,8 @@ func TestAPIDirectPassthrough(t *testing.T) { } _, 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) } } diff --git a/internal/cli/exitcode_test.go b/internal/cli/exitcode_test.go index 1e449d0..59a77c5 100644 --- a/internal/cli/exitcode_test.go +++ b/internal/cli/exitcode_test.go @@ -10,20 +10,22 @@ import ( ) func TestExitCodeMapping(t *testing.T) { + // want 直接寫 README「輸出與結束碼」的數值(0/2/3;內部錯誤 1), + // 不以常數互比,避免常數與規格再次脫鉤(#10)。 cases := []struct { name string err error - want ExitCode + want int }{ - {"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}, + {"usage error", &ErrUsage{Msg: "bad flag"}, 2}, + {"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")}, 3}, + {"wrapped api error", wrapped(&gitea.ErrAPI{StatusCode: 500}), 3}, + {"other error", errString("boom"), 1}, + {"nil maps to OK", nil, 0}, } 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) } } diff --git a/internal/cli/login_test.go b/internal/cli/login_test.go index aea20d9..f8e632f 100644 --- a/internal/cli/login_test.go +++ b/internal/cli/login_test.go @@ -145,8 +145,9 @@ func TestLoginAddRejected401NoWrite(t *testing.T) { `) _, stderr, code := run(append(prefix, "--url", srv.URL, "login", "add", "--token", "bad-tok")...) - if code != int(ExitAPI) { - t.Fatalf("401 should exit 3, got %d (stderr %s)", code, stderr) + // 401 是 API 錯誤;對照 README「輸出與結束碼」=3(#10)。 + if code != 3 { + t.Fatalf("401 should exit 3 (README api error), got %d (stderr %s)", code, stderr) } if !strings.Contains(stderr, "401") { t.Fatalf("stderr should mention 401: %s", stderr) @@ -250,8 +251,9 @@ func TestLoginRemoveAsksConfirmation(t *testing.T) { func TestLoginUnknownSubcommandUsage(t *testing.T) { prefix, _ := loginTestEnv(t, "") _, stderr, code := run(append(prefix, "login", "bogus")...) - if code != int(ExitUsage) { - t.Fatalf("want exit 2, got %d", code) + // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。 + if code != 2 { + t.Fatalf("want exit 2 (README usage), got %d", code) } if !strings.Contains(stderr, "unknown login subcommand") { t.Fatalf("stderr: %s", stderr) @@ -261,8 +263,9 @@ func TestLoginUnknownSubcommandUsage(t *testing.T) { func TestLoginNoSubcommandUsage(t *testing.T) { prefix, _ := loginTestEnv(t, "") _, stderr, code := run(append(prefix, "login")...) - if code != int(ExitUsage) { - t.Fatalf("want exit 2, got %d", code) + // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。 + if code != 2 { + t.Fatalf("want exit 2 (README usage), got %d", code) } if !strings.Contains(stderr, "subcommand") { t.Fatalf("stderr: %s", stderr) @@ -272,8 +275,9 @@ func TestLoginNoSubcommandUsage(t *testing.T) { func TestLoginRemoveNotFound(t *testing.T) { prefix, _ := loginTestEnv(t, "logins: []\n") _, stderr, code := run(append(prefix, "login", "remove", "--yes", "ghost")...) - if code != int(ExitInternal) { - t.Fatalf("want exit 1, got %d", code) + // ExitInternal 不在 README 保證範圍;此處固定為 1(內部錯誤,#10)。 + if code != 1 { + t.Fatalf("want exit 1 (internal), got %d", code) } if !strings.Contains(stderr, `login "ghost" not found`) { t.Fatalf("stderr: %s", stderr)