Files
teai/internal/workflow/judge.go
T
chenyunda218 8ce8f410ed fix: 審核交棒複合訊號——等候審核/請合併形態不再誤判 author-idle(agents #53)
#51 的三就緒詞漏掉「等候審核」形態:alterminal#2 作者留言「無待修改
項目…等候 @chenyunda218 審核…請直接合併」實質已交棒,仍誤判
author-idle,巡邏回頭催已無待辦的作者。

IsReviewHandoff 改複合訊號:@目前審核者 ∧(就緒語 ∨(完成語 ∧
等候語))。就緒語補「請…審核」;等候語=等候/等待…審核、待審、
請…合併(間距容 @帳號);完成語=已完成/無待修改等。新增防護:
預告式(即將/稍後/之後/預計…、條件句「…後請審核」)與百分比進度
(完成 80%)一律不算——作者自述還沒到位不能解讀成已交棒;句讀阻斷
避免誤傷「合併後,我會回報」真交棒尾巴,「後續」除外。

gitea.py _is_review_handoff 同步(另 PR);測試案例兩側同集
(fox#60、alterminal#2 實例+17 個邊界)。
2026-10-04 08:25:55 +08:00

318 lines
13 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.
package workflow
import (
"regexp"
"sort"
"strings"
)
// sortComments 依 created_at 字典序穩定排序(ISO8601 字串字典序與時間序一致),
// 對應 gitea.py 各處的 comments.sort(key=created_at)。
func sortComments(comments []Comment) []Comment {
out := append([]Comment(nil), comments...)
sort.SliceStable(out, func(a, b int) bool { return out[a].CreatedAt < out[b].CreatedAt })
return out
}
// LastComment 回傳依 created_at 排序後的最後一則留言;沒有留言回 nil。
func LastComment(comments []Comment) *Comment {
ordered := sortComments(comments)
if len(ordered) == 0 {
return nil
}
return &ordered[len(ordered)-1]
}
// lastCommentAuthor 回傳最後一則留言的作者;無留言或作者未知回空字串。
func lastCommentAuthor(comments []Comment) string {
if last := LastComment(comments); last != nil {
return last.Author()
}
return ""
}
func containsString(items []string, target string) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
// NeedsAuthorFollowup 判定 PR 是否需要發起者跟進:有留言、且最後一則留言
// 不是 PR 作者(gitea.py 的 _pr_needs_author_followup)。
// 無留言或作者未知 → false(尚無留言的 PR 不列入跟進清單)。
func NeedsAuthorFollowup(pr *PullRequest, comments []Comment) bool {
author := pr.Author()
if author == "" {
return false
}
last := lastCommentAuthor(comments)
return last != "" && last != author
}
// NeedsReviewerFollowup 判定使用者是否需要審核此 PR:被指定為審核者、
// 不是作者、最後一則留言不是自己(含尚無留言)
// (gitea.py 的 _pr_needs_reviewer_followup)。
func NeedsReviewerFollowup(pr *PullRequest, comments []Comment, username string) bool {
if username == "" {
return false
}
if pr.Author() == username {
return false
}
if !containsString(ReviewerNames(pr), username) {
return false
}
return lastCommentAuthor(comments) != username
}
// ReviewerNames 回傳 PR 的 requested reviewers(去重、排序)。
func ReviewerNames(pr *PullRequest) []string {
seen := map[string]bool{}
names := make([]string, 0, len(pr.RequestedReviewers))
for i := range pr.RequestedReviewers {
name := pr.RequestedReviewers[i].Name()
if name != "" && !seen[name] {
seen[name] = true
names = append(names, name)
}
}
sort.Strings(names)
return names
}
// AssigneeNames 回傳 issue 的全部 assignees(assignee 欄位+assignees 陣列,
// 去重、排序),對應 gitea.py 的 _assignee_usernames。
func AssigneeNames(issue *Issue) []string {
seen := map[string]bool{}
add := func(u *User) {
if u == nil {
return
}
if name := u.Name(); name != "" && !seen[name] {
seen[name] = true
}
}
add(issue.Assignee)
for i := range issue.Assignees {
add(&issue.Assignees[i])
}
names := make([]string, 0, len(seen))
for name := range seen {
names = append(names, name)
}
sort.Strings(names)
return names
}
// AssigneeOverlaps 判定 issue 是否分派給 members 中至少一人
// (members 為空 → false,同 gitea.py filter_issues_by_assignee 的空成員集行為)。
func AssigneeOverlaps(issue *Issue, members []string) bool {
if len(members) == 0 {
return false
}
assignees := AssigneeNames(issue)
for _, member := range members {
if member != "" && containsString(assignees, member) {
return true
}
}
return false
}
// LastCommentNotByAssignee 判定 issue 的最後一則留言是否不是任何 assignee
// 留的(無留言 → true,視為需要處理)。
// 注意:判定對象是該 issue 的**全部** assignees,不只是 members 中的成員
// (gitea.py _last_comment_is_from_assignee 的語義)。
func LastCommentNotByAssignee(issue *Issue, comments []Comment) bool {
last := lastCommentAuthor(comments)
if last == "" {
return true
}
return !containsString(AssigneeNames(issue), last)
}
// IssueNeedsWork 組合判定:分派給 members 中至少一人,且最後一則留言
// 不是 assignee(gitea.py filter_issues_by_assignee 保留的項目)。
func IssueNeedsWork(issue *Issue, comments []Comment, members []string) bool {
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、#53,同步 gitea.py _is_review_handoff)──────
//
// PR 作者的最後一則留言已把審核交棒給審核者(@審核者帳號+就緒語)時,
// 停滯點不在作者:stalled 分類由 author-idle 改判 reviewer-idle,既有
// 48 小時改派規則自然適用。案例:fox#60 作者留言「PR 已就緒,可審閱」
// 並 @chenyunda218,teai stalled 仍判 author-idle——作者已無待辦。
// #53 補遺:alterminal#2 作者留言「等候 @chenyunda218 審核…無待修改
// 項目…請直接合併」——實質已交棒但無三就緒詞,仍誤判 author-idle。
var (
// reviewReadyRe 對應 gitea.py _REVIEW_READY_RE:PR 端就緒語。
reviewReadyRe = regexp.MustCompile(`已就緒|可審閱|請審閱|請[^,。;\n]{0,8}審核`)
// reviewWaitMergeRe 對應 gitea.py _REVIEW_WAIT_MERGE_RE:等候審核語
//(#53)——「等候/等待…審核」「待審」與「請…合併」形態。R2/R3 允許
// @帳號夾在中間(「等候 @chenyunda218 審核」),故不含 @;
// 「即將/稍後…」的請求不合併語義,由前瞻防護 reviewHedgeRe 排除。
reviewWaitMergeRe = regexp.MustCompile(`(等候|等待)[^,。;\n]{0,12}(審核|審閱|review)|待審|請[^,。;\n]{0,20}合併`)
// reviewDoneRe 對應 gitea.py _REVIEW_DONE_RE:完成語(#53)——
// 作者自述 PR 內容已完成/無待辦。
reviewDoneRe = regexp.MustCompile(`已完成|已完成實作|全數完成|已完成全部|完成[^,。;\n]{0,6}(修改|調整|實作|更新)|無待修改|無待辦|沒有.{0,4}待修改|不再有.{0,4}待修改`)
// reviewPercentRe 對應 gitea.py _REVIEW_PERCENT_RE:百分比進度
//(#53)——「完成 80%」「已完成 90%」是進度回報,不是完成;命中時
// 整則留言視為未交棒(保守:作者仍有待辦,維持 author-idle)。
reviewPercentRe = regexp.MustCompile(`完成[^,。;\n]{0,6}[0-90-9]{1,3}\s*[%%]`)
// reviewHedgeRe 對應 gitea.py _REVIEW_HEDGE_RE:預告式防護(#51 既有
// 精神+#53 擴詞彙後更顯重要)——「即將/稍後/之後/預計+就緒/審核/
// review/完成/合併」與條件句「…後請審核/合併」(「實作完成後請審核」)
// 的前瞻字眼。命中時就緒語/等候語一律不算,保持 author-idle:作者
// 自己說還沒到位,不能解讀成已交棒。句讀(,。;)阻斷間距,避免
// 「合併後,我會回報」這類真交棒尾巴被誤傷;「後」後接「續」除外
//(「已完成修改,後續請審核」——完成已發生,非條件句)。
reviewHedgeRe = regexp.MustCompile(`(即將|稍後|之後|預計)[^,。;\n]{0,10}(就緒|審核|審閱|review|完成|合併)|後[^續,。;\n]{0,4}(審核|審閱|合併|review|就緒)`)
)
// isReviewHandoffSignal 判定留言文字是否命中交棒訊號(#53 複合訊號):
//
// 就緒語 ∨(完成語 ∧ 等候語)
//
// 兩路徑都先過防護:預告式(reviewHedgeRe)與百分比進度(reviewPercentRe)
// 命中時一律不算,保持 author-idle——作者自己說還沒到位/還在做,不能解讀
// 成已交棒。等候語與完成語同現才算:仍在實作的進度回報(「實作完成後請
// 審核」是條件句、無完成自述)不誤傷。
func isReviewHandoffSignal(body string) bool {
if reviewHedgeRe.MatchString(body) || reviewPercentRe.MatchString(body) {
return false
}
if reviewReadyRe.MatchString(body) {
return true
}
return reviewDoneRe.MatchString(body) && reviewWaitMergeRe.MatchString(body)
}
// IsReviewHandoff 判定 PR 作者的最後一則留言是否已把審核交棒給審核者
// (agents #51、#53;gitea.py _is_review_handoff 的同語義實作)。
//
// 審核交棒=「@審核者帳號」∧「交棒訊號」同時命中:作者已表明 PR 就緒
// (或已完成+等候審核)、明確點名目前指定的審核者,球在審核者——
// author-idle 改判 reviewer-idle。
//
// 只命中訊號不算:進度回報(「快好了,即將就緒」)沒有點名對象,作者
// 仍可能有待辦——保持 author-idle,修正不製造「丟一句已就緒就永不被催」
// 的新死角。預告式(「即將就緒」「之後再審」)由 reviewHedgeRe 排除。
// @的帳號不在目前審核者清單也不算:交棒對象需為實際審核者。
func IsReviewHandoff(last *Comment, reviewers []string) bool {
if last == nil {
return false
}
body := last.Body
if body == "" || len(reviewers) == 0 {
return false
}
if !isReviewHandoffSignal(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
}