From b2dd2f3aacc3eeef9bfae707b0600433a4aac68d Mon Sep 17 00:00:00 2001 From: ceo Date: Thu, 10 Sep 2026 09:47:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20JSON=20=E4=BD=8D=E5=85=83?= =?UTF-8?q?=E7=B5=84=E7=9B=B8=E5=AE=B9=E8=88=87=20xref=20Unicode=20?= =?UTF-8?q?=E9=82=8A=E7=95=8C=EF=BC=88=E5=AF=A9=E6=A0=B8=E6=84=8F=E8=A6=8B?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依 #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 一致。 --- cmd/xcheck/main.go | 25 +++++++++-- internal/workflow/workflow.go | 69 ++++++++++++++++++++---------- internal/workflow/workflow_test.go | 56 +++++++++++++++++------- 3 files changed, 109 insertions(+), 41 deletions(-) diff --git a/cmd/xcheck/main.go b/cmd/xcheck/main.go index 91f36f1..f7af081 100644 --- a/cmd/xcheck/main.go +++ b/cmd/xcheck/main.go @@ -6,6 +6,7 @@ package main import ( + "bytes" "encoding/json" "fmt" "io" @@ -211,8 +212,17 @@ func main() { fmt.Fprintln(os.Stderr, "ScanStalled:", err) os.Exit(1) } - out, _ := json.MarshalIndent(items, "", " ") - fmt.Println(string(out)) + // json.Marshal* 會把 <、>、& 轉義成 \u003c 等(含自訂 MarshalJSON 的輸出), + // 與 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。 @@ -251,6 +261,13 @@ func xrefsMain() { fmt.Fprintln(os.Stderr, "ResolveXrefTargets:", err) os.Exit(1) } - out, _ := json.MarshalIndent(targets, "", " ") - fmt.Println(string(out)) + var buf bytes.Buffer + 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()) } diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index 6f553b0..46efea8 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -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 (? 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, diff --git a/internal/workflow/workflow_test.go b/internal/workflow/workflow_test.go index 079816d..47da3c0 100644 --- a/internal/workflow/workflow_test.go +++ b/internal/workflow/workflow_test.go @@ -5,9 +5,11 @@ package workflow import ( + "bytes" "encoding/json" "errors" "fmt" + "strings" "testing" "time" ) @@ -86,6 +88,12 @@ func TestIterXrefsEdges(t *testing.T) { {"斜線後不切半段", "alterminal/bear#47 只應匹配整段", [][2]string{{"alterminal/bear", "47"}}}, {"連字號 repo 名", "bear-cli#9", [][2]string{{"bear-cli", "9"}}}, {"路徑後的 #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 { 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) { ts := at(-600) + activity := FormatMoment(ts) item := Item{ - Type: "issue", Repo: "alterminal/teai", Number: 7, Title: "t", URL: "u", + Type: "issue", Repo: "alterminal/teai", Number: 7, Title: "R,D & 規劃", URL: "u", Assignees: []string{"ceo"}, LastCommentBy: "max", - LastActivityAt: ts, StalledHours: 10, Reason: "assignee-idle", - ChildActivity: []Child{{Repo: "alterminal/teai", Number: 3, Title: "父", State: "open", LastActivityAt: ts.Format(time.RFC3339)}}, + LastActivityAt: ts, StalledHours: 48, Reason: "assignee-idle", + 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 & 規劃","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) if err != nil { t.Fatalf("Marshal: %v", err) } - var m map[string]any - if err := json.Unmarshal(b, &m); err != nil { - t.Fatalf("Unmarshal: %v", err) - } - 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) + got := string(b) + 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"}` + if got != want { + t.Fatalf("JSON 位元組不相容:\n got %s\nwant %s", got, want) } }