considerPull 的 author-idle 分支缺少 #47 的交棒檢查,且既有 IsDeliveryReport 訊號為 issue 交付報告設計,PR 端「已就緒,可審閱+ @審核者」形態無對應訊號——fox#60 作者留言交棒後仍被誤判 author-idle。 新增 IsReviewHandoff(@目前審核者帳號 ∧ 就緒語,mention 邊界感知 避免 @chenyunda218 誤命中 chenyunda),considerPull 在判 author-idle 前檢查,命中改判 reviewer-idle,讓既有 48 小時改派規則自然適用。 gitea.py 側 _consider pull 分支以 _is_review_handoff 同步(另 PR)。
744 lines
26 KiB
Go
744 lines
26 KiB
Go
// 套件 workflow 實作 gitea.py 工作流的核心判定邏輯(teai issue #7)。
|
||
//
|
||
// 行為基準是 alterminal/agents 的 scripts/lib/gitea.py:xref 解析
|
||
// (#N、repo#N、owner/repo#N)與 stalled 停滯掃描(reason 分類、催促冷卻、
|
||
// 父追蹤項把子項活動計入 last_activity)。判定規則以 AGENTS.md 為準,
|
||
// 本套件只實作、不改義。
|
||
//
|
||
// 資料以 Source 介面注入:CLI 層負責拉取 API,測試用假資料源,
|
||
// 邏輯本身離線可測(README 設計原則 4:可直接替換原型、交叉驗證)。
|
||
package workflow
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// 常數與 gitea.py 保持一致(STALLED_DEFAULT_HOURS、NUDGE_COOLDOWN_HOURS)。
|
||
const (
|
||
// StalledDefaultHours 是 stalled 掃描的預設門檻(小時)。
|
||
StalledDefaultHours = 4.0
|
||
// NudgeCooldownHours 是「最後留言出自目前帳號」項目的催促冷卻(小時)。
|
||
NudgeCooldownHours = 24.0
|
||
)
|
||
|
||
// xrefTimelineTypes 對應 gitea.py 的 XREF_TIMELINE_TYPES:他處留言/issue
|
||
// 提及本編號時產生的 timeline 引用事件。這類事件會刷新 updated_at,但不是
|
||
// 本 issue 的直接活動,directLastActivity 會予以排除(agents issue #34)。
|
||
var xrefTimelineTypes = map[string]bool{"comment_ref": true, "issue_ref": true}
|
||
|
||
// xrefPattern 對應 gitea.py 的 XREF_PATTERN:#N、repo#N、owner/repo#N。
|
||
//
|
||
// 前面不得是文數字/底線/斜線(避免切進 alterminal/bear#47 的後半段);
|
||
// owner/repo 兩段之間必須有斜線(避免把 bear-cli#10 誤判成 cli#10)。
|
||
// Go 的 regexp 不支援 lookbehind,改在掃描時檢查前一字元。
|
||
var xrefPattern = regexp.MustCompile(`([A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?)?#(\d+)`)
|
||
|
||
// Item 是 stalled 掃描的單筆輸出(欄位對應 gitea.py 的 find_stalled_work)。
|
||
type Item struct {
|
||
Type string
|
||
Repo string
|
||
Number int64
|
||
Title string
|
||
URL string
|
||
Assignees []string
|
||
Author string
|
||
Reviewers []string
|
||
LastCommentBy string
|
||
LastActivityAt time.Time
|
||
StalledHours float64
|
||
Reason string
|
||
ChildActivity []Child
|
||
}
|
||
|
||
// MarshalJSON 輸出鍵集與鍵序與 gitea.py 完全一致的物件:
|
||
// issue 帶 assignees;pull 帶 author 與 reviewers;
|
||
// child_activity 僅在有子項摘要時輸出。
|
||
func (i Item) MarshalJSON() ([]byte, error) {
|
||
var b strings.Builder
|
||
b.WriteString(`{"type":`)
|
||
writeJSON(&b, i.Type)
|
||
b.WriteString(`,"repo":`)
|
||
writeJSON(&b, i.Repo)
|
||
b.WriteString(`,"number":`)
|
||
fmt.Fprintf(&b, "%d", i.Number)
|
||
b.WriteString(`,"title":`)
|
||
writeJSON(&b, i.Title)
|
||
b.WriteString(`,"url":`)
|
||
writeJSON(&b, i.URL)
|
||
if i.Type == "pull" {
|
||
b.WriteString(`,"author":`)
|
||
writeJSON(&b, i.Author)
|
||
b.WriteString(`,"reviewers":`)
|
||
writeJSON(&b, i.Reviewers)
|
||
} else {
|
||
b.WriteString(`,"assignees":`)
|
||
writeJSON(&b, i.Assignees)
|
||
}
|
||
b.WriteString(`,"last_comment_by":`)
|
||
writeJSON(&b, i.LastCommentBy)
|
||
b.WriteString(`,"last_activity_at":`)
|
||
writeJSON(&b, FormatMoment(i.LastActivityAt))
|
||
b.WriteString(`,"stalled_hours":`)
|
||
// Python round(x, 1) 的 json 輸出是 48.0/10.4(repr 帶 .0);
|
||
// %g 會輸出 48,極大值還會變 1e+06,改定點一位小數。
|
||
b.WriteString(strconv.FormatFloat(i.StalledHours, 'f', 1, 64))
|
||
b.WriteString(`,"reason":`)
|
||
writeJSON(&b, i.Reason)
|
||
if len(i.ChildActivity) > 0 {
|
||
b.WriteString(`,"child_activity":`)
|
||
writeJSON(&b, i.ChildActivity)
|
||
}
|
||
b.WriteString(`}`)
|
||
return []byte(b.String()), nil
|
||
}
|
||
|
||
// FormatMoment 輸出與 Python datetime.isoformat() 一致的時刻字串
|
||
// (UTC 以 +00:00 表示,不用 Z;秒以下省略)。
|
||
func FormatMoment(t time.Time) string {
|
||
if t.Nanosecond() != 0 {
|
||
return t.Format("2006-01-02T15:04:05.999999999-07:00")
|
||
}
|
||
return t.Format("2006-01-02T15:04:05-07:00")
|
||
}
|
||
|
||
// writeJSON 以停用 HTML 轉義的編碼器輸出:Python json.dumps(ensure_ascii=False)
|
||
// 不會把 <、>、& 轉成 \u003c 等逸出,json.Marshal 預設會,造成位元組不相容。
|
||
// Encoder 會在結尾補換行, trim 掉。
|
||
func writeJSON(b *strings.Builder, v any) {
|
||
var buf bytes.Buffer
|
||
enc := json.NewEncoder(&buf)
|
||
enc.SetEscapeHTML(false)
|
||
if err := enc.Encode(v); err != nil {
|
||
return
|
||
}
|
||
b.Write(bytes.TrimSuffix(buf.Bytes(), []byte{'\n'}))
|
||
}
|
||
|
||
// Child 是父追蹤項的子項活動摘要。
|
||
type Child struct {
|
||
Repo string `json:"repo"`
|
||
Number int64 `json:"number"`
|
||
Title string `json:"title"`
|
||
State string `json:"state"`
|
||
// LastActivityAt 用指標:子項無可解析時刻時 Python 輸出 null(不是 "")。
|
||
LastActivityAt *string `json:"last_activity_at"`
|
||
}
|
||
|
||
// XrefTarget 是解析後的引用目標(owner、repo、number)。
|
||
type XrefTarget struct {
|
||
Owner string
|
||
Repo string
|
||
Number int64
|
||
}
|
||
|
||
// Source 是 stalled 掃描需要的資料源(CLI 層以 API 實作;測試注入假資料)。
|
||
type Source interface {
|
||
// MyOrganizations 回傳目前帳號所屬組織清單。
|
||
MyOrganizations() ([]string, error)
|
||
// OrganizationRepositories 回傳組織的倉庫清單。
|
||
OrganizationRepositories(org string) ([]Repo, error)
|
||
// OpenIssues 回傳倉庫的 open issues(Gitea 會混入 PR,由掃描端過濾)。
|
||
OpenIssues(owner, repo string) ([]Issue, error)
|
||
// OpenPulls 回傳倉庫的 open pull requests。
|
||
OpenPulls(owner, repo string) ([]PullRequest, error)
|
||
// IssueComments 回傳 issue/PR 的留言。
|
||
IssueComments(owner, repo string, number int64) ([]Comment, error)
|
||
// TryGetIssue 寬容版單一 issue 查詢:失敗(含 404)回 (零值, false, nil)。
|
||
TryGetIssue(owner, repo string, number int64) (Issue, bool, error)
|
||
// IssueTimeline 回傳 issue 的 timeline 事件(GET /repos/{o}/{r}/issues/{n}/timeline,
|
||
// 分頁)。directLastActivity 用來排除跨 issue 引用事件(agents issue #34)。
|
||
IssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error)
|
||
}
|
||
|
||
// iterXrefs 產出文字中的 (prefix, number) 配對(對應 _iter_issue_xrefs)。
|
||
//
|
||
// Python 原版以 lookbehind (?<![\w/#]) 在匹配起點檢查前一「字元」,
|
||
// 且 \w 採 Unicode(中日韓、全形數字都算)。Go regexp 不支援 lookbehind,
|
||
// 改為掃描時檢查起點前一 rune:是文數字(Unicode)/底線/斜線/# 者略過,
|
||
// 並從起點的下一個 rune 重試——與 Python finditer 的逐位置語義一致,
|
||
// 否則「修復bear-cli#9」這種寫法會整段被丟棄(Python 只匹配後半 cli#9)。
|
||
func iterXrefs(text string) [][2]string {
|
||
var out [][2]string
|
||
pos := 0
|
||
for pos <= len(text) {
|
||
m := xrefPattern.FindStringIndex(text[pos:])
|
||
if m == nil {
|
||
break
|
||
}
|
||
start, end := pos+m[0], pos+m[1]
|
||
if start > 0 {
|
||
prev, _ := utf8.DecodeLastRuneInString(text[:start])
|
||
if isWordChar(prev) || prev == '/' || prev == '#' {
|
||
_, size := utf8.DecodeRuneInString(text[start:])
|
||
pos = start + size
|
||
continue
|
||
}
|
||
}
|
||
match := text[start:end]
|
||
i := strings.IndexByte(match, '#')
|
||
prefix := match[:i]
|
||
number := match[i+1:]
|
||
out = append(out, [2]string{prefix, number})
|
||
pos = end
|
||
}
|
||
return out
|
||
}
|
||
|
||
// isWordChar 對應 Python \w(Unicode):字母/數字/底線。
|
||
// 前一版只認 ASCII,中文緊鄰 #N 時(「修復#12」)與 Python 行為分歧。
|
||
func isWordChar(r rune) bool {
|
||
return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)
|
||
}
|
||
|
||
// ResolveXrefTargets 把內文/留言中的引用解析成目標清單(對應 _resolve_xref_targets)。
|
||
//
|
||
// 單段 #N 指向父項所在倉庫;單段 repo#N 需 repo 存在於父項所屬組織
|
||
// (「PR#50」這類寫法因 repo 不存在而自動略過);owner/repo#N 需 owner
|
||
// 為目前帳號所屬組織(跨倉庫子項,如 bear-cli#9 → bear#35 的同組織判定)。
|
||
// 去重、保留出現順序。
|
||
func ResolveXrefTargets(org, repo string, texts []string, myOrgs map[string]bool, orgRepos func(org string) ([]string, error)) ([]XrefTarget, error) {
|
||
var resolved []XrefTarget
|
||
seen := map[XrefTarget]bool{}
|
||
var orgRepoSet map[string]bool
|
||
for _, text := range texts {
|
||
for _, pair := range iterXrefs(text) {
|
||
prefix, numberStr := pair[0], pair[1]
|
||
var number int64
|
||
if _, err := fmt.Sscanf(numberStr, "%d", &number); err != nil || number <= 0 {
|
||
continue
|
||
}
|
||
var target XrefTarget
|
||
switch {
|
||
case strings.Contains(prefix, "/"):
|
||
parts := strings.SplitN(prefix, "/", 2)
|
||
owner, name := parts[0], parts[1]
|
||
if owner != "" && name != "" && myOrgs[owner] {
|
||
target = XrefTarget{owner, name, number}
|
||
}
|
||
case prefix != "":
|
||
if orgRepoSet == nil {
|
||
names, err := orgRepos(org)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
orgRepoSet = map[string]bool{}
|
||
for _, n := range names {
|
||
orgRepoSet[n] = true
|
||
}
|
||
}
|
||
if orgRepoSet[prefix] {
|
||
target = XrefTarget{org, prefix, number}
|
||
}
|
||
default:
|
||
target = XrefTarget{org, repo, number}
|
||
}
|
||
if target != (XrefTarget{}) && !seen[target] {
|
||
seen[target] = true
|
||
resolved = append(resolved, target)
|
||
}
|
||
}
|
||
}
|
||
return resolved, nil
|
||
}
|
||
|
||
// childActivity 計算父追蹤項的子項活動(對應 _child_activity)。
|
||
//
|
||
// 只採計向下引用:子項建立時間需晚於父項(父項內文回指更早的規劃 issue
|
||
// 是上行引用,不採計)。資料源失敗(含編號不存在)靜默略過,不影響掃描。
|
||
func childActivity(src Source, org, repo string, parent Issue, comments []Comment, myOrgs map[string]bool, orgRepos func(string) ([]string, error)) (moments []time.Time, children []Child) {
|
||
texts := []string{parent.Body}
|
||
for _, c := range comments {
|
||
texts = append(texts, c.Body)
|
||
}
|
||
targets, err := ResolveXrefTargets(org, repo, texts, myOrgs, orgRepos)
|
||
if err != nil {
|
||
return nil, nil
|
||
}
|
||
for _, t := range targets {
|
||
child, ok, gerr := src.TryGetIssue(t.Owner, t.Repo, t.Number)
|
||
if gerr != nil || !ok {
|
||
continue
|
||
}
|
||
if t.Owner == org && t.Repo == repo && child.issueNumber() == parent.issueNumber() {
|
||
continue // 自我引用
|
||
}
|
||
parentCreated, pcOK := parseMoment(parent.CreatedAt)
|
||
childCreated, ccOK := parseMoment(child.CreatedAt)
|
||
if pcOK && ccOK && !childCreated.After(parentCreated) {
|
||
continue // 上行引用(父項回指更早或同時的 issue);時間未知則不排除
|
||
}
|
||
// 子項的直接活動:created_at、state 變更(closed_at)、留言(agents #32)。
|
||
// 原始 updated_at 不放入已知活動,另經 directLastActivity 核對後採計
|
||
//(與 considerIssue/considerPull 對本 issue 自身的處理同構)。
|
||
var ms []time.Time
|
||
if m, ok := parseMoment(child.CreatedAt); ok {
|
||
ms = append(ms, m)
|
||
}
|
||
if m, ok := parseMoment(child.ClosedAt); ok {
|
||
ms = append(ms, m)
|
||
}
|
||
if cs, cerr := src.IssueComments(t.Owner, t.Repo, t.Number); cerr == nil {
|
||
for _, c := range cs {
|
||
if m, ok := parseMoment(c.CreatedAt); ok {
|
||
ms = append(ms, m)
|
||
}
|
||
}
|
||
}
|
||
if childUpdated, ok := parseMoment(child.UpdatedAt); ok {
|
||
// 子項 updated_at 可能被 comment_ref/issue_ref 引用事件刷新
|
||
//(agents #38):走與本 issue 自身相同的 directLastActivity 排除
|
||
// 邏輯(同源判定,不漂移);timeline 失敗時保守沿用 updated_at。
|
||
if d, ok := directLastActivity(src, t.Owner, t.Repo, t.Number, childUpdated, true, ms); ok {
|
||
ms = append(ms, d)
|
||
}
|
||
}
|
||
var last *string
|
||
if len(ms) > 0 {
|
||
latest := maxTime(ms)
|
||
moments = append(moments, latest)
|
||
// 與 Item 相同用 FormatMoment(Python isoformat:UTC 記 +00:00);
|
||
// RFC3339 對 UTC 會輸出 Z,與基準不相容。
|
||
s := FormatMoment(latest)
|
||
last = &s
|
||
}
|
||
children = append(children, Child{
|
||
Repo: t.Owner + "/" + t.Repo,
|
||
Number: child.Number,
|
||
Title: child.Title,
|
||
State: child.State,
|
||
LastActivityAt: last,
|
||
})
|
||
}
|
||
return moments, children
|
||
}
|
||
|
||
// TimelineEvent 是 issue timeline 的單一事件(僅取判斷所需欄位;
|
||
// 對應 gitea.py _direct_last_activity 讀取的 timeline 物件)。
|
||
type TimelineEvent struct {
|
||
Type string `json:"type"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
// directLastActivity 排除跨 issue 引用事件後,本 issue 的最後直接活動時刻
|
||
// (對應 gitea.py 的 _direct_last_activity;agents issues #34/#38/#40)。
|
||
//
|
||
// 他處留言/issue 提及本編號(comment_ref/issue_ref)也會刷新 updated_at,
|
||
// 直接採用會虛假刷新停滯時鐘(false negative)並低估 stalled_hours。
|
||
// updated_at 不晚於已知直接活動(留言、created_at、#32 納入的子項活動)時,
|
||
// updated_at 本身可信,不必多打一次 timeline API;只有 updated_at 晚於全部
|
||
// 已知活動時才拉 timeline 核對:排除引用類事件後取最晚事件時刻;若最晚的
|
||
// 都是引用事件(本 issue 無直接活動可對應 updated_at),則回退已知直接活動
|
||
// 的最晚時刻(agents #40)。timeline 拉取失敗時沿用 updated_at(不中斷掃描、
|
||
// 寧可少報不誤報缺失活動)。
|
||
func directLastActivity(src Source, org, repo string, number int64, updatedAt time.Time, hasUpdated bool, known []time.Time) (time.Time, bool) {
|
||
if !hasUpdated {
|
||
return time.Time{}, false
|
||
}
|
||
var knownMax *time.Time
|
||
for i := range known {
|
||
if known[i].IsZero() {
|
||
continue
|
||
}
|
||
if knownMax == nil || known[i].After(*knownMax) {
|
||
m := known[i]
|
||
knownMax = &m
|
||
}
|
||
}
|
||
if knownMax == nil || !updatedAt.After(*knownMax) {
|
||
return updatedAt, true
|
||
}
|
||
timeline, err := src.IssueTimeline(org, repo, number)
|
||
if err != nil {
|
||
return updatedAt, true
|
||
}
|
||
var moments []time.Time
|
||
for _, e := range timeline {
|
||
if xrefTimelineTypes[e.Type] {
|
||
continue
|
||
}
|
||
if m, ok := parseMoment(e.CreatedAt); ok {
|
||
moments = append(moments, m)
|
||
}
|
||
}
|
||
if len(moments) == 0 {
|
||
// timeline 全為引用事件:updated_at 完全由他處提及刷新,本 issue 沒有
|
||
// 可對應的直接活動。回退已知直接活動的最晚時刻,避免把被刷新的
|
||
// updated_at 當活動、低估停滯時數(agents #40);連已知活動都沒有時才
|
||
// 保守沿用 updated_at(不中斷掃描、寧可少報不誤報缺失活動)。
|
||
if knownMax != nil {
|
||
return *knownMax, true
|
||
}
|
||
return updatedAt, true
|
||
}
|
||
latest := maxTime(moments)
|
||
if latest.Before(updatedAt) {
|
||
return latest, true
|
||
}
|
||
return updatedAt, true
|
||
}
|
||
|
||
func maxTime(ts []time.Time) time.Time {
|
||
best := ts[0]
|
||
for _, t := range ts[1:] {
|
||
if t.After(best) {
|
||
best = t
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
// ScanStalled 掃描跨組織的停滯 open issues/PR(對應 find_stalled_work)。
|
||
//
|
||
// 判定:最後活動(留言時間與本身 updated_at 取較晚者,父追蹤項再加計
|
||
// 子項活動)距今超過 threshold 小時。刻意包含拾取死角形態:最後一則留言
|
||
// 出自 assignee/PR 作者本人且超過時限——工作休眠、無人催促。最後留言
|
||
// 出自目前帳號(username)的項目在冷卻期間不列入(已催促過);冷卻後
|
||
// 仍停滯會再出現。輸出依停滯時間由長到短排序。
|
||
func ScanStalled(src Source, username string, threshold float64, now time.Time) ([]Item, error) {
|
||
orgs, err := src.MyOrganizations()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
myOrgs := make(map[string]bool, len(orgs))
|
||
for _, o := range orgs {
|
||
myOrgs[o] = true
|
||
}
|
||
orgRepos := func(org string) ([]string, error) {
|
||
repos, err := src.OrganizationRepositories(org)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
names := make([]string, 0, len(repos))
|
||
for _, r := range repos {
|
||
if r.Name != "" {
|
||
names = append(names, r.Name)
|
||
}
|
||
}
|
||
return names, nil
|
||
}
|
||
var items []Item
|
||
for _, org := range orgs {
|
||
repos, err := src.OrganizationRepositories(org)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
for _, r := range repos {
|
||
if r.Name == "" || r.Empty {
|
||
continue
|
||
}
|
||
// 停用 PR 的倉庫沒有 PR 可掃;issues 照常(gitea.py _iter_open_issues 不檢查此欄位)。
|
||
scanPulls := r.HasPullRequests == nil || *r.HasPullRequests
|
||
issues, err := src.OpenIssues(org, r.Name)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
for _, issue := range issues {
|
||
if issue.PullRequest != nil {
|
||
continue
|
||
}
|
||
comments, err := src.IssueComments(org, r.Name, issue.Number)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
item, ok := considerIssue(src, org, r.Name, issue, comments, username, threshold, now, myOrgs, orgRepos)
|
||
if ok {
|
||
items = append(items, item)
|
||
}
|
||
}
|
||
if !scanPulls {
|
||
continue
|
||
}
|
||
pulls, err := src.OpenPulls(org, r.Name)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
for _, pr := range pulls {
|
||
comments, err := src.IssueComments(org, r.Name, pr.Number)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if item, ok := considerPull(src, org, r.Name, pr, comments, username, threshold, now); ok {
|
||
items = append(items, item)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
sort.SliceStable(items, func(i, j int) bool {
|
||
return items[i].StalledHours > items[j].StalledHours
|
||
})
|
||
return items, nil
|
||
}
|
||
|
||
// considerIssue 對單一 issue 套用停滯判定(對應 _consider 的 issue 分支)。
|
||
func considerIssue(src Source, org, repo string, issue Issue, comments []Comment, username string, threshold float64, now time.Time, myOrgs map[string]bool, orgRepos func(string) ([]string, error)) (Item, bool) {
|
||
childMoments, children := childActivity(src, org, repo, issue, comments, myOrgs, orgRepos)
|
||
assignees := AssigneeNames(&issue)
|
||
// 已知直接活動:留言、created_at、#32 納入的子項活動。
|
||
var known []time.Time
|
||
if m, ok := parseMoment(issue.CreatedAt); ok {
|
||
known = append(known, m)
|
||
}
|
||
for _, c := range comments {
|
||
if m, ok := parseMoment(c.CreatedAt); ok {
|
||
known = append(known, m)
|
||
}
|
||
}
|
||
known = append(known, childMoments...)
|
||
// 跨 issue 引用(comment_ref/issue_ref)也會刷新 updated_at(agents #34):
|
||
// updated_at 晚於全部已知直接活動時,先排除引用事件核對後才採用,
|
||
// 避免停滯時鐘被虛假刷新或低估 stalled_hours。
|
||
var moments []time.Time
|
||
if m, ok := parseMoment(issue.UpdatedAt); ok {
|
||
if d, ok2 := directLastActivity(src, org, repo, issue.issueNumber(), m, ok, known); ok2 {
|
||
moments = append(moments, d)
|
||
}
|
||
}
|
||
moments = append(moments, known...)
|
||
if len(moments) == 0 {
|
||
return Item{}, false
|
||
}
|
||
lastActivity := maxTime(moments)
|
||
stalled := now.Sub(lastActivity).Hours()
|
||
if stalled < threshold {
|
||
return Item{}, false
|
||
}
|
||
last := LastComment(comments)
|
||
lastCommentBy := ""
|
||
if last != nil {
|
||
lastCommentBy = last.Author()
|
||
}
|
||
if lastCommentBy == username {
|
||
if m, ok := parseMoment(last.CreatedAt); ok && now.Sub(m).Hours() < NudgeCooldownHours {
|
||
return Item{}, false
|
||
}
|
||
}
|
||
reason := "waiting-outside"
|
||
switch {
|
||
case len(assignees) == 0:
|
||
reason = "no-assignee"
|
||
case lastCommentBy != "" && containsString(assignees, lastCommentBy):
|
||
if IsDeliveryReport(issue.Body, last) {
|
||
// 交付報告待確認(agents #47):assignee 已交付並交棒(回報他件/
|
||
// 請發起人確認關閉),停滯點在等待外部確認,不是 assignee 停滯。
|
||
reason = "waiting-outside"
|
||
} else {
|
||
reason = "assignee-idle"
|
||
}
|
||
case lastCommentBy == "":
|
||
reason = "no-commenter"
|
||
}
|
||
if len(children) > 0 {
|
||
// 父追蹤項一律標 parent-tracking(agents #44,與 gitea.py 同步):
|
||
// 有 open 子項在推進時,父項留言閒置屬等待子項;子項全關閉時只剩
|
||
// 父項收尾(回報驗收並關閉),兩者都不是 assignee 停滯。統一標記
|
||
// 讓「父項收尾」規則能正確匹配,不退回通用原因(如 assignee-idle)。
|
||
reason = "parent-tracking"
|
||
}
|
||
item := Item{
|
||
Type: "issue", Repo: issue.FullName(), Number: issue.issueNumber(),
|
||
Title: issue.Title, URL: issue.WebURL(),
|
||
Assignees: assignees,
|
||
LastCommentBy: lastCommentBy, LastActivityAt: lastActivity,
|
||
StalledHours: round1(stalled), Reason: reason,
|
||
}
|
||
if len(children) > 0 {
|
||
item.ChildActivity = children
|
||
}
|
||
return item, true
|
||
}
|
||
|
||
// considerPull 對單一 PR 套用停滯判定(對應 _consider 的 pull 分支)。
|
||
func considerPull(src Source, org, repo string, pr PullRequest, comments []Comment, username string, threshold float64, now time.Time) (Item, bool) {
|
||
reviewers := ReviewerNames(&pr)
|
||
author := pr.Author()
|
||
// 已知直接活動:留言、created_at。
|
||
var known []time.Time
|
||
if m, ok := parseMoment(pr.CreatedAt); ok {
|
||
known = append(known, m)
|
||
}
|
||
for _, c := range comments {
|
||
if m, ok := parseMoment(c.CreatedAt); ok {
|
||
known = append(known, m)
|
||
}
|
||
}
|
||
// 跨 issue 引用也會刷新 PR 的 updated_at(agents #34):與 issue 同一道
|
||
// directLastActivity 排除邏輯(同源判定,不漂移)。
|
||
var moments []time.Time
|
||
if m, ok := parseMoment(pr.UpdatedAt); ok {
|
||
if d, ok2 := directLastActivity(src, org, repo, pr.Number, m, ok, known); ok2 {
|
||
moments = append(moments, d)
|
||
}
|
||
}
|
||
moments = append(moments, known...)
|
||
if len(moments) == 0 {
|
||
return Item{}, false
|
||
}
|
||
lastActivity := maxTime(moments)
|
||
stalled := now.Sub(lastActivity).Hours()
|
||
if stalled < threshold {
|
||
return Item{}, false
|
||
}
|
||
last := LastComment(comments)
|
||
lastCommentBy := ""
|
||
if last != nil {
|
||
lastCommentBy = last.Author()
|
||
}
|
||
if lastCommentBy == username {
|
||
if m, ok := parseMoment(last.CreatedAt); ok && now.Sub(m).Hours() < NudgeCooldownHours {
|
||
return Item{}, false
|
||
}
|
||
}
|
||
reason := "no-commenter"
|
||
switch {
|
||
case len(reviewers) == 0:
|
||
reason = "no-reviewer"
|
||
case lastCommentBy == author:
|
||
if IsReviewHandoff(last, reviewers) {
|
||
// 審核交棒(agents #51):作者已表明 PR 就緒並 @目前審核者,
|
||
// 停滯點在審核者,不是作者停滯——球在審核者,讓既有
|
||
// reviewer 催促與 48 小時改派規則自然適用。
|
||
reason = "reviewer-idle"
|
||
} else {
|
||
reason = "author-idle"
|
||
}
|
||
case lastCommentBy != "" && containsString(reviewers, lastCommentBy):
|
||
reason = "reviewer-idle"
|
||
}
|
||
item := Item{
|
||
Type: "pull", Repo: pr.FullName(), Number: pr.Number,
|
||
Title: pr.Title, URL: pr.WebURL(),
|
||
Author: author, Reviewers: reviewers,
|
||
LastCommentBy: lastCommentBy, LastActivityAt: lastActivity,
|
||
StalledHours: round1(stalled), Reason: reason,
|
||
}
|
||
return item, true
|
||
}
|
||
|
||
// round1 舍入到小數一位(對應 Python round(x, 1))。
|
||
// 經 strconv 的十進位正確舍入(半偶數),與 Python 的浮點 round 行為一致,
|
||
// 避免 (x*10+0.5) 截斷在 .x5 邊界產生偏差。
|
||
func round1(v float64) float64 {
|
||
r, err := strconv.ParseFloat(strconv.FormatFloat(v, 'f', 1, 64), 64)
|
||
if err != nil {
|
||
return v
|
||
}
|
||
return r
|
||
}
|
||
|
||
// ── CLI 接線用的高階入口(#7:teai stalled/teai xrefs)─────────────────
|
||
|
||
// StalledSource 是 stalled/xrefs 命令需要的完整資料源:
|
||
// Source(掃描用讀取)加上目前帳號與單一 issue 查詢。
|
||
// APIClient(giteaapi.go)實作此介面;測試以假資料源注入。
|
||
type StalledSource interface {
|
||
Source
|
||
// CurrentUser 回傳目前帳號名(催促冷卻判定用)。
|
||
CurrentUser() (string, error)
|
||
// GetIssue 嚴格版單一 issue/PR 查詢;失敗回錯誤(xrefs 命令用,
|
||
// 404 應回 ErrAPI 由 CLI 映射結束碼 3)。
|
||
GetIssue(owner, repo string, number int64) (Issue, error)
|
||
}
|
||
|
||
// RunStalled 是 teai stalled 的核心:解析目前帳號後跑 ScanStalled。
|
||
// hours <= 0 時採 StalledDefaultHours(對應 gitea.py stalled [--hours N]
|
||
// 的預設 4 小時);旗標層已先攔下非正數,這裡是防禦。
|
||
func RunStalled(src StalledSource, hours float64, now time.Time) ([]Item, error) {
|
||
if hours <= 0 {
|
||
hours = StalledDefaultHours
|
||
}
|
||
username, err := src.CurrentUser()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return ScanStalled(src, username, hours, now)
|
||
}
|
||
|
||
// XrefHit 是 teai xrefs 的單筆輸出:解析後的目標加上出處
|
||
// (README 規格:"body"=內文、"comment"=留言)。
|
||
type XrefHit struct {
|
||
Owner string `json:"owner"`
|
||
Repo string `json:"repo"`
|
||
Number int64 `json:"number"`
|
||
Source string `json:"source"`
|
||
}
|
||
|
||
// ResolveIssueXrefs 是 teai xrefs 的核心:取回單一 issue/PR 的內文與
|
||
// 全部留言,解析其中的 #N、repo#N、owner/repo#N 引用(規則同
|
||
// ResolveXrefTargets:單段 repo 僅在父項所屬組織有該倉庫時解析、
|
||
// owner/repo#N 僅在 owner 為所屬組織時解析),去重並保留出現順序,
|
||
// 每筆附首次出現的出處(內文先於留言)。stalled 的父追蹤項判定
|
||
// 內部用同一套解析,不重複實作。
|
||
func ResolveIssueXrefs(src StalledSource, owner, repo string, number int64) ([]XrefHit, error) {
|
||
issue, err := src.GetIssue(owner, repo, number)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
comments, err := src.IssueComments(owner, repo, number)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
orgs, err := src.MyOrganizations()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
myOrgs := make(map[string]bool, len(orgs))
|
||
for _, o := range orgs {
|
||
myOrgs[o] = true
|
||
}
|
||
// 組織倉庫清單跨呼叫快取,避免逐則留言重打 API。
|
||
orgRepoCache := map[string][]string{}
|
||
orgRepos := func(org string) ([]string, error) {
|
||
if names, ok := orgRepoCache[org]; ok {
|
||
return names, nil
|
||
}
|
||
repos, err := src.OrganizationRepositories(org)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
names := make([]string, 0, len(repos))
|
||
for _, r := range repos {
|
||
if r.Name != "" {
|
||
names = append(names, r.Name)
|
||
}
|
||
}
|
||
orgRepoCache[org] = names
|
||
return names, nil
|
||
}
|
||
type sourceText struct {
|
||
text string
|
||
source string
|
||
}
|
||
texts := []sourceText{{issue.Body, "body"}}
|
||
for _, c := range comments {
|
||
texts = append(texts, sourceText{c.Body, "comment"})
|
||
}
|
||
var hits []XrefHit
|
||
seen := map[XrefTarget]bool{}
|
||
for _, t := range texts {
|
||
targets, err := ResolveXrefTargets(owner, repo, []string{t.text}, myOrgs, orgRepos)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, target := range targets {
|
||
if seen[target] {
|
||
continue
|
||
}
|
||
seen[target] = true
|
||
hits = append(hits, XrefHit{
|
||
Owner: target.Owner, Repo: target.Repo,
|
||
Number: target.Number, Source: t.source,
|
||
})
|
||
}
|
||
}
|
||
return hits, nil
|
||
}
|