Files
teai/internal/dailyops/dailyops.go
T
chenyunda218 1f6806ff99 dailyops:list 命令 --state all 明確帶 state=all(#33)
issues/milestones 端點省略 state 時 API 預設 open,closed 項目漏列;
pulls 端點省略時雖回全部,仍明確帶上避免依賴端點預設。三處同修並補
回歸測試(TestStateAllSendsExplicitParam)。
2026-09-11 11:29:00 +08:00

385 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}