Files
teai/internal/workflow/workflow_test.go
T
ceo a86803d336 internal/workflow:xref 解析與 stalled 掃描核心邏輯(#7)
完整移植 gitea.py 的 _resolve_xref_targets 與 find_stalled_work:
- xref:#N/repo#N/owner/repo#N;單段 repo 僅在存在於組織時解析
  (PR#50、issue#49 這類寫法自動略過);owner 需為所屬組織。
  Go regexp 無 lookbehind,掃描時檢查前一字元等價實作。
- stalled:reason 分類(issue:no-assignee/assignee-idle/no-commenter/
  waiting-outside/parent-tracking;PR:no-reviewer/author-idle/
  reviewer-idle/no-commenter)、24h 催促冷卻、父追蹤項把子項活動
  (留言、state 變更、PR 合併)計入 last_activity、上行引用不採計。
- 資料以 Source 介面注入:邏輯離線可測,CLI 接線待 #5 基礎建設。
- 輸出 JSON 與 gitea.py 位元組相容(鍵序、assignees/author 欄位、
  isoformat 時刻、round(x,1) 半偶數舍入)。
- cmd/xcheck:交叉驗證工具(--xrefs 與停滯掃描模式),已對真實 API
  與 gitea.py 並行比對,輸出一致。

go build/vet/test 全綠;單元測試覆蓋 xref 邊界與 stalled 判定。
2026-09-10 08:36:31 +08:00

507 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// workflow 套件的單元測試(teai issue #7 驗收:xref 邊界案例與 stalled 判定)。
//
// fakeSource 以假資料實作 Source 介面,邏輯離線可測;
// 語義對照組是 alterminal/agents scripts/lib/gitea.py。
package workflow
import (
"encoding/json"
"errors"
"fmt"
"testing"
"time"
)
func at(minutes int) time.Time {
return time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC).Add(time.Duration(minutes) * time.Minute)
}
// fakeSource 是注入假資料的 Source。
type fakeSource struct {
orgs []string
repos map[string][]Repository
// issues[owner/repo][number]
issues map[string]map[int]Issue
// pulls[owner/repo]
pulls map[string][]PullRequest
// comments[owner/repo/number]
comments map[string][]Comment
}
func (f *fakeSource) MyOrganizations() ([]string, error) { return f.orgs, nil }
func (f *fakeSource) OrganizationRepositories(org string) ([]Repository, error) {
return f.repos[org], nil
}
func (f *fakeSource) OpenIssues(owner, repo string) ([]Issue, error) {
var out []Issue
for _, i := range f.issues[owner+"/"+repo] {
out = append(out, i)
}
return out, nil
}
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) {
return f.comments[fmt.Sprintf("%s/%s/%d", owner, repo, number)], nil
}
func (f *fakeSource) TryGetIssue(owner, repo string, number int) (Issue, bool, error) {
i, ok := f.issues[owner+"/"+repo][number]
if !ok {
return Issue{}, false, nil
}
return i, true, nil
}
func repoWith(name string, hasPR bool) []Repository {
h := hasPR
return []Repository{{Name: name, HasPullRequests: &h}}
}
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)
if err != nil {
t.Fatalf("ResolveXrefTargets: %v", err)
}
return targets
}
// TestIterXrefsEdges 驗證 regex 邊界:lookbehind 等價實作與兩段式斜線限制。
func TestIterXrefsEdges(t *testing.T) {
cases := []struct {
name string
text string
want [][2]string
}{
{"單段 #N", "父項:#3。", [][2]string{{"3"}, {"", "3"}}[1:]},
{"單段 repo#N 存在", "見 agents#10", [][2]string{{"agents", "10"}}},
{"PR#50 寫法不是 repo", "PR#50 與 issue#49", [][2]string{{"PR", "50"}, {"issue", "49"}}},
{"owner/repo#N", "alterminal/agents#32", [][2]string{{"alterminal/agents", "32"}}},
{"斜線後不切半段", "alterminal/bear#47 只應匹配整段", [][2]string{{"alterminal/bear", "47"}}},
{"連字號 repo 名", "bear-cli#9", [][2]string{{"bear-cli", "9"}}},
{"路徑後的 #N 不匹配", "see https://x/a/#frag not #5", [][2]string{{"", "5"}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := iterXrefs(tc.text)
if len(got) != len(tc.want) {
t.Fatalf("iterXrefs(%q) = %v, want %v", tc.text, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("iterXrefs(%q)[%d] = %v, want %v", tc.text, i, got[i], tc.want[i])
}
}
})
}
}
// TestResolveXrefTargets 驗證三種形態的解析與單段 repo 的存在性邊界(#7 驗收)。
func TestResolveXrefTargets(t *testing.T) {
myOrgs := map[string]bool{"alterminal": true}
orgRepos := func(string) ([]string, error) { return []string{"agents", "teai", "bear-cli"}, nil }
texts := []string{"父項:#3,子項 teai#7,跨倉 alterminal/bear#35,不存在 nope#9,PR#50"}
got := mustResolve(t, "alterminal", "agents", texts, myOrgs, orgRepos)
want := []XrefTarget{
{Owner: "alterminal", Repo: "agents", Number: 3},
{Owner: "alterminal", Repo: "teai", Number: 7},
{Owner: "alterminal", Repo: "bear", Number: 35},
}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got[%d] %v, want %v", i, got[i], want[i])
}
}
}
// TestResolveXrefTargetsOwnerNotMyOrg owner/repo#N 的 owner 非所屬組織 → 不解析。
func TestResolveXrefTargetsOwnerNotMyOrg(t *testing.T) {
myOrgs := map[string]bool{"alterminal": true}
orgRepos := func(string) ([]string, error) { return nil, nil }
got := mustResolve(t, "alterminal", "agents", []string{"other/repo#1"}, myOrgs, orgRepos)
if len(got) != 0 {
t.Fatalf("owner 非所屬組織應略過,got %v", got)
}
}
// TestResolveXrefTargetsDedup 同一目標重複出現只保留第一個。
func TestResolveXrefTargetsDedup(t *testing.T) {
myOrgs := map[string]bool{"alterminal": true}
orgRepos := func(string) ([]string, error) { return []string{"teai"}, nil }
got := mustResolve(t, "alterminal", "agents", []string{"#7 又 teai#7"}, myOrgs, orgRepos)
if len(got) != 2 {
t.Fatalf("應兩筆(#7 同倉庫、teai#7 跨倉庫),got %v", got)
}
}
// TestScanStalledIssueReasons 驗證 issue 的 reason 分類與排序。
func TestScanStalledIssueReasons(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{
"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"},
},
},
comments: map[string][]Comment{
"alterminal/agents/2": {{User: User{"ceo"}, CreatedAt: at(-300)}},
"alterminal/agents/4": {{User: User{"dan"}, CreatedAt: at(-300)}},
},
}
got, err := ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
}
// 1: no-assignee(10h)、2: assignee-idle(5h)、3: no-commenter(5h)、4: waiting-outside(5h)、5: 不列入
if len(got) != 4 {
t.Fatalf("應 4 筆,got %d:%v", len(got), got)
}
byNumber := map[int]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"} {
if byNumber[n].Reason != wantReason {
t.Errorf("issue %d reason = %q, want %q", n, byNumber[n].Reason, wantReason)
}
}
if got[0].Number != 1 || got[0].StalledHours != 10 {
t.Errorf("排序應由長到短,首筆 issue 1(10h),got %v", got[0])
}
if byNumber[2].LastCommentBy != "ceo" {
t.Errorf("issue 2 last_comment_by 應為 ceo,got %q", byNumber[2].LastCommentBy)
}
}
// TestScanStalledNudgeCooldown 最後留言出自掃描者本人 → 24 小時內冷卻不列入。
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{
"alterminal/agents": {
1: {Number: 1, State: "open", CreatedAt: at(-600), UpdatedAt: at(-600), Assignees: []User{{"max"}}, Repository: Reference{"alterminal/agents"}},
},
},
comments: map[string][]Comment{
// admin(掃描者)10 小時前催促過 → 冷卻中,不列入。
"alterminal/agents/1": {{User: User{"admin"}, CreatedAt: at(-600)}},
},
}
got, err := ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
}
if len(got) != 0 {
t.Fatalf("催促冷卻中的項目不應列入,got %v", got)
}
// 冷卻過了(25 小時前催促)→ 重新列入。
src.comments["alterminal/agents/1"] = []Comment{{User: User{"admin"}, CreatedAt: at(-1500)}}
got, err = ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
}
if len(got) != 1 || got[0].Reason != "waiting-outside" {
t.Fatalf("冷卻後應重新列入,got %v", got)
}
}
// TestScanStalledParentTracking 父追蹤項:子項活動計入 last_activity、reason 標記。
func TestScanStalledParentTracking(t *testing.T) {
now := at(0)
// 父項與子項都 5 小時沒動:父項 last_activity 計入子項活動後同為 5h,
// 應以 parent-tracking 列入並附子項摘要;子項本身則是 assignee-idle。
src := &fakeSource{
orgs: []string{"alterminal"},
repos: map[string][]Repository{"alterminal": repoWith("teai", true)},
issues: map[string]map[int]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"}},
},
},
comments: map[string][]Comment{
"alterminal/teai/7": {{User: User{"max"}, CreatedAt: at(-300)}},
},
}
got, err := ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
}
var parent3, child7 *Item
for i := range got {
switch got[i].Number {
case 3:
parent3 = &got[i]
case 7:
child7 = &got[i]
}
}
if child7 == nil {
t.Fatalf("子項 7 應列入(5h 無活動),got %v", got)
}
if child7.Reason != "assignee-idle" {
t.Errorf("子項 7 reason = %q, want assignee-idle", child7.Reason)
}
if parent3 == nil {
t.Fatalf("父項 3 應列入,got %v", got)
}
if parent3.Reason != "parent-tracking" {
t.Errorf("父項 3 reason = %q, want parent-tracking", parent3.Reason)
}
if len(parent3.ChildActivity) != 1 || parent3.ChildActivity[0].Number != 7 {
t.Errorf("父項 3 應附子項摘要,got %v", parent3.ChildActivity)
}
if parent3.StalledHours != 5 {
t.Errorf("父項 3 stalled_hours = %v, want 5", parent3.StalledHours)
}
// 子項 1 小時前恢復活動 → 父項 last_activity 跟著更新,未達門檻不列入;
// 子項本身也不停滯。整份輸出應為空。
child := src.issues["alterminal/teai"][7]
child.UpdatedAt = at(-60)
src.issues["alterminal/teai"][7] = child
src.comments["alterminal/teai/7"] = []Comment{{User: User{"max"}, CreatedAt: at(-60)}}
got, err = ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
}
if len(got) != 0 {
t.Fatalf("子項推進中 → 父項不應誤報停滯,got %v", got)
}
}
// TestScanStalledParentTrackingNotChild 上行引用:引用對象早於父項 → 非子項,不採計。
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{
"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"}},
},
},
}
got, err := ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
}
// #39 是上行引用,不採計 → 父項 47 停滯 10h(assignee-idle:ceo 分派、無留言)→ no-commenter。
var n47 *Item
for i := range got {
if got[i].Number == 47 {
n47 = &got[i]
}
}
if n47 == nil {
t.Fatalf("上行引用不採計 → 47 應停滯列入,got %v", got)
}
if n47.Reason != "no-commenter" {
t.Errorf("47 reason = %q, want no-commenter", n47.Reason)
}
if len(n47.ChildActivity) != 0 {
t.Errorf("上行引用不應產生子項摘要,got %v", n47.ChildActivity)
}
// #39 本身 updated 1 小時前 → 不列入。
for _, it := range got {
if it.Number == 39 {
t.Errorf("39 未達門檻不應列入:%v", it)
}
}
}
// TestScanStalledPullReasons 驗證 PR 的 reason 分類。
func TestScanStalledPullReasons(t *testing.T) {
now := at(0)
h := true
src := &fakeSource{
orgs: []string{"alterminal"},
repos: map[string][]Repository{"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"}}},
},
},
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(外部留言)
},
}
got, err := ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
}
byNumber := map[int]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"} {
item, ok := byNumber[n]
if !ok {
t.Fatalf("PR %d 應列入,got %v", n, got)
}
if item.Reason != wantReason {
t.Errorf("PR %d reason = %q, want %q", n, item.Reason, wantReason)
}
}
if len(got) != 4 {
t.Fatalf("應 4 筆,got %d", len(got))
}
if byNumber[10].Author != "max" {
t.Errorf("PR 10 author 應為 max,got %q", byNumber[10].Author)
}
}
// TestScanStalledNoPullsFlag 停用 PR 的倉庫仍掃 issues,只跳過 PR。
func TestScanStalledNoPullsFlag(t *testing.T) {
now := at(0)
h := false
src := &fakeSource{
orgs: []string{"alterminal"},
repos: map[string][]Repository{"alterminal": {{Name: "docs", HasPullRequests: &h}}},
issues: map[string]map[int]Issue{
"alterminal/docs": {
1: {Number: 1, State: "open", CreatedAt: at(-600), UpdatedAt: at(-600), Repository: Reference{"alterminal/docs"}},
},
},
pulls: map[string][]PullRequest{
// 即使假資料有 PR,flag 關閉也不應掃。
"alterminal/docs": {{Number: 2, State: "open", Author: User{"max"}, CreatedAt: at(-600), UpdatedAt: at(-600)}},
},
}
got, err := ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
}
if len(got) != 1 || got[0].Number != 1 || got[0].Type != "issue" {
t.Fatalf("應只有 issue 1,got %v", got)
}
}
// TestScanStalledSkipsPRsInIssues 確認 /issues 混入的 PR 會被過濾。
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{
"alterminal/teai": {
1: {Number: 1, State: "open", CreatedAt: at(-600), UpdatedAt: at(-600), Repository: Reference{"alterminal/teai"}, PullRequest: struct{}{}},
},
},
}
got, err := ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
}
if len(got) != 0 {
t.Fatalf("/issues 混入的 PR 應過濾,got %v", got)
}
}
// TestItemJSON 輸出欄位與 gitea.py 對齊(last_activity_at 序列化、omitempty)。
func TestItemJSON(t *testing.T) {
ts := at(-600)
item := Item{
Type: "issue", Repo: "alterminal/teai", Number: 7, Title: "t", URL: "u",
Assignees: []string{"ceo"}, LastCommentBy: "max",
LastActivityAt: ts, StalledHours: 10, Reason: "assignee-idle",
ChildActivity: []Child{{Repo: "alterminal/teai", Number: 3, Title: "父", State: "open", LastActivityAt: ts.Format(time.RFC3339)}},
}
b, err := json.Marshal(item)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
for _, key := range []string{"type", "repo", "number", "title", "url", "assignees", "last_comment_by", "last_activity_at", "stalled_hours", "reason", "child_activity"} {
if _, ok := m[key]; !ok {
t.Errorf("輸出缺少欄位 %q:%s", key, b)
}
}
if _, ok := m["author"]; ok {
t.Errorf("issue 不應輸出 author:%s", b)
}
}
// TestScanStalledEmpty 無停滯項目 → 空輸出(CLI 層輸出 [])。
func TestScanStalledEmpty(t *testing.T) {
now := at(0)
src := &fakeSource{orgs: []string{"alterminal"}, repos: map[string][]Repository{"alterminal": repoWith("teai", true)}}
got, err := ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
}
if len(got) != 0 {
t.Fatalf("應空,got %v", got)
}
}
// TestScanStalledOrgError 組織層錯誤應回傳(不靜默)。
func TestScanStalledOrgError(t *testing.T) {
src := &errSource{}
if _, err := ScanStalled(src, "admin", 4.0, at(0)); err == nil {
t.Fatal("組織清單失敗應回傳錯誤")
}
}
type errSource struct{}
func (errSource) MyOrganizations() ([]string, error) { return nil, errors.New("boom") }
func (e errSource) OrganizationRepositories(string) ([]Repository, 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) {
return nil, errors.New("boom")
}
func (e errSource) TryGetIssue(string, string, int) (Issue, bool, error) {
return Issue{}, false, errors.New("boom")
}
// TestRound1 round1 十進位舍入到一位小數(與 Python round(x,1) 半偶數一致)。
func TestRound1(t *testing.T) {
cases := []struct {
in float64
want float64
}{
{10.44, 10.4}, {10.46, 10.5}, {10.0, 10.0},
// 半偶數(與 Python round(x,1) 對二進位真值的行為一致):
// 10.45、5.25、5.15 的二進位真值都略低於表值 → 舍到偶數位。
{10.45, 10.4}, {0.05, 0.1}, {5.25, 5.2}, {5.15, 5.2},
}
for _, tc := range cases {
if got := round1(tc.in); got != tc.want {
t.Errorf("round1(%v) = %v, want %v", tc.in, got, tc.want)
}
}
}