internal/workflow:xref 解析與 stalled 掃描核心邏輯(#7)

完整移植 gitea.py 的 _resolve_xref_targets 與 find_stalled_work:
- xref:#N/repo#N/owner/repo#N;單段 repo 僅在存在於組織時解析
  (PR#50、issue#49 這類寫法自動略過);owner 需為所屬組織。
  Go regexp 無 lookbehind,掃描時檢查前一字元等價實作。
- stalled:reason 分類(issue:no-assignee/assignee-idle/no-commenter/
  waiting-outside/parent-tracking;PR:no-reviewer/author-idle/
  reviewer-idle/no-commenter)、24h 催促冷卻、父追蹤項把子項活動
  (留言、state 變更、PR 合併)計入 last_activity、上行引用不採計。
- 資料以 Source 介面注入:邏輯離線可測,CLI 接線待 #5 基礎建設。
- 輸出 JSON 與 gitea.py 位元組相容(鍵序、assignees/author 欄位、
  isoformat 時刻、round(x,1) 半偶數舍入)。
- cmd/xcheck:交叉驗證工具(--xrefs 與停滯掃描模式),已對真實 API
  與 gitea.py 並行比對,輸出一致。

go build/vet/test 全綠;單元測試覆蓋 xref 邊界與 stalled 判定。
This commit is contained in:
ceo
2026-09-10 08:36:31 +08:00
parent 14edef066b
commit a86803d336
3 changed files with 1363 additions and 0 deletions
+256
View File
@@ -0,0 +1,256 @@
// cmd/xcheck 是 #7 的交叉驗證工具(不屬於 teai 本體,PR 內附驗證說明)。
//
// 以真實 Gitea API 資料源實作 workflow.Source,跑 Go 版 ScanStalled,
// 再與 gitea.py stalled(行為基準)的 JSON 輸出逐欄位比對。
// 建立限制:與 teai 本體一致,僅標準庫。
package main
import (
"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)
}
out, _ := json.MarshalIndent(items, "", " ")
fmt.Println(string(out))
}
// 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)
}
out, _ := json.MarshalIndent(targets, "", " ")
fmt.Println(string(out))
}