milestones:新增跨倉庫里程碑總覽命令(#31)
- teai milestones(不帶 --repo)輸出跨所屬組織各倉庫 open 里程碑概況, 對應 gitea.py milestones(管理者巡邏,agents AGENTS.md 10.1): 欄位 repo/id/title/url/open_issues/closed_issues/due_on/due_hours/ overdue/unassigned/updated_at,排序期限近者在前、無期限最後。 - internal/workflow.MilestoneOverview:判定核心(Source 注入、離線可測); MilestoneEntry 自訂 MarshalJSON,鍵集鍵序與 gitea.py 全等 (null 表示、due_hours 定點一位、unassigned PR 條目帶 author)。 - internal/workflow.APIClient 增 OpenMilestones/MilestoneOpenIssues (issues 端點刻意不帶 type=issues,PR 條目保留供覆蓋檢查,同 gitea.py)。 - milestones --repo a/b 維持現行單倉庫 list 語義不變。 - 空清單輸出 [](gitea.py 無輸出;#26 慣例差異,README 已文件化)。 - 交叉驗證:同帳號同時跑 gitea.py milestones 與 teai milestones, 含逾期/未到期/無期限里程碑與未分派 issue/PR 條目,輸出位元組全等。
This commit is contained in:
@@ -26,7 +26,7 @@ func init() {
|
||||
register(&command{name: "issues", usage: "issues 管理(list/view/create/close/comment)", run: runIssues})
|
||||
register(&command{name: "pulls", usage: "PR 管理(list/view/create/merge/comment;無子命令=工作流清單)", run: runPullsDaily})
|
||||
register(&command{name: "labels", usage: "標籤管理(list/create)", run: runLabels})
|
||||
register(&command{name: "milestones", usage: "里程碑管理(list/create)", run: runMilestones})
|
||||
register(&command{name: "milestones", usage: "里程碑(跨倉庫總覽;list/create 管理單倉庫)", run: runMilestones})
|
||||
register(&command{name: "releases", usage: "發佈清單(list)", run: runReleases})
|
||||
register(&command{name: "repos", usage: "倉庫查詢(list)", run: runRepos})
|
||||
register(&command{name: "api", usage: "任意 API 呼叫(--method/--data;寫入動詞需 --yes)", run: runAPIDirect})
|
||||
@@ -650,10 +650,12 @@ func runLabelsCreate(env *Env, args []string) error {
|
||||
|
||||
// ---- milestones ----
|
||||
|
||||
// runMilestones:teai milestones list|create …
|
||||
// runMilestones:teai milestones [list|create] …;不帶子命令時輸出
|
||||
// 跨倉庫里程碑總覽(#31,對應 gitea.py milestones)。帶 --repo 直接
|
||||
// 跟在 milestones 之後時維持現行單倉庫 list 語義。
|
||||
func runMilestones(env *Env, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return &ErrUsage{Msg: "milestones 需要子命令:list | create"}
|
||||
return runMilestonesOverview(env)
|
||||
}
|
||||
sub, rest := args[0], args[1:]
|
||||
switch sub {
|
||||
@@ -662,7 +664,32 @@ func runMilestones(env *Env, args []string) error {
|
||||
case "create":
|
||||
return runMilestonesCreate(env, rest)
|
||||
default:
|
||||
return &ErrUsage{Msg: fmt.Sprintf("milestones 未知子命令 %q(可用:list | create)", sub)}
|
||||
if !strings.HasPrefix(sub, "-") {
|
||||
return &ErrUsage{Msg: fmt.Sprintf("milestones 未知子命令 %q(可用:list | create;或不帶參數輸出跨倉庫總覽)", sub)}
|
||||
}
|
||||
// milestones 之後直接跟旗標(非子命令):帶 --repo → 整串轉發,
|
||||
// 維持現行單倉庫 list 語義(--state 等旗標一併生效);否則為
|
||||
// 跨倉庫總覽(總覽不接受任何命令旗標,空 FlagSet 讓未知旗標
|
||||
// 報用法錯誤)。
|
||||
hasRepo := false
|
||||
for _, a := range args {
|
||||
if a == "--repo" || strings.HasPrefix(a, "--repo=") {
|
||||
hasRepo = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasRepo {
|
||||
return runMilestonesList(env, args)
|
||||
}
|
||||
fs := flag.NewFlagSet("teai milestones", flag.ContinueOnError)
|
||||
fs.SetOutput(env.Err)
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return &ErrUsage{Msg: "milestones 不接受位置參數(子命令:list | create)"}
|
||||
}
|
||||
return runMilestonesOverview(env)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// milestones_commands.go — 跨倉庫里程碑總覽命令的 CLI 接線(#31):
|
||||
// teai milestones(不帶 --repo)。
|
||||
//
|
||||
// 核心邏輯在 internal/workflow(MilestoneOverview),本檔只做旗標
|
||||
// 剖析、資料源接線與輸出格式化(與 stalled_commands.go 同模式)。
|
||||
// 輸出與 gitea.py milestones 位元組相容(MilestoneEntry 自訂
|
||||
// MarshalJSON;縮排 2、停用 HTML 轉義、空清單輸出 [],見 #26 慣例)。
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.alterminal.com/alterminal/teai/internal/gitea"
|
||||
"gitea.alterminal.com/alterminal/teai/internal/workflow"
|
||||
)
|
||||
|
||||
// newMilestoneSourceFunc 建立總覽的資料源;宣告為變數讓接線層測試
|
||||
// 可注入假資料源(與 newStalledSourceFunc 同模式)。
|
||||
var newMilestoneSourceFunc = newMilestoneSource
|
||||
|
||||
// newMilestoneSource 依全域選項建立 workflow.MilestoneSource(API 實作)。
|
||||
func newMilestoneSource(env *Env) (workflow.MilestoneSource, 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
|
||||
}
|
||||
|
||||
// milestonesNowFunc 回傳總覽的基準時刻;宣告為變數讓測試可固定時刻。
|
||||
var milestonesNowFunc = time.Now
|
||||
|
||||
// milestoneEntryRow 把 MilestoneEntry 轉成 table 列(unassigned 僅 JSON
|
||||
// 模式輸出完整摘要;table 給人工抽查用,取計數)。
|
||||
func milestoneEntryRow(e workflow.MilestoneEntry) gitea.Values {
|
||||
dueOn := ""
|
||||
if e.DueOn != nil {
|
||||
dueOn = *e.DueOn
|
||||
}
|
||||
dueHours := ""
|
||||
if e.DueHours != nil {
|
||||
dueHours = fmt.Sprintf("%.1f", *e.DueHours)
|
||||
}
|
||||
updatedAt := ""
|
||||
if e.UpdatedAt != nil {
|
||||
updatedAt = *e.UpdatedAt
|
||||
}
|
||||
id := ""
|
||||
if e.ID != nil {
|
||||
id = fmt.Sprintf("%d", *e.ID)
|
||||
}
|
||||
return gitea.Values{
|
||||
e.Repo, id, e.Title, e.URL,
|
||||
e.OpenIssues, e.ClosedIssues, dueOn, dueHours,
|
||||
e.Overdue, len(e.Unassigned), updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// runMilestonesOverview:teai milestones(跨倉庫總覽)。
|
||||
// JSON 輸出與 gitea.py milestones 位元組相容;空清單輸出 [](#26 慣例:
|
||||
// gitea.py 對空清單不輸出,teai 輸出 [] 供 JSON 消費者明確判斷)。
|
||||
func runMilestonesOverview(env *Env) error {
|
||||
src, err := newMilestoneSourceFunc(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := workflow.MilestoneOverview(src, env.Globals.URL, milestonesNowFunc())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := printer(env)
|
||||
if p.Format == gitea.FormatTable {
|
||||
rows := make([]gitea.Values, 0, len(entries))
|
||||
for i := range entries {
|
||||
rows = append(rows, milestoneEntryRow(entries[i]))
|
||||
}
|
||||
return p.PrintList([]gitea.Field{
|
||||
{Name: "repo"}, {Name: "id"}, {Name: "title"}, {Name: "url"},
|
||||
{Name: "open_issues"}, {Name: "closed_issues"}, {Name: "due_on"},
|
||||
{Name: "due_hours"}, {Name: "overdue"}, {Name: "unassigned"}, {Name: "updated_at"},
|
||||
}, rows)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
_, err := fmt.Fprintln(env.Out, "[]")
|
||||
return err
|
||||
}
|
||||
return printJSON(env.Out, entries)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// milestones_commands_test.go — 跨倉庫里程碑總覽的 CLI 接線層測試(#31):
|
||||
// 注入假 MilestoneSource,驗證 Run→dispatch→輸出完整路徑,不走網路
|
||||
// (與 stalled_commands_test.go 同模式)。
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.alterminal.com/alterminal/teai/internal/workflow"
|
||||
)
|
||||
|
||||
// stubMilestoneSource 以記憶體資料實作 workflow.MilestoneSource。
|
||||
type stubMilestoneSource struct {
|
||||
orgs []string
|
||||
repos map[string][]workflow.Repo
|
||||
milestones map[string][]workflow.Milestone
|
||||
openIssues map[string][]workflow.Issue // key: owner/repo?milestone=<title>
|
||||
}
|
||||
|
||||
func (s *stubMilestoneSource) MyOrganizations() ([]string, error) { return s.orgs, nil }
|
||||
func (s *stubMilestoneSource) OrganizationRepositories(org string) ([]workflow.Repo, error) {
|
||||
return s.repos[org], nil
|
||||
}
|
||||
func (s *stubMilestoneSource) OpenMilestones(owner, repo string) ([]workflow.Milestone, error) {
|
||||
return s.milestones[owner+"/"+repo], nil
|
||||
}
|
||||
func (s *stubMilestoneSource) MilestoneOpenIssues(owner, repo, title string) ([]workflow.Issue, error) {
|
||||
return s.openIssues[owner+"/"+repo+"?milestone="+title], nil
|
||||
}
|
||||
|
||||
// newStubMilestoneEnv 注入假資料源並固定時刻。
|
||||
func newStubMilestoneEnv(t *testing.T, src *stubMilestoneSource, frozen time.Time) {
|
||||
t.Helper()
|
||||
origSrc, origNow := newMilestoneSourceFunc, milestonesNowFunc
|
||||
newMilestoneSourceFunc = func(env *Env) (workflow.MilestoneSource, error) { return src, nil }
|
||||
milestonesNowFunc = func() time.Time { return frozen }
|
||||
t.Cleanup(func() {
|
||||
newMilestoneSourceFunc, milestonesNowFunc = origSrc, origNow
|
||||
})
|
||||
}
|
||||
|
||||
// stubPullIssue 以 JSON 建構帶 pull_request 標記的條目(pullMarker 未匯出,
|
||||
// 測試從套件外以 API 回應的形狀建構,走 Issue.UnmarshalJSON 同一路徑)。
|
||||
func stubPullIssue(t *testing.T) workflow.Issue {
|
||||
t.Helper()
|
||||
var issue workflow.Issue
|
||||
err := json.Unmarshal([]byte(`{
|
||||
"number": 32, "title": "文件", "state": "open",
|
||||
"updated_at": "2026-09-08T00:00:00Z",
|
||||
"html_url": "https://gitea.alterminal.com/alterminal/agents/pulls/32",
|
||||
"user": {"username": "alex"},
|
||||
"pull_request": {}
|
||||
}`), &issue)
|
||||
if err != nil {
|
||||
t.Fatalf("建構 stub PR 條目失敗:%v", err)
|
||||
}
|
||||
return issue
|
||||
}
|
||||
|
||||
// idPtr/msPtr 是測試用指標 helper(workflow 套件內同名函式未匯出)。
|
||||
func idPtr(i int64) *int64 { return &i }
|
||||
func msPtr(s string) *string { return &s }
|
||||
|
||||
// newStubMilestoneData 建構總覽情境:兩倉庫各一里程碑、一個未分派 PR。
|
||||
func newStubMilestoneData(t *testing.T) *stubMilestoneSource {
|
||||
due := "2026-09-09T00:00:00Z"
|
||||
return &stubMilestoneSource{
|
||||
orgs: []string{"alterminal"},
|
||||
repos: map[string][]workflow.Repo{"alterminal": {
|
||||
{Name: "agents"}, {Name: "teai"},
|
||||
}},
|
||||
milestones: map[string][]workflow.Milestone{
|
||||
"alterminal/agents": {
|
||||
{ID: idPtr(7), Title: "MVP", State: "open",
|
||||
DueOn: &due, UpdatedAt: "2026-09-01T00:00:00Z",
|
||||
OpenIssues: 1, ClosedIssues: 0},
|
||||
},
|
||||
"alterminal/teai": {
|
||||
{ID: idPtr(3), Title: "CLI", State: "open",
|
||||
DueOn: msPtr("2026-09-12T00:00:00Z"), UpdatedAt: "2026-09-05T00:00:00Z",
|
||||
OpenIssues: 1, ClosedIssues: 2},
|
||||
},
|
||||
},
|
||||
openIssues: map[string][]workflow.Issue{
|
||||
"alterminal/agents?milestone=MVP": {stubPullIssue(t)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestWiringMilestonesOverview 驗證 teai milestones 輸出跨倉庫總覽
|
||||
// (JSON 縮排 2、鍵序與欄位齊 gitea.py)。
|
||||
func TestWiringMilestonesOverview(t *testing.T) {
|
||||
newStubMilestoneEnv(t, newStubMilestoneData(t), time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC))
|
||||
stdout, _, code := run("milestones")
|
||||
if code != 0 {
|
||||
t.Fatalf("milestones 應回 0,得到 %d(stdout=%q)", code, stdout)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"repo": "alterminal/agents"`,
|
||||
`"title": "MVP"`,
|
||||
`"overdue": true`,
|
||||
`"type": "pull"`,
|
||||
`"author": "alex"`,
|
||||
} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Fatalf("milestones 輸出應含 %q,得到:\n%s", want, stdout)
|
||||
}
|
||||
}
|
||||
// 排序:agents MVP(09-09)在 teai CLI(09-12)前。
|
||||
if strings.Index(stdout, `"title": "MVP"`) > strings.Index(stdout, `"title": "CLI"`) {
|
||||
t.Fatalf("期限近者應在前:\n%s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWiringMilestonesEmpty 驗證無里程碑 → [](#26 慣例)。
|
||||
func TestWiringMilestonesEmpty(t *testing.T) {
|
||||
newStubMilestoneEnv(t, &stubMilestoneSource{
|
||||
orgs: []string{"alterminal"},
|
||||
repos: map[string][]workflow.Repo{"alterminal": {{Name: "agents"}}},
|
||||
milestones: map[string][]workflow.Milestone{},
|
||||
openIssues: map[string][]workflow.Issue{},
|
||||
}, time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC))
|
||||
stdout, _, code := run("milestones")
|
||||
if code != 0 {
|
||||
t.Fatalf("milestones 空清單應回 0,得到 %d", code)
|
||||
}
|
||||
if strings.TrimSpace(stdout) != "[]" {
|
||||
t.Fatalf("無里程碑應輸出 [],得到 %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWiringMilestonesTable 驗證 table 輸出(人工抽查用)。
|
||||
func TestWiringMilestonesTable(t *testing.T) {
|
||||
newStubMilestoneEnv(t, newStubMilestoneData(t), time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC))
|
||||
stdout, _, code := run("milestones", "--output", "table")
|
||||
if code != 0 {
|
||||
t.Fatalf("milestones --output table 應回 0,得到 %d", code)
|
||||
}
|
||||
for _, want := range []string{"repo", "overdue", "MVP", "alterminal/agents"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Fatalf("table 輸出應含 %q,得到:\n%s", want, stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWiringMilestonesRepoFlagCompat 驗證 teai milestones --repo a/b
|
||||
// 維持現行單倉庫 list 語義(#31:帶 --repo 時不變)。
|
||||
func TestWiringMilestonesRepoFlagCompat(t *testing.T) {
|
||||
var gotPath, gotQuery string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotQuery = r.URL.Path, r.URL.RawQuery
|
||||
w.Write([]byte(`[{"title":"v1","state":"open","description":"","due_on":""}]`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
stdout, _, code := run("--url", srv.URL, "milestones", "--repo", "a/b")
|
||||
if code != 0 {
|
||||
t.Fatalf("milestones --repo a/b 應回 0,得到 %d", code)
|
||||
}
|
||||
if gotPath != "/api/v1/repos/a/b/milestones" {
|
||||
t.Fatalf("應呼叫 /repos/a/b/milestones,得到 %s", gotPath)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "state=open") {
|
||||
t.Fatalf("查詢應含 state=open,得到 %q", gotQuery)
|
||||
}
|
||||
if !strings.Contains(stdout, `"title":"v1"`) {
|
||||
t.Fatalf("輸出應含 title v1,得到:%s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWiringMilestonesUsage 驗證未知子命令與位置參數 → exit 2。
|
||||
func TestWiringMilestonesUsage(t *testing.T) {
|
||||
newStubMilestoneEnv(t, newStubMilestoneData(t), time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC))
|
||||
if _, _, code := run("milestones", "bogus"); code != int(ExitUsage) {
|
||||
t.Fatalf("milestones bogus 應回 ExitUsage,得到 %d", code)
|
||||
}
|
||||
if _, _, code := run("milestones", "extra", "arg"); code != int(ExitUsage) {
|
||||
t.Fatalf("milestones 位置參數應回 ExitUsage,得到 %d", code)
|
||||
}
|
||||
// milestones list 仍照舊要求 --repo。
|
||||
if _, _, code := run("milestones", "list"); code != int(ExitUsage) {
|
||||
t.Fatalf("milestones list 缺 --repo 應回 ExitUsage,得到 %d", code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user