From ab94cdce9f301d4385353e195018bf2e65ed054b Mon Sep 17 00:00:00 2001 From: ceo Date: Thu, 10 Sep 2026 12:20:39 +0800 Subject: [PATCH] =?UTF-8?q?internal/cli=EF=BC=9Astalled=20=E8=88=87=20xref?= =?UTF-8?q?s=20=E5=91=BD=E4=BB=A4=E6=8E=A5=E7=B7=9A=EF=BC=8C=E5=AE=8C?= =?UTF-8?q?=E6=88=90=20#7=20CLI=20=E5=B1=A4=EF=BC=88=E5=A0=86=E7=96=8A?= =?UTF-8?q?=E6=96=BC=20#23=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workflow 新增 StalledSource 介面(Source+CurrentUser+GetIssue)與 高階入口 RunStalled/ResolveIssueXrefs(xrefs 輸出附 source 出處) - APIClient 補上 StalledSource 面向(GetIssue/TryGetIssue 等) - CLI 新增 teai stalled [--hours N](JSON 與 gitea.py 位元組相容: 停用 HTML 轉義、縮排 2)與 teai xrefs / (404 → exit 3);table 模式 stalled_hours 定點一位小數 - 接線層與 workflow 層單元測試(注入假資料源、固定時刻) - README 狀態行更新 驗證:go build/vet/test 全綠;對真實 API 並行掃描,stalled 輸出 與 gitea.py 位元組一致(2169 bytes);xrefs 邊界案例(PR#50/ issue#49 略過、CJK 緊鄰、跨倉庫 owner/repo#N)與 Python 版一致。 --- README.md | 2 +- internal/cli/stalled_commands.go | 170 +++++++++++++++++ internal/cli/stalled_commands_test.go | 227 +++++++++++++++++++++++ internal/workflow/giteaapi.go | 53 ++++++ internal/workflow/stalled_entry_test.go | 231 ++++++++++++++++++++++++ internal/workflow/workflow.go | 108 +++++++++++ 6 files changed, 790 insertions(+), 1 deletion(-) create mode 100644 internal/cli/stalled_commands.go create mode 100644 internal/cli/stalled_commands_test.go create mode 100644 internal/workflow/stalled_entry_test.go diff --git a/README.md b/README.md index 9a30ab3..9ce3462 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ teai 是 Gitea 的命令列(CLI)工具,以更完整、更可靠的方式 - **語言:** Go(與 Gitea 本體一致),僅用標準庫,離線可建置 - **目標站點:** `https://gitea.alterminal.com` -- **狀態:** 基礎建設(#5:internal/gitea)、工作流命令(#6:whoami/orgs/members/mine/pulls/next)與日常操作命令(#8:issues/pulls/labels/milestones/releases/repos/api)已實作;stalled/xrefs(#7)進行中 +- **狀態:** 基礎建設(#5:internal/gitea)、工作流命令(#6:whoami/orgs/members/mine/pulls/next)、日常操作命令(#8:issues/pulls/labels/milestones/releases/repos/api)與管理者巡邏命令(#7:stalled/xrefs)已實作 - **範圍(#3):** 不避開 tea 已有的功能;單一工具涵蓋完整操作 ## 安裝與建置 diff --git a/internal/cli/stalled_commands.go b/internal/cli/stalled_commands.go new file mode 100644 index 0000000..a2d783f --- /dev/null +++ b/internal/cli/stalled_commands.go @@ -0,0 +1,170 @@ +// stalled_commands.go — 管理者巡邏命令的 CLI 接線(#7): +// stalled [--hours N] 與 xrefs / 。 +// +// 核心邏輯在 internal/workflow(ScanStalled/ResolveIssueXrefs), +// 本檔只做旗標剖析、資料源接線與輸出格式化。輸出走得體架構的 +// Printer(--output json|table);錯誤經 cli.exitCodeFor 映射結束碼 +// (用法 2/API 3)。token 解析選序見 internal/gitea/auth.go。 +package cli + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "strconv" + "time" + + "gitea.alterminal.com/alterminal/teai/internal/gitea" + "gitea.alterminal.com/alterminal/teai/internal/workflow" +) + +func init() { + commands["stalled"] = &command{ + name: "stalled", + usage: "停滯的 open issues/PR(管理者巡邏;--hours N 門檻,預設 4)", + run: runStalled, + } + commands["xrefs"] = &command{ + name: "xrefs", + usage: "解析 issue/PR 內文與留言中的 #N/repo#N/owner/repo#N 引用", + run: runXrefs, + } +} + +// newStalledSourceFunc 建立 stalled/xrefs 的資料源;宣告為變數讓 +// 接線層測試可注入假資料源(不走網路),與 newScannerFunc 同模式。 +var newStalledSourceFunc = newStalledSource + +// newStalledSource 依全域選項建立 workflow.StalledSource(API 實作)。 +func newStalledSource(env *Env) (workflow.StalledSource, error) { + token, _, err := gitea.ResolveToken(gitea.AuthOptions{ + FlagToken: env.Globals.Token, + SiteURL: env.Globals.URL, + ConfigPath: env.Globals.ConfigPath, + }) + if err != nil { + return nil, err + } + client, err := gitea.New(env.Globals.URL, token, env.Globals.Timeout) + if err != nil { + return nil, err + } + return &workflow.APIClient{Client: client}, nil +} + +// stalledFields 是 stalled 的 table 欄位(JSON 模式輸出完整 Item, +// 欄位序與 gitea.py 一致,見 workflow.Item.MarshalJSON)。 +func stalledFields() []gitea.Field { + return []gitea.Field{ + {Name: "type"}, {Name: "repo"}, {Name: "number"}, {Name: "title"}, + {Name: "last_comment_by"}, {Name: "last_activity_at"}, + {Name: "stalled_hours"}, {Name: "reason"}, + } +} + +// stalledRow 把 Item 轉成 table 列(assignees/reviewers/child_activity +// 僅 JSON 模式輸出;table 給人工抽查用,取共同欄位)。 +// stalled_hours 定點一位小數(fmt.Sprint(9.0) 會印 "9",與 JSON 的 9.0 不一致)。 +func stalledRow(item workflow.Item) gitea.Values { + return gitea.Values{ + item.Type, item.Repo, item.Number, item.Title, + item.LastCommentBy, workflow.FormatMoment(item.LastActivityAt), + strconv.FormatFloat(item.StalledHours, 'f', 1, 64), item.Reason, + } +} + +// runStalled:teai stalled [--hours N]。 +// 預設門檻 4 小時;--hours 需為正數(同 gitea.py _parse_hours)。 +// JSON 輸出與 gitea.py stalled 位元組相容(workflow.Item 自訂 MarshalJSON; +// 這裡輸出前停用 HTML 轉義並縮排 2,對應 Python json.dumps(indent=2))。 +func runStalled(env *Env, args []string) error { + fs := flag.NewFlagSet("teai stalled", flag.ContinueOnError) + fs.SetOutput(env.Err) + hours := fs.Float64("hours", workflow.StalledDefaultHours, "停滯門檻(小時,正數)") + if err := parseFlags(fs, args); err != nil { + return err + } + if fs.NArg() != 0 { + return &ErrUsage{Msg: "stalled 不接受位置參數"} + } + if *hours <= 0 { + return &ErrUsage{Msg: "--hours 需為正數(小時),例如 --hours 4"} + } + src, err := newStalledSourceFunc(env) + if err != nil { + return err + } + items, err := workflow.RunStalled(src, *hours, nowFunc()) + if err != nil { + return err + } + p := printer(env) + if p.Format == gitea.FormatTable { + rows := make([]gitea.Values, 0, len(items)) + for _, item := range items { + rows = append(rows, stalledRow(item)) + } + return p.PrintList(stalledFields(), rows) + } + // JSON:空清單輸出 [](README:無停滯項目 → 空陣列);有項目時 + // 縮排 2 且停用 HTML 轉義,與 gitea.py 的 _dump 位元組相容。 + if len(items) == 0 { + _, err := fmt.Fprintln(env.Out, "[]") + return err + } + return printJSON(env.Out, items) +} + +// runXrefs:teai xrefs / 。 +// 編號不存在 → API 錯誤(結束碼 3);無引用 → []。 +func runXrefs(env *Env, args []string) error { + fs := flag.NewFlagSet("teai xrefs", flag.ContinueOnError) + fs.SetOutput(env.Err) + if err := parseFlags(fs, args); err != nil { + return err + } + if fs.NArg() != 2 { + return &ErrUsage{Msg: "xrefs 需要 / 兩個參數"} + } + owner, name, err := splitRepoFlag(args[0]) + if err != nil { + return err + } + number, err := strconv.ParseInt(args[1], 10, 64) + if err != nil || number <= 0 { + return &ErrUsage{Msg: fmt.Sprintf("編號需為正整數,得到 %q", args[1])} + } + src, err := newStalledSourceFunc(env) + if err != nil { + return err + } + hits, err := workflow.ResolveIssueXrefs(src, owner, name, number) + if err != nil { + return err + } + p := printer(env) + rows := make([]gitea.Values, 0, len(hits)) + for _, h := range hits { + rows = append(rows, gitea.Values{h.Owner, h.Repo, h.Number, h.Source}) + } + return p.PrintList( + []gitea.Field{ + {Name: "owner"}, {Name: "repo"}, {Name: "number"}, {Name: "source"}, + }, + rows, + ) +} + +// nowFunc 回傳目前時刻;宣告為變數讓測試可固定時刻(stalled 判定用)。 +var nowFunc = time.Now + +// printJSON 以縮排 2、停用 HTML 轉義輸出 JSON(與 gitea.py 的 +// json.dumps(ensure_ascii=False, indent=2) 位元組相容;HTML 轉義會把 +// <、>、& 變 \u003c 等,破壞相容性)。 +func printJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + return enc.Encode(v) +} diff --git a/internal/cli/stalled_commands_test.go b/internal/cli/stalled_commands_test.go new file mode 100644 index 0000000..e160d83 --- /dev/null +++ b/internal/cli/stalled_commands_test.go @@ -0,0 +1,227 @@ +// stalled_commands_test.go — stalled/xrefs CLI 接線層測試(#7): +// 注入假 StalledSource,驗證 Run→dispatch→flagset 完整路徑, +// 不走網路(與 workflow_commands_test.go 同模式)。 +package cli + +import ( + "strings" + "testing" + "time" + + "gitea.alterminal.com/alterminal/teai/internal/gitea" + "gitea.alterminal.com/alterminal/teai/internal/workflow" +) + +// stubStalledSource 以記憶體資料實作 workflow.StalledSource。 +type stubStalledSource struct { + username string + orgs []string + repos map[string][]workflow.Repo + issues map[string]map[int64]workflow.Issue + comments map[string][]workflow.Comment + getErr error +} + +func (s *stubStalledSource) CurrentUser() (string, error) { return s.username, nil } +func (s *stubStalledSource) MyOrganizations() ([]string, error) { + return s.orgs, nil +} +func (s *stubStalledSource) OrganizationRepositories(org string) ([]workflow.Repo, error) { + return s.repos[org], nil +} +func (s *stubStalledSource) OpenIssues(owner, repo string) ([]workflow.Issue, error) { + var out []workflow.Issue + for _, i := range s.issues[owner+"/"+repo] { + out = append(out, i) + } + return out, nil +} +func (s *stubStalledSource) OpenPulls(owner, repo string) ([]workflow.PullRequest, error) { + return nil, nil +} +func (s *stubStalledSource) IssueComments(owner, repo string, number int64) ([]workflow.Comment, error) { + return s.comments[owner+"/"+repo+"/"+itoa(number)], nil +} +func (s *stubStalledSource) TryGetIssue(owner, repo string, number int64) (workflow.Issue, bool, error) { + i, ok := s.issues[owner+"/"+repo][number] + return i, ok, nil +} +func (s *stubStalledSource) GetIssue(owner, repo string, number int64) (workflow.Issue, error) { + if s.getErr != nil { + return workflow.Issue{}, s.getErr + } + i, ok := s.issues[owner+"/"+repo][number] + if !ok { + return workflow.Issue{}, &gitea.ErrAPI{Op: "GET /repos/" + owner + "/" + repo + "/issues/" + itoa(number), StatusCode: 404} + } + return i, nil +} + +func itoa(n int64) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} + +// newStubStalledEnv 注入假資料源並固定時刻。 +func newStubStalledEnv(t *testing.T, src *stubStalledSource, frozen time.Time) { + t.Helper() + origSrc, origNow := newStalledSourceFunc, nowFunc + newStalledSourceFunc = func(env *Env) (workflow.StalledSource, error) { return src, nil } + nowFunc = func() time.Time { return frozen } + t.Cleanup(func() { newStalledSourceFunc, nowFunc = origSrc, origNow }) +} + +// isoAt 把相對分鐘轉成 ISO8601 字串(假資料用;錨點同 workflow 測試)。 +func isoAt(minutes int) string { + return workflow.FormatMoment(time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC).Add(time.Duration(minutes) * time.Minute)) +} + +// newStubStalledData 建構 stalled 情境:agents#1 停滯 10 小時(no-assignee)。 +func newStubStalledData() *stubStalledSource { + return &stubStalledSource{ + username: "ceo", + orgs: []string{"alterminal"}, + repos: map[string][]workflow.Repo{"alterminal": {{Name: "agents"}}}, + issues: map[string]map[int64]workflow.Issue{ + "alterminal/agents": { + 1: {Number: 1, Title: "停滯項", State: "open", + CreatedAt: isoAt(-600), UpdatedAt: isoAt(-600), + Repository: &workflow.Repository{FullName: "alterminal/agents"}}, + }, + }, + comments: map[string][]workflow.Comment{}, + } +} + +// TestWiringStalled 驗證 stalled 基本路徑:JSON 輸出含停滯欄位。 +func TestWiringStalled(t *testing.T) { + newStubStalledEnv(t, newStubStalledData(), time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) + stdout, _, code := run("stalled") + if code != 0 { + t.Fatalf("stalled 應回 0,得到 %d(stdout=%q)", code, stdout) + } + for _, want := range []string{`"type": "issue"`, `"reason": "no-assignee"`, `"stalled_hours": 10.0`} { + if !strings.Contains(stdout, want) { + t.Fatalf("stalled 輸出應含 %q,得到:\n%s", want, stdout) + } + } +} + +// TestWiringStalledHoursFlag 驗證 --hours 覆寫門檻與非正數被攔。 +func TestWiringStalledHoursFlag(t *testing.T) { + freeze := time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC) + newStubStalledEnv(t, newStubStalledData(), freeze) + // 門檻 20h → 10h 停滯未過門檻 → 空陣列。 + stdout, _, code := run("stalled", "--hours", "20") + if code != 0 { + t.Fatalf("stalled --hours 20 應回 0,得到 %d", code) + } + if strings.TrimSpace(stdout) != "[]" { + t.Fatalf("無停滯項應輸出 [],得到 %q", stdout) + } + // 非正數 → exit 2。 + _, stderr, code := run("stalled", "--hours", "-1") + if code != int(ExitUsage) { + t.Fatalf("stalled --hours -1 應回 ExitUsage(%d),得到 %d(stderr=%q)", int(ExitUsage), code, stderr) + } + // 位置參數 → exit 2。 + _, _, code = run("stalled", "extra") + if code != int(ExitUsage) { + t.Fatalf("stalled extra 應回 ExitUsage(%d),得到 %d", int(ExitUsage), code) + } +} + +// TestWiringStalledTable 驗證 table 輸出(README 範例)。 +func TestWiringStalledTable(t *testing.T) { + newStubStalledEnv(t, newStubStalledData(), time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) + stdout, _, code := run("stalled", "--output", "table") + if code != 0 { + t.Fatalf("stalled --output table 應回 0,得到 %d", code) + } + for _, want := range []string{"reason", "no-assignee", "停滯項"} { + if !strings.Contains(stdout, want) { + t.Fatalf("table 輸出應含 %q,得到:\n%s", want, stdout) + } + } +} + +// TestWiringXrefs 驗證 xrefs 解析輸出(含 body/comment 出處)。 +func TestWiringXrefs(t *testing.T) { + src := newStubStalledData() + src.issues["alterminal/agents"][1] = workflow.Issue{ + Number: 1, Title: "父項", State: "open", + Body: "子項:agents#2", + Repository: &workflow.Repository{FullName: "alterminal/agents"}, + } + src.issues["alterminal/agents"][2] = workflow.Issue{ + Number: 2, Title: "子項", State: "open", + Repository: &workflow.Repository{FullName: "alterminal/agents"}, + } + src.comments["alterminal/agents/1"] = []workflow.Comment{ + {Body: "看 agents#2", User: &workflow.User{UserName: "alex"}}, + } + newStubStalledEnv(t, src, time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) + stdout, _, code := run("xrefs", "alterminal/agents", "1") + if code != 0 { + t.Fatalf("xrefs 應回 0,得到 %d(stdout=%q)", code, stdout) + } + // Printer JSON 為單行緊湊格式(無縮排);命令清單類輸出一律如此。 + for _, want := range []string{`"repo":"agents"`, `"number":2`, `"source":"body"`} { + if !strings.Contains(stdout, want) { + t.Fatalf("xrefs 輸出應含 %q,得到:\n%s", want, stdout) + } + } +} + +// TestWiringXrefsEmpty 驗證無引用 → []。 +func TestWiringXrefsEmpty(t *testing.T) { + src := newStubStalledData() + src.issues["alterminal/agents"][1] = workflow.Issue{ + Number: 1, Title: "無引用", State: "open", + Body: "沒有任何引用", + Repository: &workflow.Repository{FullName: "alterminal/agents"}, + } + newStubStalledEnv(t, src, time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) + stdout, _, code := run("xrefs", "alterminal/agents", "1") + if code != 0 { + t.Fatalf("xrefs 應回 0,得到 %d", code) + } + if strings.TrimSpace(stdout) != "[]" { + t.Fatalf("無引用應輸出 [],得到 %q", stdout) + } +} + +// TestWiringXrefsUsage 驗證參數不足/格式錯誤 → exit 2。 +func TestWiringXrefsUsage(t *testing.T) { + newStubStalledEnv(t, newStubStalledData(), time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) + if _, _, code := run("xrefs"); code != int(ExitUsage) { + t.Fatalf("xrefs 無參數應回 ExitUsage,得到 %d", code) + } + if _, _, code := run("xrefs", "alterminal/agents"); code != int(ExitUsage) { + t.Fatalf("xrefs 缺編號應回 ExitUsage,得到 %d", code) + } + if _, _, code := run("xrefs", "not-a-repo", "1"); code != int(ExitUsage) { + t.Fatalf("xrefs repo 格式錯誤應回 ExitUsage,得到 %d", code) + } + if _, _, code := run("xrefs", "alterminal/agents", "abc"); code != int(ExitUsage) { + t.Fatalf("xrefs 編號非數字應回 ExitUsage,得到 %d", code) + } +} + +// TestWiringXrefsNotFound 驗證 404 → exit 3(API 錯誤)。 +func TestWiringXrefsNotFound(t *testing.T) { + src := newStubStalledData() + src.getErr = &gitea.ErrAPI{Op: "GET /repos/alterminal/agents/issues/99", StatusCode: 404} + newStubStalledEnv(t, src, time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) + _, stderr, code := run("xrefs", "alterminal/agents", "99") + if code != int(ExitAPI) { + t.Fatalf("xrefs 404 應回 ExitAPI(%d),得到 %d(stderr=%q)", int(ExitAPI), code, stderr) + } +} diff --git a/internal/workflow/giteaapi.go b/internal/workflow/giteaapi.go index b9beab7..1d187d5 100644 --- a/internal/workflow/giteaapi.go +++ b/internal/workflow/giteaapi.go @@ -122,3 +122,56 @@ func (a *APIClient) ListComments(owner, repo string, number int64) ([]Comment, e } return comments, nil } + +// ── StalledSource 面向(#7 CLI 接線)──────────────────────────────────── +// APIClient 在 Client 之外同時實作 StalledSource(Source + CurrentUser + +// GetIssue),讓 stalled/xrefs 命令與既有命令共用同一個 HTTP 用戶端。 +// 清單類方法直接委派 Client 同名操作,語義一致(type=issues、state=open)。 + +// GetIssue 單一 issue 查詢(PR 亦經此端點,帶 pull_request 標記); +// 失敗(含 404)回 *gitea.ErrAPI,由 CLI 映射結束碼 3。 +func (a *APIClient) GetIssue(owner, repo string, number int64) (Issue, error) { + var issue Issue + path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + + "/issues/" + strconv.FormatInt(number, 10) + if err := a.Client.GetJSON(context.Background(), path, &issue); err != nil { + return Issue{}, err + } + return issue, nil +} + +// TryGetIssue 寬容版單一 issue 查詢:失敗(含 404)回 (零值, false, nil), +// 供 stalled 父追蹤項掃描靜默略過不存在的子項(Source 介面契約)。 +func (a *APIClient) TryGetIssue(owner, repo string, number int64) (Issue, bool, error) { + issue, err := a.GetIssue(owner, repo, number) + if err != nil { + return Issue{}, false, nil + } + return issue, true, nil +} + +// MyOrganizations 委派 ListOrgs(Source 面向)。 +func (a *APIClient) MyOrganizations() ([]string, error) { return a.ListOrgs() } + +// OrganizationRepositories 委派 ListOrgRepos(Source 面向)。 +func (a *APIClient) OrganizationRepositories(org string) ([]Repo, error) { + return a.ListOrgRepos(org) +} + +// OpenIssues 委派 ListIssues(Source 面向;已排除 PR)。 +func (a *APIClient) OpenIssues(owner, repo string) ([]Issue, error) { + return a.ListIssues(owner, repo) +} + +// OpenPulls 委派 ListPulls(Source 面向)。 +func (a *APIClient) OpenPulls(owner, repo string) ([]PullRequest, error) { + return a.ListPulls(owner, repo) +} + +// IssueComments 委派 ListComments(Source 面向)。 +func (a *APIClient) IssueComments(owner, repo string, number int64) ([]Comment, error) { + return a.ListComments(owner, repo, number) +} + +// 編譯期確認 APIClient 同時實作 Client 與 StalledSource。 +var _ StalledSource = (*APIClient)(nil) diff --git a/internal/workflow/stalled_entry_test.go b/internal/workflow/stalled_entry_test.go new file mode 100644 index 0000000..98c09e2 --- /dev/null +++ b/internal/workflow/stalled_entry_test.go @@ -0,0 +1,231 @@ +// stalled_entry_test.go — RunStalled/ResolveIssueXrefs 高階入口的單元測試 +//(#7 CLI 接線;資料源注入假實作,離線可測)。 +package workflow + +import ( + "strings" + "testing" + "time" +) + +// fakeStalledSource 包 fakeSource 補上 StalledSource 面向。 +type fakeStalledSource struct { + *fakeSource + username string + // getIssueErr 模擬 GetIssue 失敗(xrefs 的 404 路徑)。 + getIssueErr error +} + +func (f *fakeStalledSource) CurrentUser() (string, error) { return f.username, nil } + +func (f *fakeStalledSource) GetIssue(owner, repo string, number int64) (Issue, error) { + if f.getIssueErr != nil { + return Issue{}, f.getIssueErr + } + i, ok := f.issues[owner+"/"+repo][number] + if !ok { + return Issue{}, ¬FoundError{} + } + return i, nil +} + +type notFoundError struct{} + +func (e *notFoundError) Error() string { return "404" } + +// newStalledFixture 建構掃描情境:alterminal 組織兩個倉庫, +// agents#1 是父追蹤項(引用 teai#2),teai#2 有較新留言。 +func newStalledFixture() *fakeStalledSource { + return &fakeStalledSource{ + fakeSource: &fakeSource{ + orgs: []string{"alterminal"}, + repos: map[string][]Repo{"alterminal": { + {Name: "agents", HasPullRequests: boolPtr(true)}, + {Name: "teai", HasPullRequests: boolPtr(true)}, + }}, + issues: map[string]map[int64]Issue{ + "alterminal/agents": { + 1: {Number: 1, Title: "父追蹤項", State: "open", + Body: "子項:teai#2", CreatedAt: iso(-600), UpdatedAt: iso(-600), + Repository: &Repository{FullName: "alterminal/agents"}}, + }, + "alterminal/teai": { + 2: {Number: 2, Title: "子項", State: "open", + CreatedAt: iso(-500), UpdatedAt: iso(-90), + Repository: &Repository{FullName: "alterminal/teai"}}, + }, + }, + pulls: map[string][]PullRequest{}, + comments: map[string][]Comment{ + "alterminal/teai/2": { + {CreatedAt: iso(-90), User: &User{UserName: "alex"}, Body: "推進中"}, + }, + }, + }, + username: "ceo", + } +} + +// TestRunStalledParentTracking 驗證 RunStalled:父追蹤項把子項活動 +// 計入 last_activity(reason=parent-tracking),且門檻預設與覆寫皆可。 +func TestRunStalledParentTracking(t *testing.T) { + src := newStalledFixture() + // 預設 4 小時門檻:子項 90 分鐘前有活動 → 父項 last_activity 同步, + // 未過門檻,清單為空。 + items, err := RunStalled(src, 0, at(0)) + if err != nil { + t.Fatalf("RunStalled: %v", err) + } + if len(items) != 0 { + t.Fatalf("預設門檻下不應有停滯項,得到 %d 筆", len(items)) + } + // 門檻降到 1 小時:父追蹤項 last_activity 取子項活動(-90m)→ + // 停滯 1.5h 列入(reason=parent-tracking、附 child_activity); + // 子項 teai#2 本身也停滯 1.5h(no-assignee)。 + items, err = RunStalled(src, 1, at(0)) + if err != nil { + t.Fatalf("RunStalled: %v", err) + } + if len(items) != 2 { + t.Fatalf("應有兩筆(父追蹤項+子項),得到 %d:%+v", len(items), items) + } + var parent *Item + for i := range items { + if items[i].Number == 1 { + parent = &items[i] + } + } + if parent == nil { + t.Fatalf("找不到 agents#1:%+v", items) + } + if parent.Reason != "parent-tracking" || parent.Repo != "alterminal/agents" { + t.Fatalf("agents#1 應為 parent-tracking,得到 %s(%s)", parent.Reason, parent.Repo) + } + if len(parent.ChildActivity) == 0 { + t.Fatalf("parent-tracking 應附 child_activity") + } + if parent.StalledHours != 1.5 { + t.Fatalf("last_activity 應取子項活動(-90m → 1.5h),得到 %.1f", parent.StalledHours) + } +} + +// TestRunStalledNudgeCooldown 驗證催促冷卻:最後留言出自目前帳號 +// 且在 24 小時內 → 不列入;冷卻過後再出現。 +func TestRunStalledNudgeCooldown(t *testing.T) { + src := newStalledFixture() + setIssueBody(src, "alterminal", "agents", 1, "") // 移除子項引用 + src.comments["alterminal/agents/1"] = []Comment{ + {CreatedAt: iso(-2), User: &User{UserName: "ceo"}}, + } + findOne := func(items []Item) bool { + for _, it := range items { + if it.Repo == "alterminal/agents" && it.Number == 1 { + return true + } + } + return false + } + items, err := RunStalled(src, 1, at(0)) + if err != nil { + t.Fatalf("RunStalled: %v", err) + } + if findOne(items) { + t.Fatalf("催促冷卻期內(2 小時前留言)agents#1 不應列入,得到 %+v", items) + } + src.comments["alterminal/agents/1"] = []Comment{ + {CreatedAt: iso(-30 * 60), User: &User{UserName: "ceo"}}, + } + items, err = RunStalled(src, 1, at(0)) + if err != nil { + t.Fatalf("RunStalled: %v", err) + } + if !findOne(items) { + t.Fatalf("冷卻(24h)過後 agents#1 應再出現,得到 %+v", items) + } +} + +// TestResolveIssueXrefs 驗證 xrefs 入口:內文與留言的引用解析、 +// 去重、出處標記與單段 repo 的存在性邊界。 +func TestResolveIssueXrefs(t *testing.T) { + src := newStalledFixture() + setIssueBody(src, "alterminal", "agents", 1, "看 teai#2 與 #7,另見 alterminal/teai#2") + src.comments["alterminal/agents/1"] = []Comment{ + {Body: "teai#2 已處理", User: &User{UserName: "alex"}, CreatedAt: iso(-10)}, + {Body: "nope#9 不存在", User: &User{UserName: "max"}, CreatedAt: iso(-5)}, + } + // fixture 的組織倉庫只有 agents → teai#2 單段不解析(repo 不在 + // alterminal 組織清單);補上 teai 倉庫讓單段解析成立。 + src.repos["alterminal"] = []Repo{ + {Name: "agents", HasPullRequests: boolPtr(true)}, + {Name: "teai", HasPullRequests: boolPtr(true)}, + } + hits, err := ResolveIssueXrefs(src, "alterminal", "agents", 1) + if err != nil { + t.Fatalf("ResolveIssueXrefs: %v", err) + } + want := []XrefHit{ + {Owner: "alterminal", Repo: "teai", Number: 2, Source: "body"}, + {Owner: "alterminal", Repo: "agents", Number: 7, Source: "body"}, + } + if len(hits) != len(want) { + t.Fatalf("應解析出 %d 筆(留言重複的 teai#2 去重、nope#9 略過),得到 %d:%+v", + len(want), len(hits), hits) + } + for i := range want { + if hits[i] != want[i] { + t.Fatalf("hits[%d] = %+v, want %+v", i, hits[i], want[i]) + } + } +} + +// TestResolveIssueXrefsCommentSource 驗證出處:僅出現在留言的引用 +// 標記為 comment。 +func TestResolveIssueXrefsCommentSource(t *testing.T) { + src := newStalledFixture() + setIssueBody(src, "alterminal", "agents", 1, "內文無引用") + src.comments["alterminal/agents/1"] = []Comment{ + {Body: "追蹤 #9", User: &User{UserName: "alex"}, CreatedAt: iso(-10)}, + } + hits, err := ResolveIssueXrefs(src, "alterminal", "agents", 1) + if err != nil { + t.Fatalf("ResolveIssueXrefs: %v", err) + } + if len(hits) != 1 || hits[0].Source != "comment" || hits[0].Number != 9 { + t.Fatalf("應輸出一筆 source=comment 的 #9,得到 %+v", hits) + } +} + +// TestResolveIssueXrefsGetError 驗證 GetIssue 失敗(如 404)層層回傳, +// 由 CLI 映射結束碼。 +func TestResolveIssueXrefsGetError(t *testing.T) { + src := newStalledFixture() + src.getIssueErr = ¬FoundError{} + if _, err := ResolveIssueXrefs(src, "alterminal", "agents", 1); err == nil { + t.Fatalf("GetIssue 失敗應回傳錯誤") + } +} + +func boolPtr(b bool) *bool { return &b } + +// setIssueBody 修改 map 內 issue 的內文(map 元素不可定址,需整體重塞)。 +func setIssueBody(src *fakeStalledSource, owner, repo string, number int64, body string) { + key := owner + "/" + repo + issue := src.issues[key][number] + issue.Body = body + src.issues[key][number] = issue +} + +// TestResolveIssueXrefsEmptyBody 驗證無引用時輸出空(CLI 印 [])。 +func TestResolveIssueXrefsEmptyBody(t *testing.T) { + src := newStalledFixture() + setIssueBody(src, "alterminal", "agents", 1, strings.Repeat("無引用內文", 3)) + delete(src.comments, "alterminal/agents/1") + hits, err := ResolveIssueXrefs(src, "alterminal", "agents", 1) + if err != nil { + t.Fatalf("ResolveIssueXrefs: %v", err) + } + if len(hits) != 0 { + t.Fatalf("無引用應輸出空清單,得到 %+v", hits) + } + _ = time.Now // time 僅供 iso/at 使用;保留 import。 +} diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index 0af8180..3c904cf 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -526,3 +526,111 @@ func round1(v float64) float64 { } return r } + +// ── CLI 接線用的高階入口(#7:teai stalled/teai xrefs)───────────────── + +// StalledSource 是 stalled/xrefs 命令需要的完整資料源: +// Source(掃描用讀取)加上目前帳號與單一 issue 查詢。 +// APIClient(giteaapi.go)實作此介面;測試以假資料源注入。 +type StalledSource interface { + Source + // CurrentUser 回傳目前帳號名(催促冷卻判定用)。 + CurrentUser() (string, error) + // GetIssue 嚴格版單一 issue/PR 查詢;失敗回錯誤(xrefs 命令用, + // 404 應回 ErrAPI 由 CLI 映射結束碼 3)。 + GetIssue(owner, repo string, number int64) (Issue, error) +} + +// RunStalled 是 teai stalled 的核心:解析目前帳號後跑 ScanStalled。 +// hours <= 0 時採 StalledDefaultHours(對應 gitea.py stalled [--hours N] +// 的預設 4 小時);旗標層已先攔下非正數,這裡是防禦。 +func RunStalled(src StalledSource, hours float64, now time.Time) ([]Item, error) { + if hours <= 0 { + hours = StalledDefaultHours + } + username, err := src.CurrentUser() + if err != nil { + return nil, err + } + return ScanStalled(src, username, hours, now) +} + +// XrefHit 是 teai xrefs 的單筆輸出:解析後的目標加上出處 +// (README 規格:"body"=內文、"comment"=留言)。 +type XrefHit struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int64 `json:"number"` + Source string `json:"source"` +} + +// ResolveIssueXrefs 是 teai xrefs 的核心:取回單一 issue/PR 的內文與 +// 全部留言,解析其中的 #N、repo#N、owner/repo#N 引用(規則同 +// ResolveXrefTargets:單段 repo 僅在父項所屬組織有該倉庫時解析、 +// owner/repo#N 僅在 owner 為所屬組織時解析),去重並保留出現順序, +// 每筆附首次出現的出處(內文先於留言)。stalled 的父追蹤項判定 +// 內部用同一套解析,不重複實作。 +func ResolveIssueXrefs(src StalledSource, owner, repo string, number int64) ([]XrefHit, error) { + issue, err := src.GetIssue(owner, repo, number) + if err != nil { + return nil, err + } + comments, err := src.IssueComments(owner, repo, number) + if err != nil { + return nil, err + } + orgs, err := src.MyOrganizations() + if err != nil { + return nil, err + } + myOrgs := make(map[string]bool, len(orgs)) + for _, o := range orgs { + myOrgs[o] = true + } + // 組織倉庫清單跨呼叫快取,避免逐則留言重打 API。 + orgRepoCache := map[string][]string{} + orgRepos := func(org string) ([]string, error) { + if names, ok := orgRepoCache[org]; ok { + return names, nil + } + repos, err := src.OrganizationRepositories(org) + if err != nil { + return nil, err + } + names := make([]string, 0, len(repos)) + for _, r := range repos { + if r.Name != "" { + names = append(names, r.Name) + } + } + orgRepoCache[org] = names + return names, nil + } + type sourceText struct { + text string + source string + } + texts := []sourceText{{issue.Body, "body"}} + for _, c := range comments { + texts = append(texts, sourceText{c.Body, "comment"}) + } + var hits []XrefHit + seen := map[XrefTarget]bool{} + for _, t := range texts { + targets, err := ResolveXrefTargets(owner, repo, []string{t.text}, myOrgs, orgRepos) + if err != nil { + return nil, err + } + for _, target := range targets { + if seen[target] { + continue + } + seen[target] = true + hits = append(hits, XrefHit{ + Owner: target.Owner, Repo: target.Repo, + Number: target.Number, Source: t.source, + }) + } + } + return hits, nil +}