依 #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 一致。
274 lines
7.2 KiB
Go
274 lines
7.2 KiB
Go
// cmd/xcheck 是 #7 的交叉驗證工具(不屬於 teai 本體,PR 內附驗證說明)。
|
||
//
|
||
// 以真實 Gitea API 資料源實作 workflow.Source,跑 Go 版 ScanStalled,
|
||
// 再與 gitea.py stalled(行為基準)的 JSON 輸出逐欄位比對。
|
||
// 建立限制:與 teai 本體一致,僅標準庫。
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitea.alterminal.com/alterminal/teai/internal/workflow"
|
||
)
|
||
|
||
// apiSource 以 Gitea API 實作 workflow.Source。
|
||
type apiSource struct {
|
||
base string
|
||
token string
|
||
hc *http.Client
|
||
}
|
||
|
||
func (s *apiSource) get(path string, params map[string]string) ([]byte, int, error) {
|
||
u := s.base + path
|
||
if len(params) > 0 {
|
||
q := url.Values{}
|
||
for k, v := range params {
|
||
q.Set(k, v)
|
||
}
|
||
u += "?" + q.Encode()
|
||
}
|
||
req, err := http.NewRequest("GET", u, nil)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
req.Header.Set("Accept", "application/json")
|
||
req.Header.Set("Authorization", "token "+s.token)
|
||
resp, err := s.hc.Do(req)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(resp.Body)
|
||
return body, resp.StatusCode, err
|
||
}
|
||
|
||
func (s *apiSource) getJSON(path string, params map[string]string, out any) error {
|
||
body, code, err := s.get(path, params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if code != 200 {
|
||
return fmt.Errorf("GET %s: HTTP %d", path, code)
|
||
}
|
||
return json.Unmarshal(body, out)
|
||
}
|
||
|
||
// listAll 分頁拉取清單類 API(Gitea 上限 50/頁)。
|
||
func (s *apiSource) listAll(path string, params map[string]string, out any) error {
|
||
var items []json.RawMessage
|
||
page := 1
|
||
for {
|
||
q := map[string]string{"page": fmt.Sprint(page), "limit": "50"}
|
||
for k, v := range params {
|
||
q[k] = v
|
||
}
|
||
var chunk json.RawMessage
|
||
if err := s.getJSON(path, q, &chunk); err != nil {
|
||
return err
|
||
}
|
||
var arr []json.RawMessage
|
||
if err := json.Unmarshal(chunk, &arr); err != nil {
|
||
return fmt.Errorf("GET %s: 回傳非陣列", path)
|
||
}
|
||
items = append(items, arr...)
|
||
if len(arr) < 50 {
|
||
break
|
||
}
|
||
page++
|
||
}
|
||
var b strings.Builder
|
||
b.WriteByte('[')
|
||
for i, item := range items {
|
||
if i > 0 {
|
||
b.WriteByte(',')
|
||
}
|
||
b.Write(item)
|
||
}
|
||
b.WriteByte(']')
|
||
return json.Unmarshal([]byte(b.String()), out)
|
||
}
|
||
|
||
func (s *apiSource) MyOrganizations() ([]string, error) {
|
||
var orgs []struct {
|
||
Username string `json:"username"`
|
||
}
|
||
if err := s.listAll("user/orgs", nil, &orgs); err != nil {
|
||
return nil, err
|
||
}
|
||
names := make([]string, 0, len(orgs))
|
||
for _, o := range orgs {
|
||
if o.Username != "" {
|
||
names = append(names, o.Username)
|
||
}
|
||
}
|
||
return names, nil
|
||
}
|
||
|
||
func (s *apiSource) OrganizationRepositories(org string) ([]workflow.Repository, error) {
|
||
var repos []workflow.Repository
|
||
if err := s.listAll("orgs/"+org+"/repos", nil, &repos); err != nil {
|
||
return nil, err
|
||
}
|
||
return repos, nil
|
||
}
|
||
|
||
func (s *apiSource) OpenIssues(owner, repo string) ([]workflow.Issue, error) {
|
||
var issues []workflow.Issue
|
||
// 與 gitea.py 一致:type=issues 排除 PR;state=open。
|
||
if err := s.listAll(fmt.Sprintf("repos/%s/%s/issues", owner, repo), map[string]string{"type": "issues", "state": "open"}, &issues); err != nil {
|
||
return nil, err
|
||
}
|
||
return issues, nil
|
||
}
|
||
|
||
func (s *apiSource) OpenPulls(owner, repo string) ([]workflow.PullRequest, error) {
|
||
var pulls []workflow.PullRequest
|
||
if err := s.listAll(fmt.Sprintf("repos/%s/%s/pulls", owner, repo), map[string]string{"state": "open"}, &pulls); err != nil {
|
||
return nil, err
|
||
}
|
||
return pulls, nil
|
||
}
|
||
|
||
func (s *apiSource) IssueComments(owner, repo string, number int) ([]workflow.Comment, error) {
|
||
var comments []workflow.Comment
|
||
if err := s.listAll(fmt.Sprintf("repos/%s/%s/issues/%d/comments", owner, repo, number), nil, &comments); err != nil {
|
||
return nil, err
|
||
}
|
||
return comments, nil
|
||
}
|
||
|
||
func (s *apiSource) TryGetIssue(owner, repo string, number int) (workflow.Issue, bool, error) {
|
||
var issue workflow.Issue
|
||
body, code, err := s.get(fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, number), nil)
|
||
if err != nil {
|
||
return workflow.Issue{}, false, nil
|
||
}
|
||
if code != 200 {
|
||
return workflow.Issue{}, false, nil
|
||
}
|
||
if err := json.Unmarshal(body, &issue); err != nil {
|
||
return workflow.Issue{}, false, nil
|
||
}
|
||
return issue, true, nil
|
||
}
|
||
|
||
// loadEnv 讀 agents 倉庫的 .env(ACCESS_TOKEN、GITEA_API_DOMAIN)。
|
||
func loadEnv(path string) {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
k, v, ok := strings.Cut(line, "=")
|
||
if !ok {
|
||
continue
|
||
}
|
||
k = strings.TrimSpace(k)
|
||
v = strings.Trim(strings.TrimSpace(v), "'\"")
|
||
if os.Getenv(k) == "" {
|
||
os.Setenv(k, v)
|
||
}
|
||
}
|
||
}
|
||
|
||
func main() {
|
||
// --xrefs 模式:從 stdin 讀文字,輸出解析後的引用目標(與 gitea.py
|
||
// _resolve_xref_targets 對照用)。組織與倉庫清單來自真實 API。
|
||
if len(os.Args) > 1 && os.Args[1] == "--xrefs" {
|
||
xrefsMain()
|
||
return
|
||
}
|
||
loadEnv(os.Getenv("HOME") + "/agents/.env")
|
||
domain := os.Getenv("GITEA_API_DOMAIN")
|
||
token := os.Getenv("ACCESS_TOKEN")
|
||
if domain == "" || token == "" {
|
||
fmt.Fprintln(os.Stderr, "缺少 GITEA_API_DOMAIN/ACCESS_TOKEN")
|
||
os.Exit(1)
|
||
}
|
||
src := &apiSource{
|
||
base: "https://" + domain + "/api/v1/",
|
||
token: token,
|
||
hc: &http.Client{Timeout: 30 * time.Second},
|
||
}
|
||
username := os.Getenv("USERNAME")
|
||
if username == "" {
|
||
username = "ceo"
|
||
}
|
||
hours := 4.0
|
||
items, err := workflow.ScanStalled(src, username, hours, time.Now())
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "ScanStalled:", err)
|
||
os.Exit(1)
|
||
}
|
||
// 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。
|
||
func xrefsMain() {
|
||
loadEnv(os.Getenv("HOME") + "/agents/.env")
|
||
domain := os.Getenv("GITEA_API_DOMAIN")
|
||
token := os.Getenv("ACCESS_TOKEN")
|
||
if domain == "" || token == "" {
|
||
fmt.Fprintln(os.Stderr, "缺少 GITEA_API_DOMAIN/ACCESS_TOKEN")
|
||
os.Exit(1)
|
||
}
|
||
src := &apiSource{
|
||
base: "https://" + domain + "/api/v1/",
|
||
token: token,
|
||
hc: &http.Client{Timeout: 30 * time.Second},
|
||
}
|
||
orgs, err := src.MyOrganizations()
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "MyOrganizations:", err)
|
||
os.Exit(1)
|
||
}
|
||
myOrgs := map[string]bool{}
|
||
for _, o := range orgs {
|
||
myOrgs[o] = true
|
||
}
|
||
data, _ := io.ReadAll(os.Stdin)
|
||
repos, _ := src.OrganizationRepositories("alterminal")
|
||
names := make([]string, 0, len(repos))
|
||
for _, r := range repos {
|
||
names = append(names, r.Name)
|
||
}
|
||
targets, err := workflow.ResolveXrefTargets("alterminal", "agents", []string{string(data)}, myOrgs, func(string) ([]string, error) {
|
||
return names, nil
|
||
})
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "ResolveXrefTargets:", err)
|
||
os.Exit(1)
|
||
}
|
||
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())
|
||
}
|