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 }