internal/cli:stalled 與 xrefs 命令接線,完成 #7 CLI 層(堆疊於 #23)
- 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 <owner>/<repo> <number> (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 版一致。
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
// stalled_commands.go — 管理者巡邏命令的 CLI 接線(#7):
|
||||
// stalled [--hours N] 與 xrefs <owner>/<repo> <number>。
|
||||
//
|
||||
// 核心邏輯在 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 <owner>/<repo> <number>。
|
||||
// 編號不存在 → 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>/<repo> <number> 兩個參數"}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user