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:
2026-09-10 21:20:19 +08:00
parent fd3b8cbd6b
commit 744e7ad809
7 changed files with 916 additions and 8 deletions
+188
View File
@@ -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)
}
}