#51 的三就緒詞漏掉「等候審核」形態:alterminal#2 作者留言「無待修改 項目…等候 @chenyunda218 審核…請直接合併」實質已交棒,仍誤判 author-idle,巡邏回頭催已無待辦的作者。 IsReviewHandoff 改複合訊號:@目前審核者 ∧(就緒語 ∨(完成語 ∧ 等候語))。就緒語補「請…審核」;等候語=等候/等待…審核、待審、 請…合併(間距容 @帳號);完成語=已完成/無待修改等。新增防護: 預告式(即將/稍後/之後/預計…、條件句「…後請審核」)與百分比進度 (完成 80%)一律不算——作者自述還沒到位不能解讀成已交棒;句讀阻斷 避免誤傷「合併後,我會回報」真交棒尾巴,「後續」除外。 gitea.py _is_review_handoff 同步(另 PR);測試案例兩側同集 (fox#60、alterminal#2 實例+17 個邊界)。
318 lines
13 KiB
Go
318 lines
13 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)
|
||
}
|
||
|
||
// ── 審核交棒判定(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
|
||
}
|