// 套件 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 ) // 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) } // iterXrefs 產出文字中的 (prefix, number) 配對(對應 _iter_issue_xrefs)。 // // Python 原版以 lookbehind (? 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);時間未知則不排除 } var ms []time.Time if m, ok := parseMoment(child.UpdatedAt); 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) } } } 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 } 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(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) var moments []time.Time if m, ok := parseMoment(issue.UpdatedAt); ok { moments = append(moments, m) } if m, ok := parseMoment(issue.CreatedAt); ok { moments = append(moments, m) } for _, c := range comments { if m, ok := parseMoment(c.CreatedAt); ok { moments = append(moments, m) } } moments = append(moments, childMoments...) 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): reason = "assignee-idle" case lastCommentBy == "": reason = "no-commenter" } if len(children) > 0 && anyOpen(children) { 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(pr PullRequest, comments []Comment, username string, threshold float64, now time.Time) (Item, bool) { reviewers := ReviewerNames(&pr) author := pr.Author() var moments []time.Time if m, ok := parseMoment(pr.UpdatedAt); ok { moments = append(moments, m) } if m, ok := parseMoment(pr.CreatedAt); ok { moments = append(moments, m) } for _, c := range comments { if m, ok := parseMoment(c.CreatedAt); ok { moments = append(moments, m) } } 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: 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 } func anyOpen(children []Child) bool { for _, c := range children { if c.State == "open" { return true } } return false } // 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 }