internal/workflow:統一型別宣告,修復 #12 合併後 main 編譯失敗(#21)

#12 的 workflow.go 與 #13 的 types.go 各自宣告 User/Comment/Issue/
PullRequest/Repository,文字合併無衝突但語意衝突,main 自 a8c1f3e 起
go build 失敗。

統一採 #13 的字串模型(types.go 為唯一型別來源):
- Issue/Comment 補 State/Body/ClosedAt 欄位,新增 parseMoment
  (對應 gitea.py _parse_iso8601;保留原時區偏移,輸出與 Python
  isoformat 位元組一致)
- workflow.go 移除重複宣告,stalled/xref 邏輯改用該模型與 judge.go
  既有輔助(AssigneeNames/ReviewerNames/LastComment/containsString)
- Source 介面 number 改 int64(與 types.go 對齊),cmd/xcheck 同步
- workflow_test.go 測試資料改新型別模型(字串時刻、指標欄位)

驗證:
- go build/go vet/go test ./... 全綠(#6 與 #7 案例皆未回歸)
- xcheck 對真實 API 並行掃描,stalled 輸出與 gitea.py 位元組一致(含
  bear-cli#9 父追蹤項子項摘要、+08:00 時區偏移)
- xref 邊界案例(nope#9/PR#50 略過、bear-cli#9 內嵌解析)與 Python 一致
This commit is contained in:
ceo
2026-09-10 11:32:40 +08:00
parent 0b0c3cefcb
commit 4582dfaa4e
4 changed files with 147 additions and 218 deletions
+58 -55
View File
@@ -21,9 +21,9 @@ func at(minutes int) time.Time {
// fakeSource 是注入假資料的 Source。
type fakeSource struct {
orgs []string
repos map[string][]Repository
repos map[string][]Repo
// issues[owner/repo][number]
issues map[string]map[int]Issue
issues map[string]map[int64]Issue
// pulls[owner/repo]
pulls map[string][]PullRequest
// comments[owner/repo/number]
@@ -32,7 +32,7 @@ type fakeSource struct {
func (f *fakeSource) MyOrganizations() ([]string, error) { return f.orgs, nil }
func (f *fakeSource) OrganizationRepositories(org string) ([]Repository, error) {
func (f *fakeSource) OrganizationRepositories(org string) ([]Repo, error) {
return f.repos[org], nil
}
@@ -48,11 +48,11 @@ func (f *fakeSource) OpenPulls(owner, repo string) ([]PullRequest, error) {
return f.pulls[owner+"/"+repo], nil
}
func (f *fakeSource) IssueComments(owner, repo string, number int) ([]Comment, error) {
func (f *fakeSource) IssueComments(owner, repo string, number int64) ([]Comment, error) {
return f.comments[fmt.Sprintf("%s/%s/%d", owner, repo, number)], nil
}
func (f *fakeSource) TryGetIssue(owner, repo string, number int) (Issue, bool, error) {
func (f *fakeSource) TryGetIssue(owner, repo string, number int64) (Issue, bool, error) {
i, ok := f.issues[owner+"/"+repo][number]
if !ok {
return Issue{}, false, nil
@@ -60,11 +60,14 @@ func (f *fakeSource) TryGetIssue(owner, repo string, number int) (Issue, bool, e
return i, true, nil
}
func repoWith(name string, hasPR bool) []Repository {
func repoWith(name string, hasPR bool) []Repo {
h := hasPR
return []Repository{{Name: name, HasPullRequests: &h}}
return []Repo{{Name: name, HasPullRequests: &h}}
}
// iso 把相對分鐘轉成 parseMoment 可剖析的 ISO8601 字串(假資料用)。
func iso(minutes int) string { return FormatMoment(at(minutes)) }
func mustResolve(t *testing.T, org, repo string, texts []string, myOrgs map[string]bool, orgRepos func(string) ([]string, error)) []XrefTarget {
t.Helper()
targets, err := ResolveXrefTargets(org, repo, texts, myOrgs, orgRepos)
@@ -157,21 +160,21 @@ func TestScanStalledIssueReasons(t *testing.T) {
now := at(0)
src := &fakeSource{
orgs: []string{"alterminal"},
repos: map[string][]Repository{
repos: map[string][]Repo{
"alterminal": repoWith("agents", true),
},
issues: map[string]map[int]Issue{
issues: map[string]map[int64]Issue{
"alterminal/agents": {
1: {Number: 1, Title: "無分派者", State: "open", CreatedAt: at(-600), UpdatedAt: at(-600), Repository: Reference{"alterminal/agents"}, URL: "u1"},
2: {Number: 2, Title: "分派者自己最後留言", State: "open", Author: User{"ceo"}, CreatedAt: at(-600), UpdatedAt: at(-300), Assignees: []User{{"ceo"}}, Repository: Reference{"alterminal/agents"}, URL: "u2"},
3: {Number: 3, Title: "無留言", State: "open", CreatedAt: at(-600), UpdatedAt: at(-300), Assignees: []User{{"ceo"}}, Repository: Reference{"alterminal/agents"}, URL: "u3"},
4: {Number: 4, Title: "等外部回應", State: "open", CreatedAt: at(-600), UpdatedAt: at(-300), Assignees: []User{{"ceo"}}, Repository: Reference{"alterminal/agents"}, URL: "u4"},
5: {Number: 5, Title: "太新不在門檻內", State: "open", CreatedAt: at(-30), UpdatedAt: at(-30), Assignees: []User{{"ceo"}}, Repository: Reference{"alterminal/agents"}, URL: "u5"},
1: {Number: 1, Title: "無分派者", State: "open", CreatedAt: iso(-600), UpdatedAt: iso(-600), Repository: &Repository{FullName: "alterminal/agents"}, URL: "u1"},
2: {Number: 2, Title: "分派者自己最後留言", State: "open", User: &User{UserName: "ceo"}, CreatedAt: iso(-600), UpdatedAt: iso(-300), Assignees: []User{{UserName: "ceo"}}, Repository: &Repository{FullName: "alterminal/agents"}, URL: "u2"},
3: {Number: 3, Title: "無留言", State: "open", CreatedAt: iso(-600), UpdatedAt: iso(-300), Assignees: []User{{UserName: "ceo"}}, Repository: &Repository{FullName: "alterminal/agents"}, URL: "u3"},
4: {Number: 4, Title: "等外部回應", State: "open", CreatedAt: iso(-600), UpdatedAt: iso(-300), Assignees: []User{{UserName: "ceo"}}, Repository: &Repository{FullName: "alterminal/agents"}, URL: "u4"},
5: {Number: 5, Title: "太新不在門檻內", State: "open", CreatedAt: iso(-30), UpdatedAt: iso(-30), Assignees: []User{{UserName: "ceo"}}, Repository: &Repository{FullName: "alterminal/agents"}, URL: "u5"},
},
},
comments: map[string][]Comment{
"alterminal/agents/2": {{User: User{"ceo"}, CreatedAt: at(-300)}},
"alterminal/agents/4": {{User: User{"dan"}, CreatedAt: at(-300)}},
"alterminal/agents/2": {{User: &User{UserName: "ceo"}, CreatedAt: iso(-300)}},
"alterminal/agents/4": {{User: &User{UserName: "dan"}, CreatedAt: iso(-300)}},
},
}
got, err := ScanStalled(src, "admin", 4.0, now)
@@ -182,11 +185,11 @@ func TestScanStalledIssueReasons(t *testing.T) {
if len(got) != 4 {
t.Fatalf("應 4 筆,got %d:%v", len(got), got)
}
byNumber := map[int]Item{}
byNumber := map[int64]Item{}
for _, it := range got {
byNumber[it.Number] = it
}
for n, wantReason := range map[int]string{1: "no-assignee", 2: "assignee-idle", 3: "no-commenter", 4: "waiting-outside"} {
for n, wantReason := range map[int64]string{1: "no-assignee", 2: "assignee-idle", 3: "no-commenter", 4: "waiting-outside"} {
if byNumber[n].Reason != wantReason {
t.Errorf("issue %d reason = %q, want %q", n, byNumber[n].Reason, wantReason)
}
@@ -204,15 +207,15 @@ func TestScanStalledNudgeCooldown(t *testing.T) {
now := at(0)
src := &fakeSource{
orgs: []string{"alterminal"},
repos: map[string][]Repository{"alterminal": repoWith("agents", true)},
issues: map[string]map[int]Issue{
repos: map[string][]Repo{"alterminal": repoWith("agents", true)},
issues: map[string]map[int64]Issue{
"alterminal/agents": {
1: {Number: 1, State: "open", CreatedAt: at(-600), UpdatedAt: at(-600), Assignees: []User{{"max"}}, Repository: Reference{"alterminal/agents"}},
1: {Number: 1, State: "open", CreatedAt: iso(-600), UpdatedAt: iso(-600), Assignees: []User{{UserName: "max"}}, Repository: &Repository{FullName: "alterminal/agents"}},
},
},
comments: map[string][]Comment{
// admin(掃描者)10 小時前催促過 → 冷卻中,不列入。
"alterminal/agents/1": {{User: User{"admin"}, CreatedAt: at(-600)}},
"alterminal/agents/1": {{User: &User{UserName: "admin"}, CreatedAt: iso(-600)}},
},
}
got, err := ScanStalled(src, "admin", 4.0, now)
@@ -223,7 +226,7 @@ func TestScanStalledNudgeCooldown(t *testing.T) {
t.Fatalf("催促冷卻中的項目不應列入,got %v", got)
}
// 冷卻過了(25 小時前催促)→ 重新列入。
src.comments["alterminal/agents/1"] = []Comment{{User: User{"admin"}, CreatedAt: at(-1500)}}
src.comments["alterminal/agents/1"] = []Comment{{User: &User{UserName: "admin"}, CreatedAt: iso(-1500)}}
got, err = ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
@@ -240,15 +243,15 @@ func TestScanStalledParentTracking(t *testing.T) {
// 應以 parent-tracking 列入並附子項摘要;子項本身則是 assignee-idle。
src := &fakeSource{
orgs: []string{"alterminal"},
repos: map[string][]Repository{"alterminal": repoWith("teai", true)},
issues: map[string]map[int]Issue{
repos: map[string][]Repo{"alterminal": repoWith("teai", true)},
issues: map[string]map[int64]Issue{
"alterminal/teai": {
3: {Number: 3, Title: "父", State: "open", Body: "子項:#7", CreatedAt: at(-700), UpdatedAt: at(-300), Assignees: []User{{"ceo"}}, Repository: Reference{"alterminal/teai"}},
7: {Number: 7, Title: "子", State: "open", CreatedAt: at(-650), UpdatedAt: at(-300), Assignees: []User{{"max"}}, Repository: Reference{"alterminal/teai"}},
3: {Number: 3, Title: "父", State: "open", Body: "子項:#7", CreatedAt: iso(-700), UpdatedAt: iso(-300), Assignees: []User{{UserName: "ceo"}}, Repository: &Repository{FullName: "alterminal/teai"}},
7: {Number: 7, Title: "子", State: "open", CreatedAt: iso(-650), UpdatedAt: iso(-300), Assignees: []User{{UserName: "max"}}, Repository: &Repository{FullName: "alterminal/teai"}},
},
},
comments: map[string][]Comment{
"alterminal/teai/7": {{User: User{"max"}, CreatedAt: at(-300)}},
"alterminal/teai/7": {{User: &User{UserName: "max"}, CreatedAt: iso(-300)}},
},
}
got, err := ScanStalled(src, "admin", 4.0, now)
@@ -286,9 +289,9 @@ func TestScanStalledParentTracking(t *testing.T) {
// 子項 1 小時前恢復活動 → 父項 last_activity 跟著更新,未達門檻不列入;
// 子項本身也不停滯。整份輸出應為空。
child := src.issues["alterminal/teai"][7]
child.UpdatedAt = at(-60)
child.UpdatedAt = iso(-60)
src.issues["alterminal/teai"][7] = child
src.comments["alterminal/teai/7"] = []Comment{{User: User{"max"}, CreatedAt: at(-60)}}
src.comments["alterminal/teai/7"] = []Comment{{User: &User{UserName: "max"}, CreatedAt: iso(-60)}}
got, err = ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
@@ -303,11 +306,11 @@ func TestScanStalledParentTrackingNotChild(t *testing.T) {
now := at(0)
src := &fakeSource{
orgs: []string{"alterminal"},
repos: map[string][]Repository{"alterminal": repoWith("teai", true)},
issues: map[string]map[int]Issue{
repos: map[string][]Repo{"alterminal": repoWith("teai", true)},
issues: map[string]map[int64]Issue{
"alterminal/teai": {
47: {Number: 47, State: "open", Body: "規劃見 #39(更早)", CreatedAt: at(-700), UpdatedAt: at(-600), Assignees: []User{{"ceo"}}, Repository: Reference{"alterminal/teai"}},
39: {Number: 39, State: "open", CreatedAt: at(-2000), UpdatedAt: at(-60), Repository: Reference{"alterminal/teai"}},
47: {Number: 47, State: "open", Body: "規劃見 #39(更早)", CreatedAt: iso(-700), UpdatedAt: iso(-600), Assignees: []User{{UserName: "ceo"}}, Repository: &Repository{FullName: "alterminal/teai"}},
39: {Number: 39, State: "open", CreatedAt: iso(-2000), UpdatedAt: iso(-60), Repository: &Repository{FullName: "alterminal/teai"}},
},
},
}
@@ -345,30 +348,30 @@ func TestScanStalledPullReasons(t *testing.T) {
h := true
src := &fakeSource{
orgs: []string{"alterminal"},
repos: map[string][]Repository{"alterminal": {{Name: "teai", HasPullRequests: &h}}},
repos: map[string][]Repo{"alterminal": {{Name: "teai", HasPullRequests: &h}}},
pulls: map[string][]PullRequest{
"alterminal/teai": {
{Number: 10, State: "open", Author: User{"max"}, CreatedAt: at(-600), UpdatedAt: at(-600)},
{Number: 11, State: "open", Author: User{"max"}, CreatedAt: at(-600), UpdatedAt: at(-300), RequestedReviewers: []User{{"ceo"}}},
{Number: 12, State: "open", Author: User{"max"}, CreatedAt: at(-600), UpdatedAt: at(-300), RequestedReviewers: []User{{"ceo"}}},
{Number: 13, State: "open", Author: User{"max"}, CreatedAt: at(-600), UpdatedAt: at(-300), RequestedReviewers: []User{{"ceo"}}},
{Number: 10, User: &User{UserName: "max"}, CreatedAt: iso(-600), UpdatedAt: iso(-600)},
{Number: 11, User: &User{UserName: "max"}, CreatedAt: iso(-600), UpdatedAt: iso(-300), RequestedReviewers: []User{{UserName: "ceo"}}},
{Number: 12, User: &User{UserName: "max"}, CreatedAt: iso(-600), UpdatedAt: iso(-300), RequestedReviewers: []User{{UserName: "ceo"}}},
{Number: 13, User: &User{UserName: "max"}, CreatedAt: iso(-600), UpdatedAt: iso(-300), RequestedReviewers: []User{{UserName: "ceo"}}},
},
},
comments: map[string][]Comment{
"alterminal/teai/11": {{User: User{"max"}, CreatedAt: at(-300)}}, // author-idle
"alterminal/teai/12": {{User: User{"ceo"}, CreatedAt: at(-300)}}, // reviewer-idle
"alterminal/teai/13": {{User: User{"dan"}, CreatedAt: at(-300)}}, // no-commenter(外部留言)
"alterminal/teai/11": {{User: &User{UserName: "max"}, CreatedAt: iso(-300)}}, // author-idle
"alterminal/teai/12": {{User: &User{UserName: "ceo"}, CreatedAt: iso(-300)}}, // reviewer-idle
"alterminal/teai/13": {{User: &User{UserName: "dan"}, CreatedAt: iso(-300)}}, // no-commenter(外部留言)
},
}
got, err := ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
}
byNumber := map[int]Item{}
byNumber := map[int64]Item{}
for _, it := range got {
byNumber[it.Number] = it
}
for n, wantReason := range map[int]string{10: "no-reviewer", 11: "author-idle", 12: "reviewer-idle", 13: "no-commenter"} {
for n, wantReason := range map[int64]string{10: "no-reviewer", 11: "author-idle", 12: "reviewer-idle", 13: "no-commenter"} {
item, ok := byNumber[n]
if !ok {
t.Fatalf("PR %d 應列入,got %v", n, got)
@@ -391,15 +394,15 @@ func TestScanStalledNoPullsFlag(t *testing.T) {
h := false
src := &fakeSource{
orgs: []string{"alterminal"},
repos: map[string][]Repository{"alterminal": {{Name: "docs", HasPullRequests: &h}}},
issues: map[string]map[int]Issue{
repos: map[string][]Repo{"alterminal": {{Name: "docs", HasPullRequests: &h}}},
issues: map[string]map[int64]Issue{
"alterminal/docs": {
1: {Number: 1, State: "open", CreatedAt: at(-600), UpdatedAt: at(-600), Repository: Reference{"alterminal/docs"}},
1: {Number: 1, State: "open", CreatedAt: iso(-600), UpdatedAt: iso(-600), Repository: &Repository{FullName: "alterminal/docs"}},
},
},
pulls: map[string][]PullRequest{
// 即使假資料有 PR,flag 關閉也不應掃。
"alterminal/docs": {{Number: 2, State: "open", Author: User{"max"}, CreatedAt: at(-600), UpdatedAt: at(-600)}},
"alterminal/docs": {{Number: 2, User: &User{UserName: "max"}, CreatedAt: iso(-600), UpdatedAt: iso(-600)}},
},
}
got, err := ScanStalled(src, "admin", 4.0, now)
@@ -416,10 +419,10 @@ func TestScanStalledSkipsPRsInIssues(t *testing.T) {
now := at(0)
src := &fakeSource{
orgs: []string{"alterminal"},
repos: map[string][]Repository{"alterminal": repoWith("teai", true)},
issues: map[string]map[int]Issue{
repos: map[string][]Repo{"alterminal": repoWith("teai", true)},
issues: map[string]map[int64]Issue{
"alterminal/teai": {
1: {Number: 1, State: "open", CreatedAt: at(-600), UpdatedAt: at(-600), Repository: Reference{"alterminal/teai"}, PullRequest: struct{}{}},
1: {Number: 1, State: "open", CreatedAt: iso(-600), UpdatedAt: iso(-600), Repository: &Repository{FullName: "alterminal/teai"}, PullRequest: &pullMarker{}},
},
},
}
@@ -480,7 +483,7 @@ func TestPullJSON(t *testing.T) {
// TestScanStalledEmpty 無停滯項目 → 空輸出(CLI 層輸出 [])。
func TestScanStalledEmpty(t *testing.T) {
now := at(0)
src := &fakeSource{orgs: []string{"alterminal"}, repos: map[string][]Repository{"alterminal": repoWith("teai", true)}}
src := &fakeSource{orgs: []string{"alterminal"}, repos: map[string][]Repo{"alterminal": repoWith("teai", true)}}
got, err := ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
@@ -501,15 +504,15 @@ func TestScanStalledOrgError(t *testing.T) {
type errSource struct{}
func (errSource) MyOrganizations() ([]string, error) { return nil, errors.New("boom") }
func (e errSource) OrganizationRepositories(string) ([]Repository, error) {
func (e errSource) OrganizationRepositories(string) ([]Repo, error) {
return nil, errors.New("boom")
}
func (e errSource) OpenIssues(string, string) ([]Issue, error) { return nil, errors.New("boom") }
func (e errSource) OpenPulls(string, string) ([]PullRequest, error) { return nil, errors.New("boom") }
func (e errSource) IssueComments(string, string, int) ([]Comment, error) {
func (e errSource) IssueComments(string, string, int64) ([]Comment, error) {
return nil, errors.New("boom")
}
func (e errSource) TryGetIssue(string, string, int) (Issue, bool, error) {
func (e errSource) TryGetIssue(string, string, int64) (Issue, bool, error) {
return Issue{}, false, errors.New("boom")
}