與 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 不變。
204 lines
7.0 KiB
Go
204 lines
7.0 KiB
Go
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)
|
||
}
|