internal/workflow:xref 解析與 stalled 掃描核心邏輯(#7) #12

Merged
queena merged 3 commits from feat/issue7-stalled-xrefs into main 2026-09-10 10:35:35 +08:00
3 changed files with 109 additions and 41 deletions
Showing only changes of commit b2dd2f3aac - Show all commits
+21 -4
View File
@@ -6,6 +6,7 @@
package main package main
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -211,8 +212,17 @@ func main() {
fmt.Fprintln(os.Stderr, "ScanStalled:", err) fmt.Fprintln(os.Stderr, "ScanStalled:", err)
os.Exit(1) os.Exit(1)
} }
out, _ := json.MarshalIndent(items, "", " ") // json.Marshal* 會把 <、>、& 轉義成 \u003c 等(含自訂 MarshalJSON 的輸出),
fmt.Println(string(out)) // 與 gitea.py(ensure_ascii=False)位元組不相容;必須停用 HTML 轉義。
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(items); err != nil {
fmt.Fprintln(os.Stderr, "Encode:", err)
os.Exit(1)
}
fmt.Print(buf.String())
} }
// xrefsMain 對 stdin 的每行文字解析引用目標並輸出 JSON。 // xrefsMain 對 stdin 的每行文字解析引用目標並輸出 JSON。
@@ -251,6 +261,13 @@ func xrefsMain() {
fmt.Fprintln(os.Stderr, "ResolveXrefTargets:", err) fmt.Fprintln(os.Stderr, "ResolveXrefTargets:", err)
os.Exit(1) os.Exit(1)
} }
out, _ := json.MarshalIndent(targets, "", " ") var buf bytes.Buffer
fmt.Println(string(out)) enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(targets); err != nil {
fmt.Fprintln(os.Stderr, "Encode:", err)
os.Exit(1)
}
fmt.Print(buf.String())
} }
+43 -18
View File
@@ -10,6 +10,7 @@
package workflow package workflow
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"regexp" "regexp"
@@ -17,6 +18,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode"
"unicode/utf8"
) )
// 常數與 gitea.py 保持一致(STALLED_DEFAULT_HOURS、NUDGE_COOLDOWN_HOURS)。 // 常數與 gitea.py 保持一致(STALLED_DEFAULT_HOURS、NUDGE_COOLDOWN_HOURS)。
@@ -154,7 +157,9 @@ func (i Item) MarshalJSON() ([]byte, error) {
b.WriteString(`,"last_activity_at":`) b.WriteString(`,"last_activity_at":`)
writeJSON(&b, FormatMoment(i.LastActivityAt)) writeJSON(&b, FormatMoment(i.LastActivityAt))
b.WriteString(`,"stalled_hours":`) b.WriteString(`,"stalled_hours":`)
fmt.Fprintf(&b, "%g", i.StalledHours) // 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":`) b.WriteString(`,"reason":`)
writeJSON(&b, i.Reason) writeJSON(&b, i.Reason)
if len(i.ChildActivity) > 0 { if len(i.ChildActivity) > 0 {
@@ -174,12 +179,17 @@ func FormatMoment(t time.Time) string {
return t.Format("2006-01-02T15:04:05-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) { func writeJSON(b *strings.Builder, v any) {
enc, err := json.Marshal(v) var buf bytes.Buffer
if err != nil { enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return return
} }
b.Write(enc) b.Write(bytes.TrimSuffix(buf.Bytes(), []byte{'\n'}))
} }
// Child 是父追蹤項的子項活動摘要。 // Child 是父追蹤項的子項活動摘要。
@@ -188,7 +198,8 @@ type Child struct {
Number int `json:"number"` Number int `json:"number"`
Title string `json:"title"` Title string `json:"title"`
State string `json:"state"` State string `json:"state"`
LastActivityAt string `json:"last_activity_at"` // LastActivityAt 用指標:子項無可解析時刻時 Python 輸出 null(不是 "")。
LastActivityAt *string `json:"last_activity_at"`
} }
// XrefTarget 是解析後的引用目標(owner、repo、number)。 // XrefTarget 是解析後的引用目標(owner、repo、number)。
@@ -215,32 +226,43 @@ type Source interface {
} }
// iterXrefs 產出文字中的 (prefix, number) 配對(對應 _iter_issue_xrefs)。 // iterXrefs 產出文字中的 (prefix, number) 配對(對應 _iter_issue_xrefs)。
// 前一字元是文數字/底線/斜線者略過(Python lookbehind 的等價實作)。 //
// 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 { func iterXrefs(text string) [][2]string {
var out [][2]string var out [][2]string
for _, m := range xrefPattern.FindAllStringIndex(text, -1) { pos := 0
start := m[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 { if start > 0 {
prev := rune(text[start-1]) prev, _ := utf8.DecodeLastRuneInString(text[:start])
if isWordChar(prev) || prev == '/' { if isWordChar(prev) || prev == '/' || prev == '#' {
_, size := utf8.DecodeRuneInString(text[start:])
pos = start + size
continue continue
} }
} }
match := text[m[0]:m[1]] match := text[start:end]
i := strings.IndexByte(match, '#') i := strings.IndexByte(match, '#')
prefix := match[:i] prefix := match[:i]
number := match[i+1:] number := match[i+1:]
out = append(out, [2]string{prefix, number}) out = append(out, [2]string{prefix, number})
pos = end
} }
return out return out
} }
// isWordChar 對應 Python \w(Unicode):字母/數字/底線。
// 前一版只認 ASCII,中文緊鄰 #N 時(「修復#12」)與 Python 行為分歧。
func isWordChar(r rune) bool { func isWordChar(r rune) bool {
return r == '_' || isAlnum(r) return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)
}
func isAlnum(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
} }
// ResolveXrefTargets 把內文/留言中的引用解析成目標清單(對應 _resolve_xref_targets)。 // ResolveXrefTargets 把內文/留言中的引用解析成目標清單(對應 _resolve_xref_targets)。
@@ -332,11 +354,14 @@ func childActivity(src Source, org, repo string, parent Issue, comments []Commen
} }
} }
} }
var last string var last *string
if len(ms) > 0 { if len(ms) > 0 {
latest := maxTime(ms) latest := maxTime(ms)
moments = append(moments, latest) moments = append(moments, latest)
last = latest.Format(time.RFC3339) // 與 Item 相同用 FormatMoment(Python isoformat:UTC 記 +00:00);
// RFC3339 對 UTC 會輸出 Z,與基準不相容。
s := FormatMoment(latest)
last = &s
} }
children = append(children, Child{ children = append(children, Child{
Repo: t.Owner + "/" + t.Repo, Repo: t.Owner + "/" + t.Repo,
+41 -15
View File
@@ -5,9 +5,11 @@
package workflow package workflow
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -86,6 +88,12 @@ func TestIterXrefsEdges(t *testing.T) {
{"斜線後不切半段", "alterminal/bear#47 只應匹配整段", [][2]string{{"alterminal/bear", "47"}}}, {"斜線後不切半段", "alterminal/bear#47 只應匹配整段", [][2]string{{"alterminal/bear", "47"}}},
{"連字號 repo 名", "bear-cli#9", [][2]string{{"bear-cli", "9"}}}, {"連字號 repo 名", "bear-cli#9", [][2]string{{"bear-cli", "9"}}},
{"路徑後的 #N 不匹配", "see https://x/a/#frag not #5", [][2]string{{"", "5"}}}, {"路徑後的 #N 不匹配", "see https://x/a/#frag not #5", [][2]string{{"", "5"}}},
// Python \w 是 Unicode:CJK 緊鄰 #N 時整體視為 \w,lookbehind 擋下。
{"CJK 緊鄰 #N 略過", "修復#12的問題,見 #5", [][2]string{{"", "5"}}},
{"CJK 緊鄰 repo#N 整段略過", "修復agents#12", nil},
{"CJK 緊鄰連字號 repo 匹配後半", "修復bear-cli#9 見 #5", [][2]string{{"cli", "9"}, {"", "5"}}},
{"連續井號", "##5", nil},
{"全形數字後 #N", "12#5", nil},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -424,30 +432,48 @@ func TestScanStalledSkipsPRsInIssues(t *testing.T) {
} }
} }
// TestItemJSON 輸出欄位與 gitea.py 對齊(last_activity_at 序列化、omitempty)。 // TestItemJSON 與 gitea.py 的 JSON 位元組相容(鍵序、HTML 不轉義、48.0、null)。
func TestItemJSON(t *testing.T) { func TestItemJSON(t *testing.T) {
ts := at(-600) ts := at(-600)
activity := FormatMoment(ts)
item := Item{ item := Item{
Type: "issue", Repo: "alterminal/teai", Number: 7, Title: "t", URL: "u", Type: "issue", Repo: "alterminal/teai", Number: 7, Title: "R,D <v2> & 規劃", URL: "u",
Assignees: []string{"ceo"}, LastCommentBy: "max", Assignees: []string{"ceo"}, LastCommentBy: "max",
LastActivityAt: ts, StalledHours: 10, Reason: "assignee-idle", LastActivityAt: ts, StalledHours: 48, Reason: "assignee-idle",
ChildActivity: []Child{{Repo: "alterminal/teai", Number: 3, Title: "父", State: "open", LastActivityAt: ts.Format(time.RFC3339)}}, ChildActivity: []Child{
{Repo: "alterminal/teai", Number: 3, Title: "父", State: "open", LastActivityAt: &activity},
{Repo: "alterminal/teai", Number: 4, Title: "無時刻", State: "open"},
},
}
// json.Marshal 會把 <、>、& 重新轉義,必須用停用 HTML 轉義的編碼器。
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(item); err != nil {
t.Fatalf("Encode: %v", err)
}
got := strings.TrimSuffix(buf.String(), "\n")
want := `{"type":"issue","repo":"alterminal/teai","number":7,"title":"R,D <v2> & 規劃","url":"u","assignees":["ceo"],"last_comment_by":"max","last_activity_at":"2026-09-09T22:00:00+00:00","stalled_hours":48.0,"reason":"assignee-idle","child_activity":[{"repo":"alterminal/teai","number":3,"title":"父","state":"open","last_activity_at":"2026-09-09T22:00:00+00:00"},{"repo":"alterminal/teai","number":4,"title":"無時刻","state":"open","last_activity_at":null}]}`
if got != want {
t.Fatalf("JSON 位元組不相容:\n got %s\nwant %s", got, want)
}
}
// TestPullJSON:pull 的鍵序(author、reviewers)與非整數 stalled_hours。
func TestPullJSON(t *testing.T) {
item := Item{
Type: "pull", Repo: "alterminal/teai", Number: 12, Title: "t", URL: "u",
Author: "ceo", Reviewers: []string{"queena"},
LastActivityAt: at(-600), StalledHours: 10.44, Reason: "reviewer-idle",
} }
b, err := json.Marshal(item) b, err := json.Marshal(item)
if err != nil { if err != nil {
t.Fatalf("Marshal: %v", err) t.Fatalf("Marshal: %v", err)
} }
var m map[string]any got := string(b)
if err := json.Unmarshal(b, &m); err != nil { want := `{"type":"pull","repo":"alterminal/teai","number":12,"title":"t","url":"u","author":"ceo","reviewers":["queena"],"last_comment_by":"","last_activity_at":"2026-09-09T22:00:00+00:00","stalled_hours":10.4,"reason":"reviewer-idle"}`
t.Fatalf("Unmarshal: %v", err) if got != want {
} t.Fatalf("JSON 位元組不相容:\n got %s\nwant %s", got, want)
for _, key := range []string{"type", "repo", "number", "title", "url", "assignees", "last_comment_by", "last_activity_at", "stalled_hours", "reason", "child_activity"} {
if _, ok := m[key]; !ok {
t.Errorf("輸出缺少欄位 %q:%s", key, b)
}
}
if _, ok := m["author"]; ok {
t.Errorf("issue 不應輸出 author:%s", b)
} }
} }