iterOrgRepos(issue 掃描,供 Mine 與 members --has-work)不再跳過 empty=true 的倉庫——Gitea 允許空倉庫持有 open issue(「初始化項目」 型工作),行為基準 gitea.py 的 for_all_repositories 掃 issue 時 本來就不檢查 empty;teai 先前過度套用了空倉庫跳過條件,導致 teai next 對 colony-tick-demo issue #1 回 null。 PR 掃描(iterOpenPulls)、stalled、milestone overview 維持跳過 空倉庫不變(與 gitea.py 一致)。附回歸測試兩則。
307 lines
9.1 KiB
Go
307 lines
9.1 KiB
Go
// scan_test.go — 以假 client 驗證 Scanner 的命令層語義(對照 gitea.py):
|
||
// next 三層拾取與「最久未更新優先」、mine/pulls 過濾、members --has-work。
|
||
package workflow
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"reflect"
|
||
"testing"
|
||
)
|
||
|
||
// fakeClient 以記憶體資料實作 Client。
|
||
type fakeClient struct {
|
||
currentUser string
|
||
orgs []Org
|
||
teams map[string][]Team // org → teams
|
||
teamMembers map[int64][]string // teamID → 帳號
|
||
repos map[string][]Repo // org → repos
|
||
issues map[string][]Issue // "owner/repo" → issues(open,不含 PR)
|
||
pulls map[string][]PullRequest // "owner/repo" → open PR
|
||
comments map[string][]Comment // "owner/repo#number" → 留言
|
||
failOn map[string]error // 端點 → 錯誤(測失敗傳播)
|
||
}
|
||
|
||
func (f *fakeClient) CurrentUser() (string, error) {
|
||
if err := f.failOn["user"]; err != nil {
|
||
return "", err
|
||
}
|
||
return f.currentUser, nil
|
||
}
|
||
|
||
func (f *fakeClient) ListOrgs() ([]string, error) {
|
||
if err := f.failOn["orgs"]; err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]string, 0, len(f.orgs))
|
||
for _, o := range f.orgs {
|
||
out = append(out, o.Account())
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (f *fakeClient) ListTeams(org string) ([]Team, error) {
|
||
if err := f.failOn["teams"]; err != nil {
|
||
return nil, err
|
||
}
|
||
return f.teams[org], nil
|
||
}
|
||
|
||
func (f *fakeClient) ListTeamMembers(teamID int64) ([]string, error) {
|
||
if err := f.failOn["members"]; err != nil {
|
||
return nil, err
|
||
}
|
||
return f.teamMembers[teamID], nil
|
||
}
|
||
|
||
func (f *fakeClient) ListOrgRepos(org string) ([]Repo, error) {
|
||
if err := f.failOn["repos"]; err != nil {
|
||
return nil, err
|
||
}
|
||
return f.repos[org], nil
|
||
}
|
||
|
||
func (f *fakeClient) ListIssues(owner, repo string) ([]Issue, error) {
|
||
if err := f.failOn["issues"]; err != nil {
|
||
return nil, err
|
||
}
|
||
return f.issues[owner+"/"+repo], nil
|
||
}
|
||
|
||
func (f *fakeClient) ListPulls(owner, repo string) ([]PullRequest, error) {
|
||
if err := f.failOn["pulls"]; err != nil {
|
||
return nil, err
|
||
}
|
||
return f.pulls[owner+"/"+repo], nil
|
||
}
|
||
|
||
func (f *fakeClient) ListComments(owner, repo string, number int64) ([]Comment, error) {
|
||
if err := f.failOn["comments"]; err != nil {
|
||
return nil, err
|
||
}
|
||
return f.comments[fmt.Sprintf("%s/%s#%d", owner, repo, number)], nil
|
||
}
|
||
|
||
// newTestScanner 構造測試情境:一個組織、一個 Agents 團隊、兩個倉庫。
|
||
func newTestScanner() (*Scanner, *fakeClient) {
|
||
fc := &fakeClient{
|
||
currentUser: "alex",
|
||
orgs: []Org{{UserName: "alterminal"}},
|
||
teams: map[string][]Team{"alterminal": {{ID: 1, Name: "Agents"}}},
|
||
teamMembers: map[int64][]string{1: {"alex", "max", "queena"}},
|
||
repos: map[string][]Repo{"alterminal": {{Name: "teai"}, {Name: "agents"}}},
|
||
issues: map[string][]Issue{},
|
||
pulls: map[string][]PullRequest{},
|
||
comments: map[string][]Comment{},
|
||
failOn: map[string]error{},
|
||
}
|
||
return &Scanner{Client: fc}, fc
|
||
}
|
||
|
||
func TestNextLayerPriority(t *testing.T) {
|
||
s, fc := newTestScanner()
|
||
// 三層各有工作:author PR、reviewer PR、issue。
|
||
fc.pulls["alterminal/teai"] = []PullRequest{
|
||
{
|
||
Number: 10, Title: "我的 PR", UpdatedAt: "2026-09-09T00:00:00Z",
|
||
User: mkUser("alex"),
|
||
},
|
||
{
|
||
Number: 11, Title: "待我審核的 PR", UpdatedAt: "2026-09-09T01:00:00Z",
|
||
User: mkUser("max"),
|
||
RequestedReviewers: []User{*mkUser("alex")},
|
||
},
|
||
}
|
||
// author PR #10 有他人留言 → 需跟進。
|
||
fc.comments["alterminal/teai#10"] = []Comment{
|
||
{CreatedAt: "2026-09-09T00:10:00Z", User: mkUser("ceo")},
|
||
}
|
||
// reviewer PR #11 尚無留言 → 審核者仍應被列入。
|
||
fc.issues["alterminal/agents"] = []Issue{
|
||
{Number: 30, Title: "分派給 alex", UpdatedAt: "2026-09-08T00:00:00Z", Assignees: []User{*mkUser("alex")}},
|
||
}
|
||
work, err := s.Next()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if work.Type != "pull" || work.Role != "author" || work.Number != 10 {
|
||
t.Errorf("第一層應選 author PR #10,得到 %+v", work)
|
||
}
|
||
}
|
||
|
||
func TestNextLayerFallbackToReviewer(t *testing.T) {
|
||
s, fc := newTestScanner()
|
||
fc.pulls["alterminal/teai"] = []PullRequest{
|
||
{
|
||
Number: 11, Title: "待審核", UpdatedAt: "2026-09-09T01:00:00Z",
|
||
User: mkUser("max"),
|
||
RequestedReviewers: []User{*mkUser("alex")},
|
||
},
|
||
}
|
||
fc.issues["alterminal/agents"] = []Issue{
|
||
{Number: 30, Title: "分派給 alex", UpdatedAt: "2026-09-08T00:00:00Z", Assignees: []User{*mkUser("alex")}},
|
||
}
|
||
work, err := s.Next()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if work.Type != "pull" || work.Role != "reviewer" || work.Number != 11 {
|
||
t.Errorf("第二層應選 reviewer PR #11,得到 %+v", work)
|
||
}
|
||
}
|
||
|
||
func TestNextLayerFallbackToIssue(t *testing.T) {
|
||
s, fc := newTestScanner()
|
||
fc.issues["alterminal/agents"] = []Issue{
|
||
{Number: 30, Title: "分派給 alex", UpdatedAt: "2026-09-08T00:00:00Z", Assignees: []User{*mkUser("alex")}},
|
||
}
|
||
work, err := s.Next()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if work.Type != "issue" || work.Number != 30 {
|
||
t.Errorf("第三層應選 issue #30,得到 %+v", work)
|
||
}
|
||
}
|
||
|
||
// TestNextPicksIssueInEmptyRepo 回歸測試(#43):空倉庫(empty=true)中
|
||
// 分派給目前帳號的 open issue 應被第三層拾取——gitea.py 的
|
||
// for_all_repositories 掃 issue 不跳過空倉庫(「初始化項目」型工作)。
|
||
func TestNextPicksIssueInEmptyRepo(t *testing.T) {
|
||
s, fc := newTestScanner()
|
||
fc.repos["alterminal"] = []Repo{
|
||
{Name: "teai"},
|
||
{Name: "colony-tick-demo", Empty: true},
|
||
}
|
||
fc.issues["alterminal/colony-tick-demo"] = []Issue{
|
||
{
|
||
Number: 1, Title: "初始化項目", UpdatedAt: "2026-09-08T00:00:00Z",
|
||
Assignees: []User{*mkUser("alex")},
|
||
Repository: &Repository{FullName: "alterminal/colony-tick-demo"},
|
||
HTMLURL: "https://gitea.alterminal.com/alterminal/colony-tick-demo/issues/1",
|
||
},
|
||
}
|
||
work, err := s.Next()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if work == nil || work.Type != "issue" || work.Number != 1 || work.Repo != "alterminal/colony-tick-demo" {
|
||
t.Errorf("應拾取空倉庫中的 issue #1,得到 %+v", work)
|
||
}
|
||
}
|
||
|
||
// TestMineIncludesEmptyRepoIssues 回歸測試(#43):mine 同樣不因倉庫為空而漏列。
|
||
func TestMineIncludesEmptyRepoIssues(t *testing.T) {
|
||
s, fc := newTestScanner()
|
||
fc.repos["alterminal"] = []Repo{
|
||
{Name: "teai"},
|
||
{Name: "colony-tick-demo", Empty: true},
|
||
}
|
||
fc.issues["alterminal/colony-tick-demo"] = []Issue{
|
||
{Number: 1, Title: "初始化項目", UpdatedAt: "2026-09-08T00:00:00Z", Assignees: []User{*mkUser("alex")}},
|
||
}
|
||
issues, err := s.Mine()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(issues) != 1 || issues[0].Num() != 1 {
|
||
t.Errorf("mine 應列出空倉庫中的 issue #1,得到 %d 件", len(issues))
|
||
}
|
||
}
|
||
|
||
func TestNextNone(t *testing.T) {
|
||
s, _ := newTestScanner()
|
||
work, err := s.Next()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if work != nil {
|
||
t.Errorf("無工作應回 nil,得到 %+v", work)
|
||
}
|
||
}
|
||
|
||
func TestNextOldestFirst(t *testing.T) {
|
||
s, fc := newTestScanner()
|
||
fc.issues["alterminal/agents"] = []Issue{
|
||
{Number: 31, Title: "較新", UpdatedAt: "2026-09-09T00:00:00Z", Assignees: []User{*mkUser("alex")}},
|
||
{Number: 30, Title: "較舊", UpdatedAt: "2026-09-08T00:00:00Z", Assignees: []User{*mkUser("alex")}},
|
||
}
|
||
work, err := s.Next()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if work.Number != 30 {
|
||
t.Errorf("應選最久未更新的 #30,得到 %+v", work)
|
||
}
|
||
}
|
||
|
||
func TestMineSkipsLastCommentByAssignee(t *testing.T) {
|
||
s, fc := newTestScanner()
|
||
fc.issues["alterminal/agents"] = []Issue{
|
||
{Number: 30, Title: "已在處理", UpdatedAt: "a", Assignees: []User{*mkUser("alex")}},
|
||
}
|
||
fc.comments["alterminal/agents#30"] = []Comment{
|
||
{CreatedAt: "b", User: mkUser("alex")},
|
||
}
|
||
issues, err := s.Mine()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(issues) != 0 {
|
||
t.Errorf("最後留言來自 assignee → 應略過,得到 %d 件", len(issues))
|
||
}
|
||
}
|
||
|
||
func TestPullsMineReviewersExclusive(t *testing.T) {
|
||
s, _ := newTestScanner()
|
||
if _, err := s.Pulls(true, true); !errors.Is(err, errIncompatibleFlags) {
|
||
t.Errorf("--mine 與 --reviewer 併用應回 errIncompatibleFlags,得到 %v", err)
|
||
}
|
||
}
|
||
|
||
func TestMembersHasWork(t *testing.T) {
|
||
s, fc := newTestScanner()
|
||
// alex 有待審核 PR;max 無任何工作;queena 有分派 issue。
|
||
fc.pulls["alterminal/teai"] = []PullRequest{
|
||
{
|
||
Number: 11, UpdatedAt: "a", User: mkUser("max"),
|
||
RequestedReviewers: []User{*mkUser("alex")},
|
||
},
|
||
}
|
||
fc.issues["alterminal/agents"] = []Issue{
|
||
{Number: 30, UpdatedAt: "b", Assignees: []User{*mkUser("queena")}},
|
||
}
|
||
got, err := s.Members(true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !reflect.DeepEqual(got, []string{"alex", "queena"}) {
|
||
t.Errorf("應只有 alex 與 queena 有工作,得到 %v", got)
|
||
}
|
||
}
|
||
|
||
func TestErrorPropagation(t *testing.T) {
|
||
s, fc := newTestScanner()
|
||
fc.failOn["orgs"] = errors.New("boom")
|
||
if _, err := s.Next(); err == nil {
|
||
t.Errorf("API 錯誤應向上傳播,不應吞掉")
|
||
}
|
||
}
|
||
|
||
func TestPullsDefault(t *testing.T) {
|
||
s, fc := newTestScanner()
|
||
fc.pulls["alterminal/teai"] = []PullRequest{
|
||
{Number: 10, UpdatedAt: "a", User: mkUser("alex")},
|
||
}
|
||
fc.comments["alterminal/teai#10"] = []Comment{
|
||
{CreatedAt: "b", User: mkUser("ceo")},
|
||
}
|
||
items, err := s.Pulls(false, false)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(items) != 1 || items[0].Repo != "teai" {
|
||
t.Errorf("應列出待跟進 PR,得到 %+v", items)
|
||
}
|
||
}
|