Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a34def1f5 | ||
|
|
15c2d35935 | ||
|
|
f7d63d6e8f | ||
|
|
927855d3ca |
@@ -236,6 +236,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 為行為基準:同帳號同時跑兩者,輸出應一致(交叉驗證)。
|
||||
|
||||
+4
-1
@@ -344,8 +344,11 @@ func exitCodeFor(err error) ExitCode {
|
||||
return ExitInternal
|
||||
}
|
||||
|
||||
// runVersion 輸出版本資訊。
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -70,9 +70,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",
|
||||
@@ -97,16 +97,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(測試輔助)。
|
||||
|
||||
@@ -31,6 +31,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{
|
||||
@@ -58,7 +63,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
|
||||
}
|
||||
@@ -77,7 +82,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
|
||||
}
|
||||
@@ -106,7 +111,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
|
||||
}
|
||||
@@ -126,7 +131,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
|
||||
}
|
||||
@@ -166,7 +171,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
|
||||
}
|
||||
@@ -199,7 +204,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user