issues/milestones 端點省略 state 時 API 預設 open,closed 項目漏列; pulls 端點省略時雖回全部,仍明確帶上避免依賴端點預設。三處同修並補 回歸測試(TestStateAllSendsExplicitParam)。
385 lines
12 KiB
Go
385 lines
12 KiB
Go
// 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"
|
||
}
|
||
// state 一律明確傳給 API:issues/milestones 端點省略 state 時預設
|
||
// open(#33),closed 項目會漏列;all 也不能省略。
|
||
extra := url.Values{}
|
||
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"
|
||
}
|
||
// 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 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"
|
||
}
|
||
// 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)
|
||
}
|