- internal/workflow:Client 介面+判定語義(最後留言、作者/審核者跟進、 assignee 交集、最久未更新優先),含假 client 單元測試 - internal/workflow/giteaapi.go:以 internal/gitea(#5)實作 Client - internal/cli:註冊 whoami/orgs/members/mine/pulls/next;全域選項允許 出現在命令之後;結束碼 0/2/3 - 與 gitea.py 交叉驗證:next/mine/pulls/members(--has-work) 拾取一致 - 依 #5 分支 feature/issue5-gitea-client 為基底
262 lines
7.5 KiB
Go
262 lines
7.5 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)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|