同步 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)。
225 lines
8.1 KiB
Go
225 lines
8.1 KiB
Go
// 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)
|