Files
teai/internal/workflow/judge.go
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

204 lines
7.0 KiB
Go
Raw Permalink 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)
}