Files
teai/internal/workflow/workflow.go
T
ceo 4582dfaa4e internal/workflow:統一型別宣告,修復 #12 合併後 main 編譯失敗(#21)
#12 的 workflow.go 與 #13 的 types.go 各自宣告 User/Comment/Issue/
PullRequest/Repository,文字合併無衝突但語意衝突,main 自 a8c1f3e 起
go build 失敗。

統一採 #13 的字串模型(types.go 為唯一型別來源):
- Issue/Comment 補 State/Body/ClosedAt 欄位,新增 parseMoment
  (對應 gitea.py _parse_iso8601;保留原時區偏移,輸出與 Python
  isoformat 位元組一致)
- workflow.go 移除重複宣告,stalled/xref 邏輯改用該模型與 judge.go
  既有輔助(AssigneeNames/ReviewerNames/LastComment/containsString)
- Source 介面 number 改 int64(與 types.go 對齊),cmd/xcheck 同步
- workflow_test.go 測試資料改新型別模型(字串時刻、指標欄位)

驗證:
- go build/go vet/go test ./... 全綠(#6 與 #7 案例皆未回歸)
- xcheck 對真實 API 並行掃描,stalled 輸出與 gitea.py 位元組一致(含
  bear-cli#9 父追蹤項子項摘要、+08:00 時區偏移)
- xref 邊界案例(nope#9/PR#50 略過、bear-cli#9 內嵌解析)與 Python 一致
2026-09-10 11:32:40 +08:00

529 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 套件 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 (?<![\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);時間未知則不排除
}
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
}