Files
teai/internal/workflow/giteaapi.go
ceo e4212b9c90 internal/workflow:directLastActivity 排除跨 issue 引用事件(#34)
同步 agents #34/#38/#40 三個停滯判定修補到 Go 端:

- Source 介面新增 IssueTimeline(GET /repos/{o}/{r}/issues/{n}/timeline,分頁),
  giteaapi.go 與 cmd/xcheck 同步實作
- 新增 directLastActivity:updated_at 晚於全部已知直接活動時拉 timeline 核對,
  排除 comment_ref/issue_ref 引用事件後取最晚;全為引用事件回退已知直接活動
  最晚時刻(agents #40);timeline 失敗保守沿用 updated_at
- considerIssue/considerPull 對自身 updated_at、childActivity 對子項 updated_at
  (含子項 created_at 入已知活動)套用同一排除邏輯(agents #38)
- 回歸測試:子項 updated_at 被引用刷新(TestRunStalledChildXrefRefresh)、
  timeline 全引用事件回退與拉取失敗保守路徑(TestRunStalledTimelineAllXrefFallback)
- 修正 workflow_test.go:timelines 須在 ScanStalled 呼叫前設定

驗證:go build/vet/test 全綠;xcheck 對真實 API 掃描,
stalled 輸出與 gitea.py(agents @ 78769ec)位元組一致(5833 bytes)。
2026-09-12 15:10:31 +08:00

225 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// giteaapi.go — 以 internal/gitea 的 HTTP 用戶端實作 workflow.Client。
//
// 端點與查詢參數對照 gitea.py:清單一律帶 limit 分頁(ListAll 固定 50);
// issues 端點帶 type=issues(排除 PR)與 state=open;pulls 帶 state=open。
package workflow
import (
"context"
"errors"
"net/url"
"strconv"
"gitea.alterminal.com/alterminal/teai/internal/gitea"
)
// errNoUsername 表示 /user 回應中找不到帳號名(伺服端回應異常)。
var errNoUsername = errors.New("gitea: GET /user: response has no username")
// APIClient 是 Client 介面的 HTTP 實作,包裝 internal/gitea.Client。
type APIClient struct {
Client *gitea.Client
}
// 確認 APIClient 實作 Client 介面(編譯期檢查)。
var _ Client = (*APIClient)(nil)
func (a *APIClient) CurrentUser() (string, error) {
var u User
if err := a.Client.GetJSON(context.Background(), "/user", &u); err != nil {
return "", err
}
name := u.Name()
if name == "" {
return "", errNoUsername
}
return name, nil
}
func (a *APIClient) ListOrgs() ([]string, error) {
var orgs []Org
if err := a.Client.ListAll(context.Background(), "/user/orgs", gitea.ListOptions{}, &orgs); err != nil {
return nil, err
}
names := make([]string, 0, len(orgs))
for _, o := range orgs {
if n := o.Account(); n != "" {
names = append(names, n)
}
}
return names, nil
}
func (a *APIClient) ListTeams(org string) ([]Team, error) {
var teams []Team
path := "/orgs/" + url.PathEscape(org) + "/teams"
if err := a.Client.ListAll(context.Background(), path, gitea.ListOptions{}, &teams); err != nil {
return nil, err
}
return teams, nil
}
func (a *APIClient) ListTeamMembers(teamID int64) ([]string, error) {
var users []User
path := "/teams/" + strconv.FormatInt(teamID, 10) + "/members"
if err := a.Client.ListAll(context.Background(), path, gitea.ListOptions{}, &users); err != nil {
return nil, err
}
names := make([]string, 0, len(users))
for i := range users {
if n := users[i].Name(); n != "" {
names = append(names, n)
}
}
return names, nil
}
func (a *APIClient) ListOrgRepos(org string) ([]Repo, error) {
var repos []Repo
path := "/orgs/" + url.PathEscape(org) + "/repos"
if err := a.Client.ListAll(context.Background(), path, gitea.ListOptions{}, &repos); err != nil {
return nil, err
}
return repos, nil
}
func (a *APIClient) ListIssues(owner, repo string) ([]Issue, error) {
var issues []Issue
path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/issues"
opts := gitea.ListOptions{Extra: url.Values{
"type": {"issues"},
"state": {"open"},
}}
if err := a.Client.ListAll(context.Background(), path, opts, &issues); err != nil {
return nil, err
}
// /issues 也會回傳 PR(帶 pull_request 欄位);一律排除。
out := make([]Issue, 0, len(issues))
for i := range issues {
if issues[i].PullRequest == nil {
out = append(out, issues[i])
}
}
return out, nil
}
func (a *APIClient) ListPulls(owner, repo string) ([]PullRequest, error) {
var pulls []PullRequest
path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/pulls"
opts := gitea.ListOptions{Extra: url.Values{"state": {"open"}}}
if err := a.Client.ListAll(context.Background(), path, opts, &pulls); err != nil {
return nil, err
}
return pulls, nil
}
func (a *APIClient) ListComments(owner, repo string, number int64) ([]Comment, error) {
var comments []Comment
path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) +
"/issues/" + strconv.FormatInt(number, 10) + "/comments"
if err := a.Client.ListAll(context.Background(), path, gitea.ListOptions{}, &comments); err != nil {
return nil, err
}
return comments, nil
}
// ── StalledSource 面向(#7 CLI 接線)────────────────────────────────────
// APIClient 在 Client 之外同時實作 StalledSource(Source + CurrentUser +
// GetIssue),讓 stalled/xrefs 命令與既有命令共用同一個 HTTP 用戶端。
// 清單類方法直接委派 Client 同名操作,語義一致(type=issues、state=open)。
// GetIssue 單一 issue 查詢(PR 亦經此端點,帶 pull_request 標記);
// 失敗(含 404)回 *gitea.ErrAPI,由 CLI 映射結束碼 3。
func (a *APIClient) GetIssue(owner, repo string, number int64) (Issue, error) {
var issue Issue
path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) +
"/issues/" + strconv.FormatInt(number, 10)
if err := a.Client.GetJSON(context.Background(), path, &issue); err != nil {
return Issue{}, err
}
return issue, nil
}
// TryGetIssue 寬容版單一 issue 查詢:失敗(含 404)回 (零值, false, nil),
// 供 stalled 父追蹤項掃描靜默略過不存在的子項(Source 介面契約)。
func (a *APIClient) TryGetIssue(owner, repo string, number int64) (Issue, bool, error) {
issue, err := a.GetIssue(owner, repo, number)
if err != nil {
return Issue{}, false, nil
}
return issue, true, nil
}
// IssueTimeline 回傳 issue 的 timeline 事件(Source 面向;對應 gitea.py
// _direct_last_activity 拉取的 repos/{o}/{r}/issues/{n}/timeline)。
// 分頁由 ListAll 處理(與 gitea.py api_get_list 的 limit=50 逐頁一致)。
func (a *APIClient) IssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error) {
var events []TimelineEvent
path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) +
"/issues/" + strconv.FormatInt(number, 10) + "/timeline"
if err := a.Client.ListAll(context.Background(), path, gitea.ListOptions{}, &events); err != nil {
return nil, err
}
return events, nil
}
// MyOrganizations 委派 ListOrgs(Source 面向)。
func (a *APIClient) MyOrganizations() ([]string, error) { return a.ListOrgs() }
// OrganizationRepositories 委派 ListOrgRepos(Source 面向)。
func (a *APIClient) OrganizationRepositories(org string) ([]Repo, error) {
return a.ListOrgRepos(org)
}
// OpenIssues 委派 ListIssues(Source 面向;已排除 PR)。
func (a *APIClient) OpenIssues(owner, repo string) ([]Issue, error) {
return a.ListIssues(owner, repo)
}
// OpenPulls 委派 ListPulls(Source 面向)。
func (a *APIClient) OpenPulls(owner, repo string) ([]PullRequest, error) {
return a.ListPulls(owner, repo)
}
// IssueComments 委派 ListComments(Source 面向)。
func (a *APIClient) IssueComments(owner, repo string, number int64) ([]Comment, error) {
return a.ListComments(owner, repo, number)
}
// ── MilestoneSource 面向(#31 跨倉庫里程碑總覽)────────────────────────
// APIClient 在 StalledSource 之外另實作 MilestoneSource,讓 milestones
// 總覽命令與既有命令共用同一個 HTTP 用戶端;查詢參數對照 gitea.py 的
// list_open_milestones_for_repository/_milestone_open_issues。
// OpenMilestones 回傳倉庫的 open 里程碑(GET /repos/{o}/{r}/milestones?state=open)。
func (a *APIClient) OpenMilestones(owner, repo string) ([]Milestone, error) {
var ms []Milestone
path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/milestones"
opts := gitea.ListOptions{Extra: url.Values{"state": {"open"}}}
if err := a.Client.ListAll(context.Background(), path, opts, &ms); err != nil {
return nil, err
}
return ms, nil
}
// MilestoneOpenIssues 回傳掛在指定里程碑(依名稱過濾,Gitea 以
// milestones=<title> 查詢)的 open 條目。刻意**不**帶 type=issues——
// PR 亦屬里程碑條目,保留兩者供覆蓋檢查(同 gitea.py
// _milestone_open_issues;PR 帶 pull_request 標記由 workflow 判別)。
func (a *APIClient) MilestoneOpenIssues(owner, repo, milestoneTitle string) ([]Issue, error) {
var issues []Issue
path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/issues"
opts := gitea.ListOptions{Extra: url.Values{
"state": {"open"},
"milestones": {milestoneTitle},
}}
if err := a.Client.ListAll(context.Background(), path, opts, &issues); err != nil {
return nil, err
}
return issues, nil
}
// 編譯期確認 APIClient 同時實作 Client、StalledSource 與 MilestoneSource。
var _ StalledSource = (*APIClient)(nil)
var _ MilestoneSource = (*APIClient)(nil)