// Package dailyops 實作 tea 對等的日常操作(#8):issues、pulls、labels、 // milestones、releases、repos 的讀寫,直接以 Gitea API 為準。 // // 讀寫行為對照 Gitea 1.27 API(issues 端點帶 type=issues 排除 PR, // 見 gitea.py 的注意事項);寫入的「--yes 閘門」在 CLI 層(見 // dailyops_commands.go),本套件只負責 API 呼叫。 package dailyops import ( "context" "fmt" "net/url" "strconv" "strings" "gitea.alterminal.com/alterminal/teai/internal/gitea" ) // User 是 API 回應中的使用者精簡欄位。 type User struct { UserName string `json:"login"` } // Name 回傳帳號名。 func (u *User) Name() string { if u == nil { return "" } return u.UserName } // Issue 是 Gitea issue/PR 共用端點的精簡欄位(PR 亦走 issues 端點)。 type Issue struct { Number int64 `json:"number"` Title string `json:"title"` State string `json:"state"` Body string `json:"body"` HTMLURL string `json:"html_url"` UpdatedAt string `json:"updated_at"` User *User `json:"user"` Assignees []*User `json:"assignees"` Labels []Label `json:"labels"` Assignee []string `json:"-"` // 輸出用:帳號清單(由 Assignees 導出) } // Author 回傳建立者帳號。 func (i *Issue) Author() string { return i.User.Name() } // AssigneeNames 回傳 assignees 帳號清單(輸出用)。 func (i *Issue) AssigneeNames() []string { names := make([]string, 0, len(i.Assignees)) for _, a := range i.Assignees { if n := a.Name(); n != "" { names = append(names, n) } } return names } // LabelNames 回傳標籤名稱清單(輸出用)。 func (i *Issue) LabelNames() []string { names := make([]string, 0, len(i.Labels)) for _, l := range i.Labels { names = append(names, l.Name) } return names } // Label 是倉庫標籤。 type Label struct { ID int64 `json:"id"` Name string `json:"name"` Color string `json:"color"` Description string `json:"description"` } // Milestone 是倉庫里程碑。 type Milestone struct { ID int64 `json:"id"` Title string `json:"title"` Description string `json:"description"` State string `json:"state"` DueOn string `json:"due_on"` } // Release 是倉庫發佈。 type Release struct { TagName string `json:"tag_name"` Name string `json:"name"` Draft bool `json:"draft"` Prerelease bool `json:"prerelease"` CreatedAt string `json:"created_at"` PublishedAt string `json:"published_at"` HTMLURL string `json:"html_url"` } // Comment 是 issue/PR 留言。 type Comment struct { Body string `json:"body"` CreatedAt string `json:"created_at"` User *User `json:"user"` } // Author 回串留言者帳號。 func (c *Comment) Author() string { return c.User.Name() } // Pull 是 pull request 的精簡欄位。 type Pull struct { Number int64 `json:"number"` Title string `json:"title"` State string `json:"state"` Body string `json:"body"` HTMLURL string `json:"html_url"` UpdatedAt string `json:"updated_at"` User *User `json:"user"` Head *pullRef `json:"head"` Base *pullRef `json:"base"` } // pullRef 是 PR 的 head/base 參照。 type pullRef struct { Ref string `json:"ref"` SHA string `json:"sha"` Repo *struct { FullName string `json:"full_name"` CloneURL string `json:"clone_url"` } `json:"repo"` } // HeadRef 回傳 head 分支名(輸出用)。 func (p *Pull) HeadRef() string { if p.Head == nil { return "" } return p.Head.Ref } // BaseRef 回傳 base 分支名(輸出用)。 func (p *Pull) BaseRef() string { if p.Base == nil { return "" } return p.Base.Ref } // Author 回傳 PR 作者帳號。 func (p *Pull) Author() string { return p.User.Name() } // Repo 是倉庫的精簡欄位。 type Repo struct { FullName string `json:"full_name"` Description string `json:"description"` Private bool `json:"private"` Empty bool `json:"empty"` UpdatedAt string `json:"updated_at"` HTMLURL string `json:"html_url"` } // Ops 聚合日常操作的 API 呼叫。 type Ops struct { // Client 是 Gitea API 用戶端(internal/gitea)。 Client *gitea.Client } // repoPath 組出 /repos/{owner}/{repo} 前綴;owner/repo 皆做路徑跳脫。 func repoPath(owner, repo string) string { return "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) } // ListIssues 列出倉庫 issues。state:open|closed|all(預設 open); // labels 非空時以逗號連接傳給 API 的 labels 參數(Gitea labels 過濾, // 多標籤為 AND 語義);type=issues 排除 PR(Gitea 的 issues 端點會連 PR 一起回)。 func (o *Ops) ListIssues(ctx context.Context, owner, repo, state string, labels []string) ([]Issue, error) { if state == "" { state = "open" } // state 一律明確傳給 API:issues/milestones 端點省略 state 時預設 // open(#33),closed 項目會漏列;all 也不能省略。 extra := url.Values{} extra.Set("state", state) if len(labels) > 0 { extra.Set("labels", strings.Join(labels, ",")) } // type=issues:排除 PR(PR 在此端點帶 pull_request 欄位)。 extra.Set("type", "issues") var issues []Issue if err := o.Client.ListAll(ctx, repoPath(owner, repo)+"/issues", gitea.ListOptions{Extra: extra}, &issues); err != nil { return nil, err } return issues, nil } // CreateIssue 建立 issue;assignees 可為空。 func (o *Ops) CreateIssue(ctx context.Context, owner, repo, title, body string, assignees []string) (*Issue, error) { payload := map[string]any{ "title": title, "body": body, } if len(assignees) > 0 { payload["assignees"] = assignees } var issue Issue if err := o.Client.DoJSON(ctx, "POST", repoPath(owner, repo)+"/issues", "", payload, &issue); err != nil { return nil, err } return &issue, nil } // GetIssue 取得單一 issue(含 PR,但此處供 issues view 用)。 func (o *Ops) GetIssue(ctx context.Context, owner, repo string, number int64) (*Issue, error) { var issue Issue path := repoPath(owner, repo) + "/issues/" + strconv.FormatInt(number, 10) if err := o.Client.GetJSON(ctx, path, &issue); err != nil { return nil, err } return &issue, nil } // SetIssueState 變更 issue 狀態(open|closed)。 func (o *Ops) SetIssueState(ctx context.Context, owner, repo string, number int64, state string) (*Issue, error) { payload := map[string]any{"state": state} var issue Issue path := repoPath(owner, repo) + "/issues/" + strconv.FormatInt(number, 10) if err := o.Client.DoJSON(ctx, "PATCH", path, "", payload, &issue); err != nil { return nil, err } return &issue, nil } // ListComments 列出 issue/PR 的留言(PR 亦走 issues 端點)。 func (o *Ops) ListComments(ctx context.Context, owner, repo string, number int64) ([]Comment, error) { var comments []Comment path := repoPath(owner, repo) + "/issues/" + strconv.FormatInt(number, 10) + "/comments" if err := o.Client.ListAll(ctx, path, gitea.ListOptions{}, &comments); err != nil { return nil, err } return comments, nil } // AddComment 對 issue/PR 留言。 func (o *Ops) AddComment(ctx context.Context, owner, repo string, number int64, body string) (*Comment, error) { payload := map[string]any{"body": body} var comment Comment path := repoPath(owner, repo) + "/issues/" + strconv.FormatInt(number, 10) + "/comments" if err := o.Client.DoJSON(ctx, "POST", path, "", payload, &comment); err != nil { return nil, err } return &comment, nil } // ListPulls 列出倉庫 PR;state 預設 open。 func (o *Ops) ListPulls(ctx context.Context, owner, repo, state string) ([]Pull, error) { if state == "" { state = "open" } // state 一律明確傳給 API:pulls 端點省略 state 時雖回全部,仍明確 // 帶上,避免依賴端點預設值(#33)。 extra := url.Values{} extra.Set("state", state) var pulls []Pull if err := o.Client.ListAll(ctx, repoPath(owner, repo)+"/pulls", gitea.ListOptions{Extra: extra}, &pulls); err != nil { return nil, err } return pulls, nil } // GetPull 取得單一 PR。 func (o *Ops) GetPull(ctx context.Context, owner, repo string, number int64) (*Pull, error) { var pr Pull path := repoPath(owner, repo) + "/pulls/" + strconv.FormatInt(number, 10) if err := o.Client.GetJSON(ctx, path, &pr); err != nil { return nil, err } return &pr, nil } // Review 是 pull request 審核的精簡欄位。 type Review struct { ID int64 `json:"ID"` Number int64 `json:"number"` State string `json:"state"` Body string `json:"body"` Submitted string `json:"submitted_at"` User *User `json:"user"` } // Author 回傳審核者帳號。 func (r *Review) Author() string { return r.User.Name() } // ApprovePull 核准 PR(POST /repos/{owner}/{repo}/pulls/{n}/reviews,event=APPROVED)。 // body 是審核意見,可為空。 func (o *Ops) ApprovePull(ctx context.Context, owner, repo string, number int64, body string) (*Review, error) { payload := map[string]any{ "event": "APPROVED", "body": body, } var review Review path := repoPath(owner, repo) + "/pulls/" + strconv.FormatInt(number, 10) + "/reviews" if err := o.Client.DoJSON(ctx, "POST", path, "", payload, &review); err != nil { return nil, err } return &review, nil } // ClosePull 關閉 PR 不合併(PATCH /repos/{owner}/{repo}/pulls/{n},state=closed)。 func (o *Ops) ClosePull(ctx context.Context, owner, repo string, number int64) (*Pull, error) { payload := map[string]any{"state": "closed"} var pr Pull path := repoPath(owner, repo) + "/pulls/" + strconv.FormatInt(number, 10) if err := o.Client.DoJSON(ctx, "PATCH", path, "", payload, &pr); err != nil { return nil, err } return &pr, nil } // HeadInfo 是 PR head 參照的完整資訊(checkout 用:同倉庫分支 or 跨倉庫 clone 網址)。 type HeadInfo struct { // Ref 是 head 分支名。 Ref string // Repo 是 head 所屬倉庫(owner/name);與目標倉庫相同時為空。 Repo string // CloneURL 是 head 倉庫的 https clone 網址;同倉庫時為空。 CloneURL string // SHA 是 head commit(暫不使用,保留給未來 reset/rebase 用)。 SHA string } // PullHeadInfo 取得 PR 的 head 資訊。同倉庫分支 → Repo/CloneURL 為空、只剩 Ref; // 跨倉庫 fork → Ref+Repo+CloneURL 齊備,checkout 改以 clone --branch Ref CloneURL。 func (o *Ops) PullHeadInfo(ctx context.Context, owner, repo string, number int64) (*HeadInfo, error) { pr, err := o.GetPull(ctx, owner, repo, number) if err != nil { return nil, err } info := &HeadInfo{Ref: pr.HeadRef()} if pr.Head != nil { info.SHA = pr.Head.SHA if pr.Head.Repo != nil { full := pr.Head.Repo.FullName same := full != "" && full == owner+"/"+repo if !same { info.Repo = full info.CloneURL = pr.Head.Repo.CloneURL } } } return info, nil } // String 依 head 來源輸出人類可讀描述(輸出用)。 func (h *HeadInfo) String() string { if h.Repo != "" { return h.Repo + "#" + h.Ref } return h.Ref } // CreatePull 建立 PR。 func (o *Ops) CreatePull(ctx context.Context, owner, repo, title, body, head, base string) (*Pull, error) { payload := map[string]any{ "title": title, "body": body, "head": head, "base": base, } var pull Pull if err := o.Client.DoJSON(ctx, "POST", repoPath(owner, repo)+"/pulls", "", payload, &pull); err != nil { return nil, err } return &pull, nil } // MergePull 合併 PR。style:merge|rebase|rebase-merge|squash(預設 merge)。 func (o *Ops) MergePull(ctx context.Context, owner, repo string, number int64, style string) error { if style == "" { style = "merge" } payload := map[string]any{"Do": style} path := repoPath(owner, repo) + "/pulls/" + strconv.FormatInt(number, 10) + "/merge" return o.Client.DoJSON(ctx, "POST", path, "", payload, nil) } // ListLabels 列出倉庫標籤。 func (o *Ops) ListLabels(ctx context.Context, owner, repo string) ([]Label, error) { var labels []Label if err := o.Client.ListAll(ctx, repoPath(owner, repo)+"/labels", gitea.ListOptions{}, &labels); err != nil { return nil, err } return labels, nil } // CreateLabel 建立標籤;color 格式如 #ffffff。 func (o *Ops) CreateLabel(ctx context.Context, owner, repo, name, color, description string) (*Label, error) { payload := map[string]any{ "name": name, "color": color, "description": description, } var label Label if err := o.Client.DoJSON(ctx, "POST", repoPath(owner, repo)+"/labels", "", payload, &label); err != nil { return nil, err } return &label, nil } // ListMilestones 列出倉庫里程碑;state 預設 open(可用 all)。 func (o *Ops) ListMilestones(ctx context.Context, owner, repo, state string) ([]Milestone, error) { if state == "" { state = "open" } // state 一律明確傳給 API:milestones 端點省略 state 時預設 open // (#33),closed 項目會漏列。 extra := url.Values{} extra.Set("state", state) var ms []Milestone if err := o.Client.ListAll(ctx, repoPath(owner, repo)+"/milestones", gitea.ListOptions{Extra: extra}, &ms); err != nil { return nil, err } return ms, nil } // CreateMilestone 建立里程碑;dueOn 可空(RFC3339)。 func (o *Ops) CreateMilestone(ctx context.Context, owner, repo, title, description, dueOn string) (*Milestone, error) { payload := map[string]any{ "title": title, "description": description, } if dueOn != "" { payload["due_on"] = dueOn } var m Milestone if err := o.Client.DoJSON(ctx, "POST", repoPath(owner, repo)+"/milestones", "", payload, &m); err != nil { return nil, err } return &m, nil } // ListReleases 列出倉庫發佈。 func (o *Ops) ListReleases(ctx context.Context, owner, repo string) ([]Release, error) { var releases []Release if err := o.Client.ListAll(ctx, repoPath(owner, repo)+"/releases", gitea.ListOptions{}, &releases); err != nil { return nil, err } return releases, nil } // ListMyRepos 列出自己可存取的倉庫。 func (o *Ops) ListMyRepos(ctx context.Context) ([]Repo, error) { var repos []Repo if err := o.Client.ListAll(ctx, "/user/repos", gitea.ListOptions{}, &repos); err != nil { return nil, err } return repos, nil } // ListOrgRepos 列出組織倉庫。 func (o *Ops) ListOrgRepos(ctx context.Context, org string) ([]Repo, error) { var repos []Repo if err := o.Client.ListAll(ctx, "/orgs/"+url.PathEscape(org)+"/repos", gitea.ListOptions{}, &repos); err != nil { return nil, err } return repos, nil } // CallAPIDirect 是 teai api 的原樣傳遞:以指定動詞呼叫 path(相對 /api/v1), // data 為原樣 JSON(可為 nil)。回傳原始回應位元組。 func (o *Ops) CallAPIDirect(ctx context.Context, method, path, rawQuery string, data []byte) ([]byte, error) { if path == "" || path[0] != '/' { return nil, fmt.Errorf("api path 必須以 / 開頭(相對 api/v1)") } return o.Client.DoRaw(ctx, method, path, rawQuery, data) }