#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 一致
529 lines
17 KiB
Go
529 lines
17 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
|
||
)
|
||
|
||
// 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
|
||
}
|