tea organizations 對等的日常操作:
- organizations list:GET /user/orgs,輸出 name/full_name/website/
location/description/visibility(與 teai orgs 工作流命令同一
端點,但輸出完整欄位)
- organizations create:POST /orgs,--name 必填,可選 --full-name/
--description/--website/--location/--visibility;寫入需 --yes
- organizations delete:DELETE /orgs/{org},組織名為位置參數
(tea 相容);寫入需 --yes
驗收測試涵蓋端點、payload、--yes 閘門(未確認不發請求)與
值域檢查;並以本機建置對 Gitea 實測 list 輸出欄位與 tea 一致。
534 lines
17 KiB
Go
534 lines
17 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"
|
||
"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)
|
||
}
|
||
|
||
// ---- organizations(#45,tea organizations 對等)----
|
||
|
||
// Organization 是帳號所屬/可存取組織的精簡欄位
|
||
// (GET /user/orgs、POST /orgs 回應共用)。name 是帳號名、
|
||
// full_name 是顯示名稱(如 "Alterminal Co., Ltd."),兩者不同。
|
||
type Organization struct {
|
||
UserName string `json:"username"`
|
||
Name string `json:"name"`
|
||
FullName string `json:"full_name"`
|
||
Website string `json:"website"`
|
||
Location string `json:"location"`
|
||
Description string `json:"description"`
|
||
Visibility string `json:"visibility"`
|
||
}
|
||
|
||
// Account 回傳組織帳號名:先 username,缺漏時回退 name
|
||
// (同 workflow.Org.Account 的取值順序)。
|
||
func (o *Organization) Account() string {
|
||
if o.UserName != "" {
|
||
return o.UserName
|
||
}
|
||
return o.Name
|
||
}
|
||
|
||
// ListMyOrganizations 列出自己可存取的組織(GET /user/orgs,分頁)。
|
||
// tea organizations list 的對等端點;teai orgs(工作流)輸出帳號清單,
|
||
// 本指令輸出完整欄位供日常操作使用。
|
||
func (o *Ops) ListMyOrganizations(ctx context.Context) ([]Organization, error) {
|
||
var orgs []Organization
|
||
if err := o.Client.ListAll(ctx, "/user/orgs", gitea.ListOptions{}, &orgs); err != nil {
|
||
return nil, err
|
||
}
|
||
return orgs, nil
|
||
}
|
||
|
||
// CreateOrganization 建立組織(POST /orgs)。name 必填;其餘欄位可空。
|
||
// visibility:public|limited|private(空值交由服務端預設)。
|
||
func (o *Ops) CreateOrganization(ctx context.Context, name, fullName, description, website, location, visibility string) (*Organization, error) {
|
||
payload := map[string]any{
|
||
"username": name,
|
||
"full_name": fullName,
|
||
"description": description,
|
||
"website": website,
|
||
"location": location,
|
||
}
|
||
if visibility != "" {
|
||
payload["visibility"] = visibility
|
||
}
|
||
var org Organization
|
||
if err := o.Client.DoJSON(ctx, "POST", "/orgs", "", payload, &org); err != nil {
|
||
return nil, err
|
||
}
|
||
return &org, nil
|
||
}
|
||
|
||
// DeleteOrganization 刪除組織(DELETE /orgs/{org})。
|
||
func (o *Ops) DeleteOrganization(ctx context.Context, org string) error {
|
||
path := "/orgs/" + url.PathEscape(org)
|
||
return o.Client.DoJSON(ctx, "DELETE", path, "", nil, nil)
|
||
}
|