修正 JSON 位元組相容與 xref Unicode 邊界(審核意見)

依 #12 審核意見修正四項偏差,並補一項同源問題:

1. iterXrefs 前一字元檢查改 Unicode:isWordChar 改用
   unicode.IsLetter/IsDigit,且以 utf8 正確解碼前一 rune
   (原版只認 ASCII 又取單一位元組)。另補齊 Python finditer
   的重試語義:起點被 lookbehind 擋下時從下一 rune 重試,
   否則「修復bear-cli#9」會整段丟失(Python 匹配後半 cli#9);
   lookbehind 字元集含 #(##5 不匹配)。
2. writeJSON 改 json.Encoder + SetEscapeHTML(false):
   Python ensure_ascii=False 不轉義 <、>、&,json.Marshal 預設
   會,標題含這些字元即失去位元組相容。
3. stalled_hours 改 strconv.FormatFloat(v,'f',1,64):整數值輸出
   48.0(原 %g 輸出 48,極大值還會變 1e+06)。
4. Child.last_activity_at 無可解析時刻時輸出 null(原輸出 ""),
   型別改 *string;格式對齊 FormatMoment(UTC 記 +00:00 非 Z)。
5. cmd/xcheck 輸出同樣停用 HTML 轉義(Marshal* 對自訂
   MarshalJSON 的輸出仍會轉義)。

驗證:go build/vet/test 全綠(16 測試函式,新增 CJK、##5、
全形數字、HTML 字元、48.0、null 等案例);cmd/xcheck 與
gitea.py stalled 對真實 API 並行掃描輸出位元組相同(1749B,
含 bear-cli#9 父追蹤項與 5 子項摘要);--xrefs 對 CJK
邊界輸入與 Python 一致。
This commit is contained in:
ceo
2026-09-10 09:47:46 +08:00
parent a86803d336
commit b2dd2f3aac
3 changed files with 109 additions and 41 deletions
+47 -22
View File
@@ -10,6 +10,7 @@
package workflow
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
@@ -17,6 +18,8 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
// 常數與 gitea.py 保持一致(STALLED_DEFAULT_HOURS、NUDGE_COOLDOWN_HOURS)。
@@ -154,7 +157,9 @@ func (i Item) MarshalJSON() ([]byte, error) {
b.WriteString(`,"last_activity_at":`)
writeJSON(&b, FormatMoment(i.LastActivityAt))
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":`)
writeJSON(&b, i.Reason)
if len(i.ChildActivity) > 0 {
@@ -174,21 +179,27 @@ func FormatMoment(t time.Time) string {
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) {
enc, err := json.Marshal(v)
if err != nil {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return
}
b.Write(enc)
b.Write(bytes.TrimSuffix(buf.Bytes(), []byte{'\n'}))
}
// Child 是父追蹤項的子項活動摘要。
type Child struct {
Repo string `json:"repo"`
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
LastActivityAt string `json:"last_activity_at"`
Repo string `json:"repo"`
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
// LastActivityAt 用指標:子項無可解析時刻時 Python 輸出 null(不是 "")。
LastActivityAt *string `json:"last_activity_at"`
}
// XrefTarget 是解析後的引用目標(owner、repo、number)。
@@ -215,32 +226,43 @@ type Source interface {
}
// 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 {
var out [][2]string
for _, m := range xrefPattern.FindAllStringIndex(text, -1) {
start := m[0]
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 := rune(text[start-1])
if isWordChar(prev) || prev == '/' {
prev, _ := utf8.DecodeLastRuneInString(text[:start])
if isWordChar(prev) || prev == '/' || prev == '#' {
_, size := utf8.DecodeRuneInString(text[start:])
pos = start + size
continue
}
}
match := text[m[0]:m[1]]
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 == '_' || isAlnum(r)
}
func isAlnum(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)
}
// 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 {
latest := maxTime(ms)
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{
Repo: t.Owner + "/" + t.Repo,