diff --git a/cmd/xcheck/main.go b/cmd/xcheck/main.go index f2f625d..4b71e67 100644 --- a/cmd/xcheck/main.go +++ b/cmd/xcheck/main.go @@ -165,6 +165,14 @@ func (s *apiSource) IssueComments(owner, repo string, number int64) ([]workflow. return comments, nil } +func (s *apiSource) IssueTimeline(owner, repo string, number int64) ([]workflow.TimelineEvent, error) { + var events []workflow.TimelineEvent + if err := s.listAll(fmt.Sprintf("repos/%s/%s/issues/%d/timeline", owner, repo, number), nil, &events); err != nil { + return nil, err + } + return events, nil +} + func (s *apiSource) TryGetIssue(owner, repo string, number int64) (workflow.Issue, bool, error) { var issue workflow.Issue body, code, err := s.get(fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, number), nil) diff --git a/internal/cli/stalled_commands_test.go b/internal/cli/stalled_commands_test.go index e160d83..8e1d241 100644 --- a/internal/cli/stalled_commands_test.go +++ b/internal/cli/stalled_commands_test.go @@ -46,6 +46,9 @@ func (s *stubStalledSource) TryGetIssue(owner, repo string, number int64) (workf i, ok := s.issues[owner+"/"+repo][number] return i, ok, nil } +func (s *stubStalledSource) IssueTimeline(owner, repo string, number int64) ([]workflow.TimelineEvent, error) { + return nil, nil +} func (s *stubStalledSource) GetIssue(owner, repo string, number int64) (workflow.Issue, error) { if s.getErr != nil { return workflow.Issue{}, s.getErr @@ -157,7 +160,7 @@ func TestWiringXrefs(t *testing.T) { src := newStubStalledData() src.issues["alterminal/agents"][1] = workflow.Issue{ Number: 1, Title: "父項", State: "open", - Body: "子項:agents#2", + Body: "子項:agents#2", Repository: &workflow.Repository{FullName: "alterminal/agents"}, } src.issues["alterminal/agents"][2] = workflow.Issue{ @@ -185,7 +188,7 @@ func TestWiringXrefsEmpty(t *testing.T) { src := newStubStalledData() src.issues["alterminal/agents"][1] = workflow.Issue{ Number: 1, Title: "無引用", State: "open", - Body: "沒有任何引用", + Body: "沒有任何引用", Repository: &workflow.Repository{FullName: "alterminal/agents"}, } newStubStalledEnv(t, src, time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)) diff --git a/internal/workflow/giteaapi.go b/internal/workflow/giteaapi.go index a7c2f64..d471c24 100644 --- a/internal/workflow/giteaapi.go +++ b/internal/workflow/giteaapi.go @@ -150,6 +150,19 @@ func (a *APIClient) TryGetIssue(owner, repo string, number int64) (Issue, bool, return issue, true, nil } +// IssueTimeline 回傳 issue 的 timeline 事件(Source 面向;對應 gitea.py +// _direct_last_activity 拉取的 repos/{o}/{r}/issues/{n}/timeline)。 +// 分頁由 ListAll 處理(與 gitea.py api_get_list 的 limit=50 逐頁一致)。 +func (a *APIClient) IssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error) { + var events []TimelineEvent + path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + + "/issues/" + strconv.FormatInt(number, 10) + "/timeline" + if err := a.Client.ListAll(context.Background(), path, gitea.ListOptions{}, &events); err != nil { + return nil, err + } + return events, nil +} + // MyOrganizations 委派 ListOrgs(Source 面向)。 func (a *APIClient) MyOrganizations() ([]string, error) { return a.ListOrgs() } diff --git a/internal/workflow/stalled_entry_test.go b/internal/workflow/stalled_entry_test.go index 98c09e2..2d117c1 100644 --- a/internal/workflow/stalled_entry_test.go +++ b/internal/workflow/stalled_entry_test.go @@ -1,8 +1,10 @@ // stalled_entry_test.go — RunStalled/ResolveIssueXrefs 高階入口的單元測試 -//(#7 CLI 接線;資料源注入假實作,離線可測)。 +// (#7 CLI 接線;資料源注入假實作,離線可測)。 package workflow import ( + "errors" + "fmt" "strings" "testing" "time" @@ -14,10 +16,22 @@ type fakeStalledSource struct { username string // getIssueErr 模擬 GetIssue 失敗(xrefs 的 404 路徑)。 getIssueErr error + // timeline[owner/repo/number]:issue timeline 事件(無則回空清單, + // 模擬 API 成功但無事件;timelineErr 模擬拉取失敗)。 + timeline map[string][]TimelineEvent + timelineErr map[string]error } func (f *fakeStalledSource) CurrentUser() (string, error) { return f.username, nil } +func (f *fakeStalledSource) IssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error) { + key := fmt.Sprintf("%s/%s/%d", owner, repo, number) + if err := f.timelineErr[key]; err != nil { + return nil, err + } + return f.timeline[key], nil +} + func (f *fakeStalledSource) GetIssue(owner, repo string, number int64) (Issue, error) { if f.getIssueErr != nil { return Issue{}, f.getIssueErr @@ -38,7 +52,7 @@ func (e *notFoundError) Error() string { return "404" } func newStalledFixture() *fakeStalledSource { return &fakeStalledSource{ fakeSource: &fakeSource{ - orgs: []string{"alterminal"}, + orgs: []string{"alterminal"}, repos: map[string][]Repo{"alterminal": { {Name: "agents", HasPullRequests: boolPtr(true)}, {Name: "teai", HasPullRequests: boolPtr(true)}, @@ -229,3 +243,92 @@ func TestResolveIssueXrefsEmptyBody(t *testing.T) { } _ = time.Now // time 僅供 iso/at 使用;保留 import。 } + +// TestRunStalledChildXrefRefresh 回歸測試(agents #38、teai #34): +// 子項 updated_at 被跨 issue 引用(comment_ref)刷新時,子項有效活動 +// 應經 directLastActivity 排除引用事件後採計,父追蹤項的停滯時鐘 +// 不被虛假刷新。 +func TestRunStalledChildXrefRefresh(t *testing.T) { + src := newStalledFixture() + // 子項 teai#2:直接活動為 created(-500) 與留言(-90), + // updated_at 卻是 -10(他處留言提及刷新)。 + issue := src.issues["alterminal/teai"][2] + issue.UpdatedAt = iso(-10) + src.issues["alterminal/teai"][2] = issue + // timeline:-10 的刷新是 comment_ref(引用事件,排除), + // -90 有直接事件(label)可對應 → 有效活動 -90。 + src.timeline = map[string][]TimelineEvent{ + "alterminal/teai/2": { + {Type: "label", CreatedAt: iso(-90)}, + {Type: "comment_ref", CreatedAt: iso(-10)}, + }, + } + // 門檻 1 小時:父項 last_activity 取子項有效活動 -90 → 停滯 1.5h 列入; + // 若誤採被刷新的 -10,父項與子項都不會列入。 + items, err := RunStalled(src, 1, at(0)) + if err != nil { + t.Fatalf("RunStalled: %v", err) + } + if len(items) != 2 { + t.Fatalf("應有兩筆(父追蹤項+子項),得到 %d:%+v", len(items), items) + } + var parent *Item + for i := range items { + if items[i].Repo == "alterminal/agents" && items[i].Number == 1 { + parent = &items[i] + } + } + if parent == nil { + t.Fatalf("找不到 agents#1:%+v", items) + } + if parent.Reason != "parent-tracking" { + t.Fatalf("agents#1 應為 parent-tracking,得到 %s", parent.Reason) + } + if parent.StalledHours != 1.5 { + t.Fatalf("父項停滯應以子項有效活動(-90m → 1.5h)計,得到 %.1f", parent.StalledHours) + } +} + +// TestRunStalledTimelineAllXrefFallback 回歸測試(agents #40、teai #34): +// issue 自身 updated_at 晚於全部已知直接活動、且 timeline 全為引用事件時, +// 回退已知直接活動的最晚時刻(不高估活動、不低估停滯); +// timeline 拉取失敗則保守沿用 updated_at。 +func TestRunStalledTimelineAllXrefFallback(t *testing.T) { + src := newStalledFixture() + setIssueBody(src, "alterminal", "agents", 1, "") // 移除父項引用,teai#2 獨立判定 + delete(src.comments, "alterminal/teai/2") + // agents#1 給一則近期留言,避免本身停滯(10h)干擾本測試的斷言。 + src.comments["alterminal/agents/1"] = []Comment{ + {CreatedAt: iso(-10), User: &User{UserName: "alex"}}, + } + // teai#2:僅有 created(-500) 此一已知直接活動, + // updated_at -10 完全由他處提及刷新。 + issue := src.issues["alterminal/teai"][2] + issue.UpdatedAt = iso(-10) + src.issues["alterminal/teai"][2] = issue + // 情境一:timeline 全為引用事件 → 回退 knownMax(created -500) + // → 停滯 8.3h,超過預設門檻 4h,列入。 + src.timeline = map[string][]TimelineEvent{ + "alterminal/teai/2": {{Type: "issue_ref", CreatedAt: iso(-10)}}, + } + items, err := RunStalled(src, 0, at(0)) + if err != nil { + t.Fatalf("RunStalled: %v", err) + } + if len(items) != 1 || items[0].Number != 2 { + t.Fatalf("全引用事件應回退直接活動 → teai#2 停滯 8.3h 列入,得到 %+v", items) + } + if items[0].StalledHours != 8.3 { + t.Fatalf("回退 created(-500) → 停滯應 8.3h,得到 %.1f", items[0].StalledHours) + } + // 情境二:timeline 拉取失敗 → 保守沿用 updated_at(-10) → 0.2h 未達門檻,不列入。 + src.timeline = nil + src.timelineErr = map[string]error{"alterminal/teai/2": errors.New("boom")} + items, err = RunStalled(src, 0, at(0)) + if err != nil { + t.Fatalf("RunStalled: %v", err) + } + if len(items) != 0 { + t.Fatalf("timeline 失敗應保守沿用 updated_at → 不列入,得到 %+v", items) + } +} diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index 3c904cf..8ca8f59 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -30,6 +30,11 @@ const ( NudgeCooldownHours = 24.0 ) +// xrefTimelineTypes 對應 gitea.py 的 XREF_TIMELINE_TYPES:他處留言/issue +// 提及本編號時產生的 timeline 引用事件。這類事件會刷新 updated_at,但不是 +// 本 issue 的直接活動,directLastActivity 會予以排除(agents issue #34)。 +var xrefTimelineTypes = map[string]bool{"comment_ref": true, "issue_ref": true} + // xrefPattern 對應 gitea.py 的 XREF_PATTERN:#N、repo#N、owner/repo#N。 // // 前面不得是文數字/底線/斜線(避免切進 alterminal/bear#47 的後半段); @@ -149,6 +154,9 @@ type Source interface { IssueComments(owner, repo string, number int64) ([]Comment, error) // TryGetIssue 寬容版單一 issue 查詢:失敗(含 404)回 (零值, false, nil)。 TryGetIssue(owner, repo string, number int64) (Issue, bool, error) + // IssueTimeline 回傳 issue 的 timeline 事件(GET /repos/{o}/{r}/issues/{n}/timeline, + // 分頁)。directLastActivity 用來排除跨 issue 引用事件(agents issue #34)。 + IssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error) } // iterXrefs 產出文字中的 (prefix, number) 配對(對應 _iter_issue_xrefs)。 @@ -268,8 +276,11 @@ func childActivity(src Source, org, repo string, parent Issue, comments []Commen if pcOK && ccOK && !childCreated.After(parentCreated) { continue // 上行引用(父項回指更早或同時的 issue);時間未知則不排除 } + // 子項的直接活動:created_at、state 變更(closed_at)、留言(agents #32)。 + // 原始 updated_at 不放入已知活動,另經 directLastActivity 核對後採計 + //(與 considerIssue/considerPull 對本 issue 自身的處理同構)。 var ms []time.Time - if m, ok := parseMoment(child.UpdatedAt); ok { + if m, ok := parseMoment(child.CreatedAt); ok { ms = append(ms, m) } if m, ok := parseMoment(child.ClosedAt); ok { @@ -282,6 +293,14 @@ func childActivity(src Source, org, repo string, parent Issue, comments []Commen } } } + if childUpdated, ok := parseMoment(child.UpdatedAt); ok { + // 子項 updated_at 可能被 comment_ref/issue_ref 引用事件刷新 + //(agents #38):走與本 issue 自身相同的 directLastActivity 排除 + // 邏輯(同源判定,不漂移);timeline 失敗時保守沿用 updated_at。 + if d, ok := directLastActivity(src, t.Owner, t.Repo, t.Number, childUpdated, true, ms); ok { + ms = append(ms, d) + } + } var last *string if len(ms) > 0 { latest := maxTime(ms) @@ -302,6 +321,71 @@ func childActivity(src Source, org, repo string, parent Issue, comments []Commen return moments, children } +// TimelineEvent 是 issue timeline 的單一事件(僅取判斷所需欄位; +// 對應 gitea.py _direct_last_activity 讀取的 timeline 物件)。 +type TimelineEvent struct { + Type string `json:"type"` + CreatedAt string `json:"created_at"` +} + +// directLastActivity 排除跨 issue 引用事件後,本 issue 的最後直接活動時刻 +// (對應 gitea.py 的 _direct_last_activity;agents issues #34/#38/#40)。 +// +// 他處留言/issue 提及本編號(comment_ref/issue_ref)也會刷新 updated_at, +// 直接採用會虛假刷新停滯時鐘(false negative)並低估 stalled_hours。 +// updated_at 不晚於已知直接活動(留言、created_at、#32 納入的子項活動)時, +// updated_at 本身可信,不必多打一次 timeline API;只有 updated_at 晚於全部 +// 已知活動時才拉 timeline 核對:排除引用類事件後取最晚事件時刻;若最晚的 +// 都是引用事件(本 issue 無直接活動可對應 updated_at),則回退已知直接活動 +// 的最晚時刻(agents #40)。timeline 拉取失敗時沿用 updated_at(不中斷掃描、 +// 寧可少報不誤報缺失活動)。 +func directLastActivity(src Source, org, repo string, number int64, updatedAt time.Time, hasUpdated bool, known []time.Time) (time.Time, bool) { + if !hasUpdated { + return time.Time{}, false + } + var knownMax *time.Time + for i := range known { + if known[i].IsZero() { + continue + } + if knownMax == nil || known[i].After(*knownMax) { + m := known[i] + knownMax = &m + } + } + if knownMax == nil || !updatedAt.After(*knownMax) { + return updatedAt, true + } + timeline, err := src.IssueTimeline(org, repo, number) + if err != nil { + return updatedAt, true + } + var moments []time.Time + for _, e := range timeline { + if xrefTimelineTypes[e.Type] { + continue + } + if m, ok := parseMoment(e.CreatedAt); ok { + moments = append(moments, m) + } + } + if len(moments) == 0 { + // timeline 全為引用事件:updated_at 完全由他處提及刷新,本 issue 沒有 + // 可對應的直接活動。回退已知直接活動的最晚時刻,避免把被刷新的 + // updated_at 當活動、低估停滯時數(agents #40);連已知活動都沒有時才 + // 保守沿用 updated_at(不中斷掃描、寧可少報不誤報缺失活動)。 + if knownMax != nil { + return *knownMax, true + } + return updatedAt, true + } + latest := maxTime(moments) + if latest.Before(updatedAt) { + return latest, true + } + return updatedAt, true +} + func maxTime(ts []time.Time) time.Time { best := ts[0] for _, t := range ts[1:] { @@ -382,7 +466,7 @@ func ScanStalled(src Source, username string, threshold float64, now time.Time) if err != nil { continue } - if item, ok := considerPull(pr, comments, username, threshold, now); ok { + if item, ok := considerPull(src, org, r.Name, pr, comments, username, threshold, now); ok { items = append(items, item) } } @@ -398,19 +482,27 @@ func ScanStalled(src Source, username string, threshold float64, now time.Time) func considerIssue(src Source, org, repo string, issue Issue, comments []Comment, username string, threshold float64, now time.Time, myOrgs map[string]bool, orgRepos func(string) ([]string, error)) (Item, bool) { childMoments, children := childActivity(src, org, repo, issue, comments, myOrgs, orgRepos) assignees := AssigneeNames(&issue) - var moments []time.Time - if m, ok := parseMoment(issue.UpdatedAt); ok { - moments = append(moments, m) - } + // 已知直接活動:留言、created_at、#32 納入的子項活動。 + var known []time.Time if m, ok := parseMoment(issue.CreatedAt); ok { - moments = append(moments, m) + known = append(known, m) } for _, c := range comments { if m, ok := parseMoment(c.CreatedAt); ok { - moments = append(moments, m) + known = append(known, m) } } - moments = append(moments, childMoments...) + known = append(known, childMoments...) + // 跨 issue 引用(comment_ref/issue_ref)也會刷新 updated_at(agents #34): + // updated_at 晚於全部已知直接活動時,先排除引用事件核對後才採用, + // 避免停滯時鐘被虛假刷新或低估 stalled_hours。 + var moments []time.Time + if m, ok := parseMoment(issue.UpdatedAt); ok { + if d, ok2 := directLastActivity(src, org, repo, issue.issueNumber(), m, ok, known); ok2 { + moments = append(moments, d) + } + } + moments = append(moments, known...) if len(moments) == 0 { return Item{}, false } @@ -455,21 +547,28 @@ func considerIssue(src Source, org, repo string, issue Issue, comments []Comment } // considerPull 對單一 PR 套用停滯判定(對應 _consider 的 pull 分支)。 -func considerPull(pr PullRequest, comments []Comment, username string, threshold float64, now time.Time) (Item, bool) { +func considerPull(src Source, org, repo string, pr PullRequest, comments []Comment, username string, threshold float64, now time.Time) (Item, bool) { reviewers := ReviewerNames(&pr) author := pr.Author() - var moments []time.Time - if m, ok := parseMoment(pr.UpdatedAt); ok { - moments = append(moments, m) - } + // 已知直接活動:留言、created_at。 + var known []time.Time if m, ok := parseMoment(pr.CreatedAt); ok { - moments = append(moments, m) + known = append(known, m) } for _, c := range comments { if m, ok := parseMoment(c.CreatedAt); ok { - moments = append(moments, m) + known = append(known, m) } } + // 跨 issue 引用也會刷新 PR 的 updated_at(agents #34):與 issue 同一道 + // directLastActivity 排除邏輯(同源判定,不漂移)。 + var moments []time.Time + if m, ok := parseMoment(pr.UpdatedAt); ok { + if d, ok2 := directLastActivity(src, org, repo, pr.Number, m, ok, known); ok2 { + moments = append(moments, d) + } + } + moments = append(moments, known...) if len(moments) == 0 { return Item{}, false } diff --git a/internal/workflow/workflow_test.go b/internal/workflow/workflow_test.go index ff8d2f0..a05a200 100644 --- a/internal/workflow/workflow_test.go +++ b/internal/workflow/workflow_test.go @@ -28,6 +28,8 @@ type fakeSource struct { pulls map[string][]PullRequest // comments[owner/repo/number] comments map[string][]Comment + // timelines[owner/repo/number]:issue timeline 事件(nil → 空 timeline)。 + timelines map[string][]TimelineEvent } func (f *fakeSource) MyOrganizations() ([]string, error) { return f.orgs, nil } @@ -52,6 +54,10 @@ func (f *fakeSource) IssueComments(owner, repo string, number int64) ([]Comment, return f.comments[fmt.Sprintf("%s/%s/%d", owner, repo, number)], nil } +func (f *fakeSource) IssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error) { + return f.timelines[fmt.Sprintf("%s/%s/%d", owner, repo, number)], nil +} + func (f *fakeSource) TryGetIssue(owner, repo string, number int64) (Issue, bool, error) { i, ok := f.issues[owner+"/"+repo][number] if !ok { @@ -314,6 +320,13 @@ func TestScanStalledParentTrackingNotChild(t *testing.T) { }, }, } + // timeline:#47 的 updated_at 對應直接事件(assignees)、#39 對應 label + //(agents #34 後 updated_at 需經 timeline 核對才採計;直接事件可對應 → 採用)。 + // 必須在 ScanStalled 之前設定——掃描當下讀不到的事後修改無效。 + src.timelines = map[string][]TimelineEvent{ + "alterminal/teai/47": {{Type: "assignees", CreatedAt: iso(-600)}}, + "alterminal/teai/39": {{Type: "label", CreatedAt: iso(-60)}}, + } got, err := ScanStalled(src, "admin", 4.0, now) if err != nil { t.Fatalf("ScanStalled: %v", err) @@ -515,6 +528,9 @@ func (e errSource) IssueComments(string, string, int64) ([]Comment, error) { func (e errSource) TryGetIssue(string, string, int64) (Issue, bool, error) { return Issue{}, false, errors.New("boom") } +func (e errSource) IssueTimeline(string, string, int64) ([]TimelineEvent, error) { + return nil, errors.New("boom") +} // TestRound1 round1 十進位舍入到一位小數(與 Python round(x,1) 半偶數一致)。 func TestRound1(t *testing.T) {