// 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) }