From 4a5d02fd7040d7679775048cbf53c9d61ed28e67 Mon Sep 17 00:00:00 2001 From: chenyunda218 Date: Wed, 16 Sep 2026 13:17:55 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20stalled=20PR=20=E4=BD=9C=E8=80=85?= =?UTF-8?q?=E5=B7=B2=E4=BA=A4=E6=A3=92=E5=AF=A9=E6=A0=B8=E6=94=B9=E5=88=A4?= =?UTF-8?q?=20reviewer-idle=EF=BC=88agents=20#51=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit considerPull 的 author-idle 分支缺少 #47 的交棒檢查,且既有 IsDeliveryReport 訊號為 issue 交付報告設計,PR 端「已就緒,可審閱+ @審核者」形態無對應訊號——fox#60 作者留言交棒後仍被誤判 author-idle。 新增 IsReviewHandoff(@目前審核者帳號 ∧ 就緒語,mention 邊界感知 避免 @chenyunda218 誤命中 chenyunda),considerPull 在判 author-idle 前檢查,命中改判 reviewer-idle,讓既有 48 小時改派規則自然適用。 gitea.py 側 _consider pull 分支以 _is_review_handoff 同步(另 PR)。 --- README.md | 6 +++ internal/workflow/judge.go | 72 ++++++++++++++++++++++++++++++ internal/workflow/judge_test.go | 34 ++++++++++++++ internal/workflow/workflow.go | 9 +++- internal/workflow/workflow_test.go | 13 ++++-- 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f257f0c..72eaaa4 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,12 @@ PR 改帶 `author` 與 `reviewers` 欄位。`reason` 取值: 改判 `waiting-outside`——已交付待發起人確認不是 assignee 停滯。認領/進度/預告式 留言(無交棒對象)維持 `assignee-idle`,不製造「認領後棄置永不被催」的新死角。 +`author-idle` 判定前會先檢查 PR 作者的最後一則留言是否為**審核交棒**(agents #51): +@目前審核者帳號 ∧ 就緒語(已就緒/可審閱/請審閱)同時命中 → 改判 `reviewer-idle` +——作者已表明就緒並點名審核者,球在審核者,讓既有 reviewer 催促與 48 小時改派規則 +自然適用。@的帳號必須在目前審核者清單且為完整匹配(@chenyunda218 不算提及 +chenyunda);僅就緒語而無點名對象(進度回報、預告式)維持 `author-idle`。 + ### `teai milestones` — 跨倉庫里程碑總覽 ```sh diff --git a/internal/workflow/judge.go b/internal/workflow/judge.go index 8e64c96..cdf2274 100644 --- a/internal/workflow/judge.go +++ b/internal/workflow/judge.go @@ -201,3 +201,75 @@ func IsDeliveryReport(issueBody string, last *Comment) bool { } return deliveryHandoffRe.MatchString(body) } + +// ── 審核交棒判定(agents #51,同步 gitea.py _is_review_handoff)────────── +// +// PR 作者的最後一則留言已把審核交棒給審核者(@審核者帳號+就緒語)時, +// 停滯點不在作者:stalled 分類由 author-idle 改判 reviewer-idle,既有 +// 48 小時改派規則自然適用。案例:fox#60 作者留言「PR 已就緒,可審閱」 +// 並 @chenyunda218,teai stalled 仍判 author-idle——作者已無待辦。 + +var ( + // reviewReadyRe 對應 gitea.py _REVIEW_READY_RE:PR 端就緒語。 + reviewReadyRe = regexp.MustCompile(`已就緒|可審閱|請審閱`) +) + +// IsReviewHandoff 判定 PR 作者的最後一則留言是否已把審核交棒給審核者 +// (agents #51;gitea.py _is_review_handoff 的同語義實作)。 +// +// 審核交棒=「@審核者帳號」∧「就緒語」同時命中:作者已表明 PR 就緒、 +// 明確點名目前指定的審核者,球在審核者——author-idle 改判 reviewer-idle。 +// +// 只命中就緒語不算:進度回報(「快好了,即將就緒」)沒有點名對象,作者 +// 仍可能有待辦——保持 author-idle,修正不製造「丟一句已就緒就永不被催」 +// 的新死角。@的帳號不在目前審核者清單也不算:交棒對象需為實際審核者。 +func IsReviewHandoff(last *Comment, reviewers []string) bool { + if last == nil { + return false + } + body := last.Body + if body == "" || len(reviewers) == 0 { + return false + } + if !reviewReadyRe.MatchString(body) { + return false + } + for _, r := range reviewers { + if mentionsUser(body, r) { + return true + } + } + return false +} + +// mentionsUser 判定 body 是否 @ 了指定帳號(agents #51)。 +// 帳號字元後需為非帳號字元或文末,避免前綴碰撞——如 @chenyunda218 +// 被誤判為提及帳號 chenyunda(兩者可在同一組織並存)。 +func mentionsUser(body, name string) bool { + if name == "" { + return false + } + mention := "@" + name + for from := 0; ; { + idx := strings.Index(body[from:], mention) + if idx < 0 { + return false + } + end := from + idx + len(mention) + if end >= len(body) || !isUsernameByte(body[end]) { + return true + } + from = from + idx + 1 + } +} + +// isUsernameByte 判定位元組是否屬於 Gitea 帳號字元(字母數字與 _ . -)。 +func isUsernameByte(b byte) bool { + switch { + case b >= 'a' && b <= 'z', b >= 'A' && b <= 'Z', b >= '0' && b <= '9': + return true + case b == '_' || b == '.' || b == '-': + return true + } + return false +} diff --git a/internal/workflow/judge_test.go b/internal/workflow/judge_test.go index 011fbfa..a58ce1a 100644 --- a/internal/workflow/judge_test.go +++ b/internal/workflow/judge_test.go @@ -179,3 +179,37 @@ func TestIsDeliveryReport(t *testing.T) { t.Error("nil 最後留言應為 false") } } + +// TestIsReviewHandoff(agents #51):審核交棒=@審核者帳號 ∧ 就緒語。 +// 案例與 gitea.py _is_review_handoff 的離線驗證同集(fox#60 實例)。 +func TestIsReviewHandoff(t *testing.T) { + // fox#60 作者(alex)最後留言結尾(2026-09-16 08:05)。 + fox60 := "狀態回報(作者端自查,分支無需再變更):\n\n- 已重新核對分支與 main 的差異…\n- PR 分支 CI 已通過(run 209)。\n\n@chenyunda218 PR 已就緒,可審閱。@ceo 已知悉管理者巡邏的備註;審核合併後,我會在下一個 main 部署 run 跑通時回 #58 驗收。" + cases := []struct { + name string + comment string + reviewers []string + want bool + }{ + {"fox#60 實例(@審核者+已就緒,可審閱)", fox60, []string{"chenyunda218"}, true}, + {"多位審核者命中其一", fox60, []string{"ceo", "chenyunda218"}, true}, + {"前綴碰撞(@chenyunda218 ≠ chenyunda)", fox60, []string{"chenyunda"}, false}, + {"就緒語但無 @(無點名對象)", fox60, []string{"dan"}, false}, + {"進度回報(無就緒語)", "進行中:已改完兩步,CI 跑著,@chenyunda218 稍後再看。", []string{"chenyunda218"}, false}, + {"預告式(即將就緒不算)", "@chenyunda218 快好了,即將整理完成。", []string{"chenyunda218"}, false}, + {"無審核者", fox60, nil, false}, + {"空留言", "", []string{"chenyunda218"}, false}, + } + for _, tc := range cases { + var last *Comment + if tc.comment != "" { + last = &Comment{Body: tc.comment} + } + if got := IsReviewHandoff(last, tc.reviewers); got != tc.want { + t.Errorf("%s:IsReviewHandoff = %v,want %v", tc.name, got, tc.want) + } + } + if IsReviewHandoff(nil, []string{"chenyunda218"}) { + t.Error("nil 最後留言應為 false") + } +} diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index 3299df0..d213a59 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -602,7 +602,14 @@ func considerPull(src Source, org, repo string, pr PullRequest, comments []Comme case len(reviewers) == 0: reason = "no-reviewer" case lastCommentBy == author: - reason = "author-idle" + if IsReviewHandoff(last, reviewers) { + // 審核交棒(agents #51):作者已表明 PR 就緒並 @目前審核者, + // 停滯點在審核者,不是作者停滯——球在審核者,讓既有 + // reviewer 催促與 48 小時改派規則自然適用。 + reason = "reviewer-idle" + } else { + reason = "author-idle" + } case lastCommentBy != "" && containsString(reviewers, lastCommentBy): reason = "reviewer-idle" } diff --git a/internal/workflow/workflow_test.go b/internal/workflow/workflow_test.go index bbd7480..f958b8c 100644 --- a/internal/workflow/workflow_test.go +++ b/internal/workflow/workflow_test.go @@ -414,12 +414,16 @@ func TestScanStalledPullReasons(t *testing.T) { {Number: 11, User: &User{UserName: "max"}, CreatedAt: iso(-600), UpdatedAt: iso(-300), RequestedReviewers: []User{{UserName: "ceo"}}}, {Number: 12, User: &User{UserName: "max"}, CreatedAt: iso(-600), UpdatedAt: iso(-300), RequestedReviewers: []User{{UserName: "ceo"}}}, {Number: 13, User: &User{UserName: "max"}, CreatedAt: iso(-600), UpdatedAt: iso(-300), RequestedReviewers: []User{{UserName: "ceo"}}}, + // agents #51:作者最後留言含審核交棒(@審核者+就緒語)→ reviewer-idle。 + {Number: 14, User: &User{UserName: "max"}, CreatedAt: iso(-600), UpdatedAt: iso(-300), RequestedReviewers: []User{{UserName: "chenyunda218"}}}, }, }, comments: map[string][]Comment{ "alterminal/teai/11": {{User: &User{UserName: "max"}, CreatedAt: iso(-300)}}, // author-idle "alterminal/teai/12": {{User: &User{UserName: "ceo"}, CreatedAt: iso(-300)}}, // reviewer-idle "alterminal/teai/13": {{User: &User{UserName: "dan"}, CreatedAt: iso(-300)}}, // no-commenter(外部留言) + // fox#60 形態(agents #51):作者留言 @chenyunda218+「已就緒,可審閱」→ 審核交棒。 + "alterminal/teai/14": {{User: &User{UserName: "max"}, CreatedAt: iso(-300), Body: "狀態回報:分支無需再變更,CI 已通過。@chenyunda218 PR 已就緒,可審閱。"}}, }, } got, err := ScanStalled(src, "admin", 4.0, now) @@ -430,7 +434,7 @@ func TestScanStalledPullReasons(t *testing.T) { for _, it := range got { byNumber[it.Number] = it } - for n, wantReason := range map[int64]string{10: "no-reviewer", 11: "author-idle", 12: "reviewer-idle", 13: "no-commenter"} { + for n, wantReason := range map[int64]string{10: "no-reviewer", 11: "author-idle", 12: "reviewer-idle", 13: "no-commenter", 14: "reviewer-idle"} { item, ok := byNumber[n] if !ok { t.Fatalf("PR %d 應列入,got %v", n, got) @@ -439,12 +443,15 @@ func TestScanStalledPullReasons(t *testing.T) { t.Errorf("PR %d reason = %q, want %q", n, item.Reason, wantReason) } } - if len(got) != 4 { - t.Fatalf("應 4 筆,got %d", len(got)) + if len(got) != 5 { + t.Fatalf("應 5 筆,got %d", len(got)) } if byNumber[10].Author != "max" { t.Errorf("PR 10 author 應為 max,got %q", byNumber[10].Author) } + if byNumber[14].LastCommentBy != "max" { + t.Errorf("PR 14 last_comment_by 應為 max(作者留言後交棒),got %q", byNumber[14].LastCommentBy) + } } // TestScanStalledNoPullsFlag 停用 PR 的倉庫仍掃 issues,只跳過 PR。 -- 2.54.0