Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a5d02fd70 | ||
|
|
a9abf96a42 | ||
|
|
1d9092cdde |
@@ -237,6 +237,17 @@ PR 改帶 `author` 與 `reviewers` 欄位。`reason` 取值:
|
||||
- issue:`no-assignee`、`assignee-idle`、`no-commenter`、`waiting-outside`、`parent-tracking`
|
||||
- PR:`no-reviewer`、`author-idle`、`reviewer-idle`、`no-commenter`
|
||||
|
||||
`assignee-idle` 判定前會先檢查最後一則 assignee 留言是否為**交付報告**(agents #47):
|
||||
逐項查核/驗收結果(結構訊號)∧ 回報他件/請他人確認關閉(交棒訊號)同時命中 →
|
||||
改判 `waiting-outside`——已交付待發起人確認不是 assignee 停滯。認領/進度/預告式
|
||||
留言(無交棒對象)維持 `assignee-idle`,不製造「認領後棄置永不被催」的新死角。
|
||||
|
||||
`author-idle` 判定前會先檢查 PR 作者的最後一則留言是否為**審核交棒**(agents #51):
|
||||
@目前審核者帳號 ∧ 就緒語(已就緒/可審閱/請審閱)同時命中 → 改判 `reviewer-idle`
|
||||
——作者已表明就緒並點名審核者,球在審核者,讓既有 reviewer 催促與 48 小時改派規則
|
||||
自然適用。@的帳號必須在目前審核者清單且為完整匹配(@chenyunda218 不算提及
|
||||
chenyunda);僅就緒語而無點名對象(進度回報、預告式)維持 `author-idle`。
|
||||
|
||||
### `teai milestones` — 跨倉庫里程碑總覽
|
||||
|
||||
```sh
|
||||
|
||||
+139
-1
@@ -1,6 +1,10 @@
|
||||
package workflow
|
||||
|
||||
import "sort"
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sortComments 依 created_at 字典序穩定排序(ISO8601 字串字典序與時間序一致),
|
||||
// 對應 gitea.py 各處的 comments.sort(key=created_at)。
|
||||
@@ -135,3 +139,137 @@ func LastCommentNotByAssignee(issue *Issue, comments []Comment) bool {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -143,3 +143,73 @@ func TestFullNameFallbacks(t *testing.T) {
|
||||
t.Errorf("全缺 → 空,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsDeliveryReport(agents #47):交付報告=結構訊號 ∧ 交棒訊號。
|
||||
// 案例與 gitea.py _is_delivery_report 的離線驗證同集(bear#52 實例)。
|
||||
func TestIsDeliveryReport(t *testing.T) {
|
||||
body52 := "## 完成定義\n- 逐項回報查核結果至此 issue;\n- 全數通過 → 回報 #39 並由 #39 assignee 關閉"
|
||||
claim := "認領。我來執行四項驗收查核,完成後逐項回報結果至此。"
|
||||
report := "## 全階段驗收查核結果(#52)\n**結論:四項全數通過。** 逐項如下…\n四項全過,符合「完成定義」。回報 #39:全數驗收通過,可由 @ceo 關閉 #39 與里程碑。"
|
||||
reportFail := "## 查核結果\n結論:第 2 項不通過。已開立修復 issue #60,請 @ceo 確認分配。"
|
||||
cases := []struct {
|
||||
name string
|
||||
issueBody string
|
||||
comment string
|
||||
want bool
|
||||
}{
|
||||
{"交付報告+回報#39+@ceo 關閉", body52, report, true},
|
||||
{"失敗型交付(開修復 issue+請確認)", body52, reportFail, true},
|
||||
{"認領留言(逐項+回報 但無交棒)", body52, claim, false},
|
||||
{"純進度留言", body52, "進行中,已看完 P1,預計今晚交付。", false},
|
||||
{"已完成但無交棒", body52, "已完成,程式碼都在分支上。", false},
|
||||
{"無完成定義內文+逐項交棒", "一般 issue 內文", "逐項測試結果全數通過,請 @ceo 確認後關閉。", true},
|
||||
{"無完成定義+僅交棒(無結構)", "一般 issue 內文", "請 @ceo 確認後關閉。", false},
|
||||
{"空留言", body52, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
var last *Comment
|
||||
if tc.comment != "" {
|
||||
last = &Comment{Body: tc.comment}
|
||||
}
|
||||
if got := IsDeliveryReport(tc.issueBody, last); got != tc.want {
|
||||
t.Errorf("%s:IsDeliveryReport = %v,want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
if IsDeliveryReport(body52, nil) {
|
||||
t.Error("nil 最後留言應為 false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsReviewHandoff(agents #51):審核交棒=@審核者帳號 ∧ 就緒語。
|
||||
// 案例與 gitea.py _is_review_handoff 的離線驗證同集(fox#60 實例)。
|
||||
func TestIsReviewHandoff(t *testing.T) {
|
||||
// fox#60 作者(alex)最後留言結尾(2026-09-16 08:05)。
|
||||
fox60 := "狀態回報(作者端自查,分支無需再變更):\n\n- 已重新核對分支與 main 的差異…\n- PR 分支 CI 已通過(run 209)。\n\n@chenyunda218 PR 已就緒,可審閱。@ceo 已知悉管理者巡邏的備註;審核合併後,我會在下一個 main 部署 run 跑通時回 #58 驗收。"
|
||||
cases := []struct {
|
||||
name string
|
||||
comment string
|
||||
reviewers []string
|
||||
want bool
|
||||
}{
|
||||
{"fox#60 實例(@審核者+已就緒,可審閱)", fox60, []string{"chenyunda218"}, true},
|
||||
{"多位審核者命中其一", fox60, []string{"ceo", "chenyunda218"}, true},
|
||||
{"前綴碰撞(@chenyunda218 ≠ chenyunda)", fox60, []string{"chenyunda"}, false},
|
||||
{"就緒語但無 @(無點名對象)", fox60, []string{"dan"}, false},
|
||||
{"進度回報(無就緒語)", "進行中:已改完兩步,CI 跑著,@chenyunda218 稍後再看。", []string{"chenyunda218"}, false},
|
||||
{"預告式(即將就緒不算)", "@chenyunda218 快好了,即將整理完成。", []string{"chenyunda218"}, false},
|
||||
{"無審核者", fox60, nil, false},
|
||||
{"空留言", "", []string{"chenyunda218"}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
var last *Comment
|
||||
if tc.comment != "" {
|
||||
last = &Comment{Body: tc.comment}
|
||||
}
|
||||
if got := IsReviewHandoff(last, tc.reviewers); got != tc.want {
|
||||
t.Errorf("%s:IsReviewHandoff = %v,want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
if IsReviewHandoff(nil, []string{"chenyunda218"}) {
|
||||
t.Error("nil 最後留言應為 false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,7 +526,13 @@ func considerIssue(src Source, org, repo string, issue Issue, comments []Comment
|
||||
case len(assignees) == 0:
|
||||
reason = "no-assignee"
|
||||
case lastCommentBy != "" && containsString(assignees, lastCommentBy):
|
||||
reason = "assignee-idle"
|
||||
if IsDeliveryReport(issue.Body, last) {
|
||||
// 交付報告待確認(agents #47):assignee 已交付並交棒(回報他件/
|
||||
// 請發起人確認關閉),停滯點在等待外部確認,不是 assignee 停滯。
|
||||
reason = "waiting-outside"
|
||||
} else {
|
||||
reason = "assignee-idle"
|
||||
}
|
||||
case lastCommentBy == "":
|
||||
reason = "no-commenter"
|
||||
}
|
||||
@@ -596,7 +602,14 @@ func considerPull(src Source, org, repo string, pr PullRequest, comments []Comme
|
||||
case len(reviewers) == 0:
|
||||
reason = "no-reviewer"
|
||||
case lastCommentBy == author:
|
||||
reason = "author-idle"
|
||||
if IsReviewHandoff(last, reviewers) {
|
||||
// 審核交棒(agents #51):作者已表明 PR 就緒並 @目前審核者,
|
||||
// 停滯點在審核者,不是作者停滯——球在審核者,讓既有
|
||||
// reviewer 催促與 48 小時改派規則自然適用。
|
||||
reason = "reviewer-idle"
|
||||
} else {
|
||||
reason = "author-idle"
|
||||
}
|
||||
case lastCommentBy != "" && containsString(reviewers, lastCommentBy):
|
||||
reason = "reviewer-idle"
|
||||
}
|
||||
|
||||
@@ -414,12 +414,16 @@ func TestScanStalledPullReasons(t *testing.T) {
|
||||
{Number: 11, User: &User{UserName: "max"}, CreatedAt: iso(-600), UpdatedAt: iso(-300), RequestedReviewers: []User{{UserName: "ceo"}}},
|
||||
{Number: 12, User: &User{UserName: "max"}, CreatedAt: iso(-600), UpdatedAt: iso(-300), RequestedReviewers: []User{{UserName: "ceo"}}},
|
||||
{Number: 13, User: &User{UserName: "max"}, CreatedAt: iso(-600), UpdatedAt: iso(-300), RequestedReviewers: []User{{UserName: "ceo"}}},
|
||||
// agents #51:作者最後留言含審核交棒(@審核者+就緒語)→ reviewer-idle。
|
||||
{Number: 14, User: &User{UserName: "max"}, CreatedAt: iso(-600), UpdatedAt: iso(-300), RequestedReviewers: []User{{UserName: "chenyunda218"}}},
|
||||
},
|
||||
},
|
||||
comments: map[string][]Comment{
|
||||
"alterminal/teai/11": {{User: &User{UserName: "max"}, CreatedAt: iso(-300)}}, // author-idle
|
||||
"alterminal/teai/12": {{User: &User{UserName: "ceo"}, CreatedAt: iso(-300)}}, // reviewer-idle
|
||||
"alterminal/teai/13": {{User: &User{UserName: "dan"}, CreatedAt: iso(-300)}}, // no-commenter(外部留言)
|
||||
// fox#60 形態(agents #51):作者留言 @chenyunda218+「已就緒,可審閱」→ 審核交棒。
|
||||
"alterminal/teai/14": {{User: &User{UserName: "max"}, CreatedAt: iso(-300), Body: "狀態回報:分支無需再變更,CI 已通過。@chenyunda218 PR 已就緒,可審閱。"}},
|
||||
},
|
||||
}
|
||||
got, err := ScanStalled(src, "admin", 4.0, now)
|
||||
@@ -430,7 +434,7 @@ func TestScanStalledPullReasons(t *testing.T) {
|
||||
for _, it := range got {
|
||||
byNumber[it.Number] = it
|
||||
}
|
||||
for n, wantReason := range map[int64]string{10: "no-reviewer", 11: "author-idle", 12: "reviewer-idle", 13: "no-commenter"} {
|
||||
for n, wantReason := range map[int64]string{10: "no-reviewer", 11: "author-idle", 12: "reviewer-idle", 13: "no-commenter", 14: "reviewer-idle"} {
|
||||
item, ok := byNumber[n]
|
||||
if !ok {
|
||||
t.Fatalf("PR %d 應列入,got %v", n, got)
|
||||
@@ -439,12 +443,15 @@ func TestScanStalledPullReasons(t *testing.T) {
|
||||
t.Errorf("PR %d reason = %q, want %q", n, item.Reason, wantReason)
|
||||
}
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("應 4 筆,got %d", len(got))
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("應 5 筆,got %d", len(got))
|
||||
}
|
||||
if byNumber[10].Author != "max" {
|
||||
t.Errorf("PR 10 author 應為 max,got %q", byNumber[10].Author)
|
||||
}
|
||||
if byNumber[14].LastCommentBy != "max" {
|
||||
t.Errorf("PR 14 last_comment_by 應為 max(作者留言後交棒),got %q", byNumber[14].LastCommentBy)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanStalledNoPullsFlag 停用 PR 的倉庫仍掃 issues,只跳過 PR。
|
||||
@@ -595,3 +602,39 @@ func TestRound1(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanStalledDeliveryReport(agents #47):assignee 最後留言為交付報告
|
||||
// (結構訊號+交棒訊號)→ waiting-outside,不是 assignee-idle;
|
||||
// 同型認領留言(無交棒)維持 assignee-idle——修正不製造新死角。
|
||||
func TestScanStalledDeliveryReport(t *testing.T) {
|
||||
now := at(0)
|
||||
defBody := "## 完成定義\n- 逐項回報查核結果至此 issue;\n- 全數通過 → 回報 #39 並由 #39 assignee 關閉"
|
||||
src := &fakeSource{
|
||||
orgs: []string{"alterminal"},
|
||||
repos: map[string][]Repo{"alterminal": repoWith("bear", true)},
|
||||
issues: map[string]map[int64]Issue{
|
||||
"alterminal/bear": {
|
||||
5: {Number: 5, Title: "驗收", State: "open", Body: defBody, CreatedAt: iso(-600), UpdatedAt: iso(-300), Assignees: []User{{UserName: "queena"}}, Repository: &Repository{FullName: "alterminal/bear"}},
|
||||
6: {Number: 6, Title: "認領中", State: "open", Body: defBody, CreatedAt: iso(-600), UpdatedAt: iso(-300), Assignees: []User{{UserName: "dan"}}, Repository: &Repository{FullName: "alterminal/bear"}},
|
||||
},
|
||||
},
|
||||
comments: map[string][]Comment{
|
||||
"alterminal/bear/5": {{User: &User{UserName: "queena"}, CreatedAt: iso(-300), Body: "## 查核結果\n結論:四項全數通過。回報 #39:可由 @ceo 關閉。"}},
|
||||
"alterminal/bear/6": {{User: &User{UserName: "dan"}, CreatedAt: iso(-300), Body: "認領。完成後逐項回報結果至此。"}},
|
||||
},
|
||||
}
|
||||
got, err := ScanStalled(src, "admin", 4.0, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanStalled: %v", err)
|
||||
}
|
||||
byNumber := map[int64]Item{}
|
||||
for _, it := range got {
|
||||
byNumber[it.Number] = it
|
||||
}
|
||||
if it, ok := byNumber[5]; !ok || it.Reason != "waiting-outside" {
|
||||
t.Errorf("issue 5(交付報告)reason 應為 waiting-outside,got %+v", it)
|
||||
}
|
||||
if it, ok := byNumber[6]; !ok || it.Reason != "assignee-idle" {
|
||||
t.Errorf("issue 6(認領留言)reason 應為 assignee-idle,got %+v", it)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user