#12 的 workflow.go 與 #13 的 types.go 各自宣告 User/Comment/Issue/
PullRequest/Repository,文字合併無衝突但語意衝突,main 自 a8c1f3e 起
go build 失敗。
統一採 #13 的字串模型(types.go 為唯一型別來源):
- Issue/Comment 補 State/Body/ClosedAt 欄位,新增 parseMoment
(對應 gitea.py _parse_iso8601;保留原時區偏移,輸出與 Python
isoformat 位元組一致)
- workflow.go 移除重複宣告,stalled/xref 邏輯改用該模型與 judge.go
既有輔助(AssigneeNames/ReviewerNames/LastComment/containsString)
- Source 介面 number 改 int64(與 types.go 對齊),cmd/xcheck 同步
- workflow_test.go 測試資料改新型別模型(字串時刻、指標欄位)
驗證:
- go build/go vet/go test ./... 全綠(#6 與 #7 案例皆未回歸)
- xcheck 對真實 API 並行掃描,stalled 輸出與 gitea.py 位元組一致(含
bear-cli#9 父追蹤項子項摘要、+08:00 時區偏移)
- xref 邊界案例(nope#9/PR#50 略過、bear-cli#9 內嵌解析)與 Python 一致
295 lines
7.9 KiB
Go
295 lines
7.9 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)
|
||
}
|
||
|
||
// 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) 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())
|
||
}
|