- 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 版一致。
171 lines
5.6 KiB
Go
171 lines
5.6 KiB
Go
// 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)
|
||
}
|