From 1d9092cddee18952a0ec47b5f3c6ca06f7827665 Mon Sep 17 00:00:00 2001 From: chenyunda218 Date: Tue, 15 Sep 2026 04:28:08 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9Astalled=20=E5=88=86=E9=A1=9E?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=20agents#47=E2=80=94=E2=80=94assignee=20?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=E5=A0=B1=E5=91=8A=E5=BE=85=E7=A2=BA=E8=AA=8D?= =?UTF-8?q?=E6=94=B9=E5=88=A4=20waiting-outside?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 與 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 不變。 --- README.md | 5 +++ internal/workflow/judge.go | 68 +++++++++++++++++++++++++++++- internal/workflow/judge_test.go | 36 ++++++++++++++++ internal/workflow/workflow.go | 8 +++- internal/workflow/workflow_test.go | 36 ++++++++++++++++ 5 files changed, 151 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0ba4af9..f257f0c 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,11 @@ 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`,不製造「認領後棄置永不被催」的新死角。 + ### `teai milestones` — 跨倉庫里程碑總覽 ```sh diff --git a/internal/workflow/judge.go b/internal/workflow/judge.go index 4614216..8e64c96 100644 --- a/internal/workflow/judge.go +++ b/internal/workflow/judge.go @@ -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,65 @@ 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) +} diff --git a/internal/workflow/judge_test.go b/internal/workflow/judge_test.go index dfa82cb..011fbfa 100644 --- a/internal/workflow/judge_test.go +++ b/internal/workflow/judge_test.go @@ -143,3 +143,39 @@ 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") + } +} diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index a0ece73..3299df0 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -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" } diff --git a/internal/workflow/workflow_test.go b/internal/workflow/workflow_test.go index da3ea92..bbd7480 100644 --- a/internal/workflow/workflow_test.go +++ b/internal/workflow/workflow_test.go @@ -595,3 +595,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) + } +} -- 2.54.0