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:
@@ -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