// 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=