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,同步 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 }