// 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" "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"` Repo *struct { FullName string `json:"full_name"` } `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); // type=issues 排除 PR(Gitea 的 issues 端點會連 PR 一起回)。 func (o *Ops) ListIssues(ctx context.Context, owner, repo, state string) ([]Issue, error) { if state == "" { state = "open" } extra := url.Values{} if state != "all" { extra.Set("state", state) } // 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" } extra := url.Values{} if state != "all" { 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 pull Pull path := repoPath(owner, repo) + "/pulls/" + strconv.FormatInt(number, 10) if err := o.Client.GetJSON(ctx, path, &pull); err != nil { return nil, err } return &pull, nil } // 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" } extra := url.Values{} if state != "all" { 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) }