internal/workflow + cli:gitea.py 工作流命令 Go 化(#6)

- 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 為基底
This commit is contained in:
2026-09-10 08:39:35 +08:00
parent 6951817313
commit 5583a00bb5
10 changed files with 1604 additions and 6 deletions
+261
View File
@@ -0,0 +1,261 @@
// 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)
}
}