// 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) } // CurrentUser 以 token 呼叫 /user 取實際帳號,與 gitea.py 的 // get_current_username() 同源(token 決定帳號,不接受環境變數覆寫, // 失敗即報錯退出,不靜默退回預設值)。 func (s *apiSource) CurrentUser() (string, error) { var user struct { Username string `json:"username"` Login string `json:"login"` } if err := s.getJSON("user", nil, &user); err != nil { return "", err } if user.Username != "" { return user.Username, nil } if user.Login != "" { return user.Login, nil } return "", fmt.Errorf("無法從 Gitea API /user 取得目前帳號") } 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.Repo, error) { var repos []workflow.Repo 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 int64) ([]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) IssueTimeline(owner, repo string, number int64) ([]workflow.TimelineEvent, error) { var events []workflow.TimelineEvent if err := s.listAll(fmt.Sprintf("repos/%s/%s/issues/%d/timeline", owner, repo, number), nil, &events); err != nil { return nil, err } return events, nil } func (s *apiSource) TryGetIssue(owner, repo string, number int64) (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, err := src.CurrentUser() if err != nil { fmt.Fprintln(os.Stderr, "CurrentUser:", err) os.Exit(1) } hours := 4.0 items, scanErr := workflow.ScanStalled(src, username, hours, time.Now()) if scanErr != nil { fmt.Fprintln(os.Stderr, "ScanStalled:", scanErr) 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()) }