Compare commits

..
Author SHA1 Message Date
chenyunda218 4a5d02fd70 fix: stalled PR 作者已交棒審核改判 reviewer-idle(agents #51)
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)。
2026-09-16 13:17:55 +08:00
ceo a9abf96a42 Merge pull request 'fix:stalled 分類同步 agents#47——assignee 交付報告待確認改判 waiting-outside' (#49) from fix/stalled-delivery-report into main 2026-09-15 04:32:01 +08:00
chenyunda218 1d9092cdde fix:stalled 分類同步 agents#47——assignee 交付報告待確認改判 waiting-outside
與 agents 86f570a(PR alterminal/agents#48)同語義:最後一則 assignee
留言符合交付報告特徵(結構訊號:逐項查核/驗收結果、彙總 verdict、內文
含「完成定義」段落時簡式交付語也算;∧ 交棒訊號:回報/關閉他件 #N、
或請/可由/待他人確認關閉)時,assignee-idle 改判 waiting-outside。

兩訊號同時命中才翻轉:認領/進度/預告式留言(無交棒對象)保持
assignee-idle,不製造「認領後棄置永不被催」的新死角。

驗證:TestIsDeliveryReport(8 案例,與 gitea.py 離線驗證同集)+
TestScanStalledDeliveryReport(stalled 整合:交付報告翻轉、認領不翻轉)
+全套 go test 綠;live A/B 對安裝版二進位:bear#52 assignee-idle →
waiting-outside,bear#39 parent-tracking 不變。
2026-09-15 04:28:08 +08:00
5 changed files with 281 additions and 6 deletions
+11
View File
@@ -237,6 +237,17 @@ PR 改帶 `author` 與 `reviewers` 欄位。`reason` 取值:
- issue:`no-assignee`、`assignee-idle`、`no-commenter`、`waiting-outside`、`parent-tracking` - issue:`no-assignee`、`assignee-idle`、`no-commenter`、`waiting-outside`、`parent-tracking`
- PR:`no-reviewer`、`author-idle`、`reviewer-idle`、`no-commenter` - PR:`no-reviewer`、`author-idle`、`reviewer-idle`、`no-commenter`
`assignee-idle` 判定前會先檢查最後一則 assignee 留言是否為**交付報告**(agents #47):
逐項查核/驗收結果(結構訊號)∧ 回報他件/請他人確認關閉(交棒訊號)同時命中 →
改判 `waiting-outside`——已交付待發起人確認不是 assignee 停滯。認領/進度/預告式
留言(無交棒對象)維持 `assignee-idle`,不製造「認領後棄置永不被催」的新死角。
`author-idle` 判定前會先檢查 PR 作者的最後一則留言是否為**審核交棒**(agents #51):
@目前審核者帳號 ∧ 就緒語(已就緒/可審閱/請審閱)同時命中 → 改判 `reviewer-idle`
——作者已表明就緒並點名審核者,球在審核者,讓既有 reviewer 催促與 48 小時改派規則
自然適用。@的帳號必須在目前審核者清單且為完整匹配(@chenyunda218 不算提及
chenyunda);僅就緒語而無點名對象(進度回報、預告式)維持 `author-idle`。
### `teai milestones` — 跨倉庫里程碑總覽 ### `teai milestones` — 跨倉庫里程碑總覽
```sh ```sh
+139 -1
View File
@@ -1,6 +1,10 @@
package workflow package workflow
import "sort" import (
"regexp"
"sort"
"strings"
)
// sortComments 依 created_at 字典序穩定排序(ISO8601 字串字典序與時間序一致), // sortComments 依 created_at 字典序穩定排序(ISO8601 字串字典序與時間序一致),
// 對應 gitea.py 各處的 comments.sort(key=created_at)。 // 對應 gitea.py 各處的 comments.sort(key=created_at)。
@@ -135,3 +139,137 @@ func LastCommentNotByAssignee(issue *Issue, comments []Comment) bool {
func IssueNeedsWork(issue *Issue, comments []Comment, members []string) bool { func IssueNeedsWork(issue *Issue, comments []Comment, members []string) bool {
return AssigneeOverlaps(issue, members) && LastCommentNotByAssignee(issue, comments) return AssigneeOverlaps(issue, members) && LastCommentNotByAssignee(issue, comments)
} }
// ── 交付報告判定(agents #47,同步 gitea.py _is_delivery_report)──────────
//
// 最後一則 assignee 留言已把工作交棒給發起人/他人確認(逐項查核/驗收
// 結果+回報他件或請確認關閉)時,停滯點不在 assignee:stalled 分類由
// assignee-idle 改判 waiting-outside,管理者巡邏不再對已交付、待確認的
// 處理者觸發自動催促。
var (
// deliveryVerdictRe 對應 gitea.py _DELIVERY_VERDICT_RE:彙總 verdict 語。
deliveryVerdictRe = regexp.MustCompile(`(全數|全部|\d+\s*項)通過|項全過|不通過|未通過`)
// deliveryHandoffRe 對應 gitea.py _DELIVERY_HANDOFF_RE:交棒訊號。
deliveryHandoffRe = regexp.MustCompile(`回報\s*#\d+|關閉\s*#\d+|請[^\n。]{0,40}(確認|關閉)|可由[^\n。]{0,40}關閉|待[^\n。]{0,20}確認`)
// completionDefinitionRe 對應 _has_completion_definition:內文「完成定義」
// 段落(ATX 標題或行首字樣)。
completionDefinitionRe = regexp.MustCompile(`(?m)^#{1,6}\s*完成定義|^完成定義\s*[::]?`)
// simpleDeliveryRe 內文有「完成定義」時的簡式交付語。
simpleDeliveryRe = regexp.MustCompile(`已完成|已交付|已通過|已全數`)
)
// hasCompletionDefinition 內文是否含「完成定義」段落。
func hasCompletionDefinition(body string) bool {
return completionDefinitionRe.MatchString(body)
}
// IsDeliveryReport 判定最後一則 assignee 留言是否符合交付報告特徵
// (agents #47;gitea.py _is_delivery_report 的同語義實作)。
//
// 交付報告=「結構訊號」∧「交棒訊號」同時命中:
// - 結構訊號:逐項查核/驗收/測試結果、結論、或彙總 verdict 語;內文有
// 「完成定義」段落時,簡式交付語(已完成/已交付)也算。
// - 交棒訊號:回報/關閉他件(#N)、或請/可由/待他人確認關閉。
//
// 只命中結構訊號不算:認領/進度/預告式留言(「完成後逐項回報結果至此」)
// 沒有交棒對象,仍是 assignee 自己該做——保持 assignee-idle,修正不製造
// 「認領後棄置永不被催促」的新死角。
func IsDeliveryReport(issueBody string, last *Comment) bool {
if last == nil {
return false
}
body := last.Body
if body == "" {
return false
}
structural := false
for _, marker := range []string{"查核結果", "驗收結果", "測試結果", "逐項", "結論", "完成定義"} {
if strings.Contains(body, marker) {
structural = true
break
}
}
if !structural && deliveryVerdictRe.MatchString(body) {
structural = true
}
if !structural && hasCompletionDefinition(issueBody) && simpleDeliveryRe.MatchString(body) {
structural = true
}
if !structural {
return false
}
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
}
+70
View File
@@ -143,3 +143,73 @@ func TestFullNameFallbacks(t *testing.T) {
t.Errorf("全缺 → 空,得到 %q", got) t.Errorf("全缺 → 空,得到 %q", got)
} }
} }
// TestIsDeliveryReport(agents #47):交付報告=結構訊號 ∧ 交棒訊號。
// 案例與 gitea.py _is_delivery_report 的離線驗證同集(bear#52 實例)。
func TestIsDeliveryReport(t *testing.T) {
body52 := "## 完成定義\n- 逐項回報查核結果至此 issue;\n- 全數通過 → 回報 #39 並由 #39 assignee 關閉"
claim := "認領。我來執行四項驗收查核,完成後逐項回報結果至此。"
report := "## 全階段驗收查核結果(#52)\n**結論:四項全數通過。** 逐項如下…\n四項全過,符合「完成定義」。回報 #39:全數驗收通過,可由 @ceo 關閉 #39 與里程碑。"
reportFail := "## 查核結果\n結論:第 2 項不通過。已開立修復 issue #60,請 @ceo 確認分配。"
cases := []struct {
name string
issueBody string
comment string
want bool
}{
{"交付報告+回報#39+@ceo 關閉", body52, report, true},
{"失敗型交付(開修復 issue+請確認)", body52, reportFail, true},
{"認領留言(逐項+回報 但無交棒)", body52, claim, false},
{"純進度留言", body52, "進行中,已看完 P1,預計今晚交付。", false},
{"已完成但無交棒", body52, "已完成,程式碼都在分支上。", false},
{"無完成定義內文+逐項交棒", "一般 issue 內文", "逐項測試結果全數通過,請 @ceo 確認後關閉。", true},
{"無完成定義+僅交棒(無結構)", "一般 issue 內文", "請 @ceo 確認後關閉。", false},
{"空留言", body52, "", false},
}
for _, tc := range cases {
var last *Comment
if tc.comment != "" {
last = &Comment{Body: tc.comment}
}
if got := IsDeliveryReport(tc.issueBody, last); got != tc.want {
t.Errorf("%s:IsDeliveryReport = %v,want %v", tc.name, got, tc.want)
}
}
if IsDeliveryReport(body52, nil) {
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")
}
}
+13
View File
@@ -526,7 +526,13 @@ func considerIssue(src Source, org, repo string, issue Issue, comments []Comment
case len(assignees) == 0: case len(assignees) == 0:
reason = "no-assignee" reason = "no-assignee"
case lastCommentBy != "" && containsString(assignees, lastCommentBy): case lastCommentBy != "" && containsString(assignees, lastCommentBy):
if IsDeliveryReport(issue.Body, last) {
// 交付報告待確認(agents #47):assignee 已交付並交棒(回報他件/
// 請發起人確認關閉),停滯點在等待外部確認,不是 assignee 停滯。
reason = "waiting-outside"
} else {
reason = "assignee-idle" reason = "assignee-idle"
}
case lastCommentBy == "": case lastCommentBy == "":
reason = "no-commenter" reason = "no-commenter"
} }
@@ -596,7 +602,14 @@ func considerPull(src Source, org, repo string, pr PullRequest, comments []Comme
case len(reviewers) == 0: case len(reviewers) == 0:
reason = "no-reviewer" reason = "no-reviewer"
case lastCommentBy == author: case lastCommentBy == author:
if IsReviewHandoff(last, reviewers) {
// 審核交棒(agents #51):作者已表明 PR 就緒並 @目前審核者,
// 停滯點在審核者,不是作者停滯——球在審核者,讓既有
// reviewer 催促與 48 小時改派規則自然適用。
reason = "reviewer-idle"
} else {
reason = "author-idle" reason = "author-idle"
}
case lastCommentBy != "" && containsString(reviewers, lastCommentBy): case lastCommentBy != "" && containsString(reviewers, lastCommentBy):
reason = "reviewer-idle" reason = "reviewer-idle"
} }
+46 -3
View File
@@ -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: 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: 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"}}}, {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{ comments: map[string][]Comment{
"alterminal/teai/11": {{User: &User{UserName: "max"}, CreatedAt: iso(-300)}}, // author-idle "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/12": {{User: &User{UserName: "ceo"}, CreatedAt: iso(-300)}}, // reviewer-idle
"alterminal/teai/13": {{User: &User{UserName: "dan"}, CreatedAt: iso(-300)}}, // no-commenter(外部留言) "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) got, err := ScanStalled(src, "admin", 4.0, now)
@@ -430,7 +434,7 @@ func TestScanStalledPullReasons(t *testing.T) {
for _, it := range got { for _, it := range got {
byNumber[it.Number] = it 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] item, ok := byNumber[n]
if !ok { if !ok {
t.Fatalf("PR %d 應列入,got %v", n, got) 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) t.Errorf("PR %d reason = %q, want %q", n, item.Reason, wantReason)
} }
} }
if len(got) != 4 { if len(got) != 5 {
t.Fatalf("應 4 筆,got %d", len(got)) t.Fatalf("應 5 筆,got %d", len(got))
} }
if byNumber[10].Author != "max" { if byNumber[10].Author != "max" {
t.Errorf("PR 10 author 應為 max,got %q", byNumber[10].Author) 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。 // TestScanStalledNoPullsFlag 停用 PR 的倉庫仍掃 issues,只跳過 PR。
@@ -595,3 +602,39 @@ func TestRound1(t *testing.T) {
} }
} }
} }
// TestScanStalledDeliveryReport(agents #47):assignee 最後留言為交付報告
// (結構訊號+交棒訊號)→ waiting-outside,不是 assignee-idle;
// 同型認領留言(無交棒)維持 assignee-idle——修正不製造新死角。
func TestScanStalledDeliveryReport(t *testing.T) {
now := at(0)
defBody := "## 完成定義\n- 逐項回報查核結果至此 issue;\n- 全數通過 → 回報 #39 並由 #39 assignee 關閉"
src := &fakeSource{
orgs: []string{"alterminal"},
repos: map[string][]Repo{"alterminal": repoWith("bear", true)},
issues: map[string]map[int64]Issue{
"alterminal/bear": {
5: {Number: 5, Title: "驗收", State: "open", Body: defBody, CreatedAt: iso(-600), UpdatedAt: iso(-300), Assignees: []User{{UserName: "queena"}}, Repository: &Repository{FullName: "alterminal/bear"}},
6: {Number: 6, Title: "認領中", State: "open", Body: defBody, CreatedAt: iso(-600), UpdatedAt: iso(-300), Assignees: []User{{UserName: "dan"}}, Repository: &Repository{FullName: "alterminal/bear"}},
},
},
comments: map[string][]Comment{
"alterminal/bear/5": {{User: &User{UserName: "queena"}, CreatedAt: iso(-300), Body: "## 查核結果\n結論:四項全數通過。回報 #39:可由 @ceo 關閉。"}},
"alterminal/bear/6": {{User: &User{UserName: "dan"}, CreatedAt: iso(-300), Body: "認領。完成後逐項回報結果至此。"}},
},
}
got, err := ScanStalled(src, "admin", 4.0, now)
if err != nil {
t.Fatalf("ScanStalled: %v", err)
}
byNumber := map[int64]Item{}
for _, it := range got {
byNumber[it.Number] = it
}
if it, ok := byNumber[5]; !ok || it.Reason != "waiting-outside" {
t.Errorf("issue 5(交付報告)reason 應為 waiting-outside,got %+v", it)
}
if it, ok := byNumber[6]; !ok || it.Reason != "assignee-idle" {
t.Errorf("issue 6(認領留言)reason 應為 assignee-idle,got %+v", it)
}
}