Files
teai/internal/cli/dailyops_commands.go
chenyunda218 744e7ad809 milestones:新增跨倉庫里程碑總覽命令(#31)
- teai milestones(不帶 --repo)輸出跨所屬組織各倉庫 open 里程碑概況,
  對應 gitea.py milestones(管理者巡邏,agents AGENTS.md 10.1):
  欄位 repo/id/title/url/open_issues/closed_issues/due_on/due_hours/
  overdue/unassigned/updated_at,排序期限近者在前、無期限最後。
- internal/workflow.MilestoneOverview:判定核心(Source 注入、離線可測);
  MilestoneEntry 自訂 MarshalJSON,鍵集鍵序與 gitea.py 全等
  (null 表示、due_hours 定點一位、unassigned PR 條目帶 author)。
- internal/workflow.APIClient 增 OpenMilestones/MilestoneOpenIssues
  (issues 端點刻意不帶 type=issues,PR 條目保留供覆蓋檢查,同 gitea.py)。
- milestones --repo a/b 維持現行單倉庫 list 語義不變。
- 空清單輸出 [](gitea.py 無輸出;#26 慣例差異,README 已文件化)。
- 交叉驗證:同帳號同時跑 gitea.py milestones 與 teai milestones,
  含逾期/未到期/無期限里程碑與未分派 issue/PR 條目,輸出位元組全等。
2026-09-10 21:20:19 +08:00

894 lines
27 KiB
Go
Raw Permalink 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.
// dailyops_commands.go — 日常操作命令的 CLI 接線(#8):issues、pulls、
// labels、milestones、releases、repos、api(tea 對等指令)。
//
// 規則:
// - 寫入類命令(create/close/comment/merge…)一律要求 --yes,
// 未帶 --yes 時不產生任何遠端變更(exit 2,用法錯誤)。
// - `teai pulls` 相容 #6 的工作流用法(無子命令=工作流清單 --mine/--reviewer);
// 有子命令(list/create/view/merge/comment)時走日常操作。
// - 輸出走 internal/gitea 的 Printer(--output json|table)。
package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"strconv"
"strings"
"gitea.alterminal.com/alterminal/teai/internal/dailyops"
"gitea.alterminal.com/alterminal/teai/internal/gitea"
)
func init() {
register := func(c *command) { commands[c.name] = c }
register(&command{name: "issues", usage: "issues 管理(list/view/create/close/comment)", run: runIssues})
register(&command{name: "pulls", usage: "PR 管理(list/view/create/merge/comment;無子命令=工作流清單)", run: runPullsDaily})
register(&command{name: "labels", usage: "標籤管理(list/create)", run: runLabels})
register(&command{name: "milestones", usage: "里程碑(跨倉庫總覽;list/create 管理單倉庫)", run: runMilestones})
register(&command{name: "releases", usage: "發佈清單(list)", run: runReleases})
register(&command{name: "repos", usage: "倉庫查詢(list)", run: runRepos})
register(&command{name: "api", usage: "任意 API 呼叫(--method/--data;寫入動詞需 --yes)", run: runAPIDirect})
}
// requireYes 檢查寫入閘門:未帶 --yes 回 ErrUsage(exit 2),不觸發任何請求。
func requireYes(yes bool, what string) error {
if !yes {
return &ErrUsage{Msg: what + " 是寫入操作,需 --yes 明確確認(未確認,未產生任何遠端變更)"}
}
return nil
}
// splitRepoFlag 把 --repo owner/repo 拆成兩段;空值回 ("", "", nil)。
func splitRepoFlag(v string) (string, string, error) {
if v == "" {
return "", "", nil
}
parts := strings.SplitN(v, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", &ErrUsage{Msg: errNoRepoFlag.Error()}
}
return parts[0], parts[1], nil
}
// newOpsClient 依全域選項建立 dailyops 用戶端。
func newOpsClient(env *Env) (*dailyops.Ops, error) {
token, _, err := gitea.ResolveToken(gitea.AuthOptions{
FlagToken: env.Globals.Token,
SiteURL: env.Globals.URL,
ConfigPath: env.Globals.ConfigPath,
})
if err != nil {
return nil, err
}
client, err := gitea.New(env.Globals.URL, token, env.Globals.Timeout)
if err != nil {
return nil, err
}
return &dailyops.Ops{Client: client}, nil
}
// parseNumber 把位置參數解析為 issue/PR 編號。
func parseNumber(s string) (int64, error) {
n, err := strconv.ParseInt(s, 10, 64)
if err != nil || n <= 0 {
return 0, &ErrUsage{Msg: fmt.Sprintf("無效的編號 %q", s)}
}
return n, nil
}
// joinOrEmpty 把清單型欄位格式化為逗號分隔(table 用)。
func joinOrEmpty(v any) string {
switch x := v.(type) {
case string:
return x
case []string:
return strings.Join(x, ",")
default:
return fmt.Sprint(x)
}
}
// issueRowsFields 是 issues 清單的欄位定義(與值順序對應)。
func issueListFields() []gitea.Field {
return []gitea.Field{
{Name: "number"}, {Name: "title"}, {Name: "state"},
{Name: "author"}, {Name: "assignees", ValueFmt: joinOrEmpty}, {Name: "updated_at"},
}
}
// ---- issues ----
// runIssues:teai issues [list|view|create|close|comment] …
func runIssues(env *Env, args []string) error {
if len(args) == 0 {
return &ErrUsage{Msg: "issues 需要子命令:list | view | create | close | comment"}
}
sub, rest := args[0], args[1:]
switch sub {
case "list":
return runIssuesList(env, rest)
case "view":
return runIssuesView(env, rest)
case "create":
return runIssuesCreate(env, rest)
case "close":
return runIssuesClose(env, rest)
case "comment":
return runComment(env, rest, false)
default:
return &ErrUsage{Msg: fmt.Sprintf("issues 未知子命令 %q(可用:list | view | create | close | comment)", sub)}
}
}
// runIssuesList:teai issues list --repo owner/repo [--state open|closed|all]
func runIssuesList(env *Env, args []string) error {
fs := flag.NewFlagSet("teai issues list", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
state := fs.String("state", "open", "狀態:open|closed|all")
if err := parseFlags(fs, args); err != nil {
return err
}
if fs.NArg() != 0 {
return &ErrUsage{Msg: "issues list 不接受位置參數"}
}
owner, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "issues list 需要 --repo owner/repo"}
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
issues, err := ops.ListIssues(context.Background(), owner, name, *state)
if err != nil {
return err
}
p := printer(env)
rows := make([]gitea.Values, 0, len(issues))
for i := range issues {
it := &issues[i]
rows = append(rows, gitea.Values{
it.Number, it.Title, it.State, it.Author(),
it.AssigneeNames(), it.UpdatedAt,
})
}
return p.PrintList(issueListFields(), rows)
}
// runIssuesView:teai issues view --repo owner/repo <number> [--comments]
func runIssuesView(env *Env, args []string) error {
fs := flag.NewFlagSet("teai issues view", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
withComments := fs.Bool("comments", false, "連留言一起輸出")
if err := parseFlags(fs, args); err != nil {
return err
}
owner, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "issues view 需要 --repo owner/repo"}
}
if fs.NArg() != 1 {
return &ErrUsage{Msg: "issues view 需要恰好一個編號"}
}
num, err := parseNumber(fs.Arg(0))
if err != nil {
return err
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
it, err := ops.GetIssue(context.Background(), owner, name, num)
if err != nil {
return err
}
p := printer(env)
fields := []gitea.Field{
{Name: "number"}, {Name: "title"}, {Name: "state"},
{Name: "author"}, {Name: "assignees", ValueFmt: joinOrEmpty},
{Name: "labels", ValueFmt: joinOrEmpty}, {Name: "body"},
{Name: "url"}, {Name: "updated_at"},
}
row := gitea.Values{
it.Number, it.Title, it.State, it.Author(),
it.AssigneeNames(), it.LabelNames(), it.Body,
it.HTMLURL, it.UpdatedAt,
}
if err := p.PrintOne(fields, row); err != nil {
return err
}
if !*withComments {
return nil
}
return printComments(env, ops, owner, name, num)
}
// printComments 輸出 issue/PR 的留言清單(view --comments 共用)。
func printComments(env *Env, ops *dailyops.Ops, owner, repo string, number int64) error {
comments, err := ops.ListComments(context.Background(), owner, repo, number)
if err != nil {
return err
}
p := printer(env)
rows := make([]gitea.Values, 0, len(comments))
for i := range comments {
c := &comments[i]
rows = append(rows, gitea.Values{c.Author(), c.CreatedAt, c.Body})
}
return p.PrintList([]gitea.Field{
{Name: "author"}, {Name: "created_at"}, {Name: "body"},
}, rows)
}
// runIssuesCreate:teai issues create --repo owner/repo --title … [--body …] [--assignees a,b] --yes
func runIssuesCreate(env *Env, args []string) error {
fs := flag.NewFlagSet("teai issues create", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
title := fs.String("title", "", "標題(必填)")
body := fs.String("body", "", "內文")
assignees := fs.String("assignees", "", "assignees(逗號分隔,可選)")
yes := fs.Bool("yes", false, "確認寫入")
if err := parseFlags(fs, args); err != nil {
return err
}
owner, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "issues create 需要 --repo owner/repo"}
}
if strings.TrimSpace(*title) == "" {
return &ErrUsage{Msg: "issues create 需要 --title"}
}
if err := requireYes(*yes, "issues create"); err != nil {
return err
}
var assigneeList []string
if strings.TrimSpace(*assignees) != "" {
for _, a := range strings.Split(*assignees, ",") {
if a = strings.TrimSpace(a); a != "" {
assigneeList = append(assigneeList, a)
}
}
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
issue, err := ops.CreateIssue(context.Background(), owner, name, *title, *body, assigneeList)
if err != nil {
return err
}
return printer(env).PrintOne([]gitea.Field{
{Name: "number"}, {Name: "title"}, {Name: "url"},
}, gitea.Values{issue.Number, issue.Title, issue.HTMLURL})
}
// runIssuesClose:teai issues close --repo owner/repo <number> --yes
func runIssuesClose(env *Env, args []string) error {
fs := flag.NewFlagSet("teai issues close", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
yes := fs.Bool("yes", false, "確認寫入")
if err := parseFlags(fs, args); err != nil {
return err
}
owner, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "issues close 需要 --repo owner/repo"}
}
if fs.NArg() != 1 {
return &ErrUsage{Msg: "issues close 需要恰好一個編號"}
}
num, err := parseNumber(fs.Arg(0))
if err != nil {
return err
}
if err := requireYes(*yes, "issues close"); err != nil {
return err
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
issue, err := ops.SetIssueState(context.Background(), owner, name, num, "closed")
if err != nil {
return err
}
return printer(env).PrintOne([]gitea.Field{
{Name: "number"}, {Name: "title"}, {Name: "state"}, {Name: "url"},
}, gitea.Values{issue.Number, issue.Title, issue.State, issue.HTMLURL})
}
// runComment:teai issues comment / teai pulls comment 共用。
func runComment(env *Env, args []string, isPR bool) error {
name := "issues comment"
if isPR {
name = "pulls comment"
}
fs := flag.NewFlagSet("teai "+name, flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
body := fs.String("body", "", "留言內容(必填)")
yes := fs.Bool("yes", false, "確認寫入")
if err := parseFlags(fs, args); err != nil {
return err
}
owner, rname, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: name + " 需要 --repo owner/repo"}
}
if strings.TrimSpace(*body) == "" {
return &ErrUsage{Msg: name + " 需要 --body"}
}
if fs.NArg() != 1 {
return &ErrUsage{Msg: name + " 需要恰好一個編號"}
}
num, err := parseNumber(fs.Arg(0))
if err != nil {
return err
}
if err := requireYes(*yes, name); err != nil {
return err
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
comment, err := ops.AddComment(context.Background(), owner, rname, num, *body)
if err != nil {
return err
}
return printer(env).PrintOne([]gitea.Field{
{Name: "author"}, {Name: "created_at"}, {Name: "body"},
}, gitea.Values{comment.Author(), comment.CreatedAt, comment.Body})
}
// ---- pulls ----
// runPullsDaily:teai pulls [list|view|create|merge|comment] …;
// 無子命令時沿用 #6 的工作流清單(teai pulls [--mine|--reviewer] [--repo])。
func runPullsDaily(env *Env, args []string) error {
if len(args) == 0 || strings.HasPrefix(args[0], "-") {
// 相容 #6:teai pulls / teai pulls --mine …(工作流清單)。
return runPulls(env, args)
}
sub, rest := args[0], args[1:]
switch sub {
case "list":
return runPullsList(env, rest)
case "view":
return runPullsView(env, rest)
case "create":
return runPullsCreate(env, rest)
case "merge":
return runPullsMerge(env, rest)
case "comment":
return runComment(env, rest, true)
default:
return &ErrUsage{Msg: fmt.Sprintf("pulls 未知子命令 %q(可用:list | view | create | merge | comment;或無子命令=工作流清單)", sub)}
}
}
// runPullsList:teai pulls list --repo owner/repo [--state open|closed|all]
func runPullsList(env *Env, args []string) error {
fs := flag.NewFlagSet("teai pulls list", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
state := fs.String("state", "open", "狀態:open|closed|all")
if err := parseFlags(fs, args); err != nil {
return err
}
owner, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "pulls list 需要 --repo owner/repo"}
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
pulls, err := ops.ListPulls(context.Background(), owner, name, *state)
if err != nil {
return err
}
p := printer(env)
rows := make([]gitea.Values, 0, len(pulls))
for i := range pulls {
pr := &pulls[i]
rows = append(rows, gitea.Values{
pr.Number, pr.Title, pr.State, pr.Author(),
pr.HeadRef(), pr.BaseRef(), pr.UpdatedAt,
})
}
return p.PrintList([]gitea.Field{
{Name: "number"}, {Name: "title"}, {Name: "state"},
{Name: "author"}, {Name: "head"}, {Name: "base"}, {Name: "updated_at"},
}, rows)
}
// runPullsView:teai pulls view --repo owner/repo <number> [--comments]
func runPullsView(env *Env, args []string) error {
fs := flag.NewFlagSet("teai pulls view", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
withComments := fs.Bool("comments", false, "連留言一起輸出")
if err := parseFlags(fs, args); err != nil {
return err
}
owner, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "pulls view 需要 --repo owner/repo"}
}
if fs.NArg() != 1 {
return &ErrUsage{Msg: "pulls view 需要恰好一個編號"}
}
num, err := parseNumber(fs.Arg(0))
if err != nil {
return err
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
pr, err := ops.GetPull(context.Background(), owner, name, num)
if err != nil {
return err
}
p := printer(env)
fields := []gitea.Field{
{Name: "number"}, {Name: "title"}, {Name: "state"},
{Name: "author"}, {Name: "head"}, {Name: "base"}, {Name: "body"},
{Name: "url"}, {Name: "updated_at"},
}
row := gitea.Values{
pr.Number, pr.Title, pr.State, pr.Author(),
pr.HeadRef(), pr.BaseRef(), pr.Body, pr.HTMLURL, pr.UpdatedAt,
}
if err := p.PrintOne(fields, row); err != nil {
return err
}
if !*withComments {
return nil
}
return printComments(env, ops, owner, name, num)
}
// runPullsCreate:teai pulls create --repo owner/repo --head <branch> --base <branch> --title … [--body …] --yes
func runPullsCreate(env *Env, args []string) error {
fs := flag.NewFlagSet("teai pulls create", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
head := fs.String("head", "", "來源分支(必填)")
base := fs.String("base", "main", "目標分支(預設 main)")
title := fs.String("title", "", "標題(必填)")
body := fs.String("body", "", "說明")
yes := fs.Bool("yes", false, "確認寫入")
if err := parseFlags(fs, args); err != nil {
return err
}
owner, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "pulls create 需要 --repo owner/repo"}
}
if *head == "" || *title == "" {
return &ErrUsage{Msg: "pulls create 需要 --head 與 --title"}
}
if err := requireYes(*yes, "pulls create"); err != nil {
return err
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
pr, err := ops.CreatePull(context.Background(), owner, name, *title, *body, *head, *base)
if err != nil {
return err
}
return printer(env).PrintOne([]gitea.Field{
{Name: "number"}, {Name: "title"}, {Name: "url"},
}, gitea.Values{pr.Number, pr.Title, pr.HTMLURL})
}
// runPullsMerge:teai pulls merge --repo owner/repo <number> [--style merge|rebase|squash] --yes
func runPullsMerge(env *Env, args []string) error {
fs := flag.NewFlagSet("teai pulls merge", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
style := fs.String("style", "merge", "合併方式:merge|rebase|rebase-merge|squash")
yes := fs.Bool("yes", false, "確認寫入")
if err := parseFlags(fs, args); err != nil {
return err
}
owner, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "pulls merge 需要 --repo owner/repo"}
}
if fs.NArg() != 1 {
return &ErrUsage{Msg: "pulls merge 需要恰好一個編號"}
}
num, err := parseNumber(fs.Arg(0))
if err != nil {
return err
}
switch *style {
case "merge", "rebase", "rebase-merge", "squash":
default:
return &ErrUsage{Msg: "pulls merge 的 --style 需為 merge|rebase|rebase-merge|squash"}
}
if err := requireYes(*yes, "pulls merge"); err != nil {
return err
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
if err := ops.MergePull(context.Background(), owner, name, num, *style); err != nil {
return err
}
return printer(env).PrintOne([]gitea.Field{
{Name: "number"}, {Name: "state"},
}, gitea.Values{num, "merged"})
}
// ---- labels ----
// runLabels:teai labels list --repo owner/repo|teai labels create … --yes
func runLabels(env *Env, args []string) error {
if len(args) == 0 {
return &ErrUsage{Msg: "labels 需要子命令:list | create"}
}
sub, rest := args[0], args[1:]
switch sub {
case "list":
return runLabelsList(env, rest)
case "create":
return runLabelsCreate(env, rest)
default:
return &ErrUsage{Msg: fmt.Sprintf("labels 未知子命令 %q(可用:list | create)", sub)}
}
}
// runLabelsList:teai labels list --repo owner/repo
func runLabelsList(env *Env, args []string) error {
fs := flag.NewFlagSet("teai labels list", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
if err := parseFlags(fs, args); err != nil {
return err
}
owner, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "labels list 需要 --repo owner/repo"}
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
labels, err := ops.ListLabels(context.Background(), owner, name)
if err != nil {
return err
}
rows := make([]gitea.Values, 0, len(labels))
for _, l := range labels {
rows = append(rows, gitea.Values{l.Name, l.Color, l.Description})
}
return printer(env).PrintList([]gitea.Field{
{Name: "name"}, {Name: "color"}, {Name: "description"},
}, rows)
}
// runLabelsCreate:teai labels create --repo owner/repo --name … [--color …] [--description …] --yes
func runLabelsCreate(env *Env, args []string) error {
fs := flag.NewFlagSet("teai labels create", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
labelName := fs.String("name", "", "標籤名(必填)")
color := fs.String("color", "#ffffff", "顏色(#RRGGBB,預設 #ffffff)")
desc := fs.String("description", "", "說明")
yes := fs.Bool("yes", false, "確認寫入")
if err := parseFlags(fs, args); err != nil {
return err
}
owner, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "labels create 需要 --repo owner/repo"}
}
if *labelName == "" {
return &ErrUsage{Msg: "labels create 需要 --name"}
}
if err := requireYes(*yes, "labels create"); err != nil {
return err
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
label, err := ops.CreateLabel(context.Background(), owner, name, *labelName, *color, *desc)
if err != nil {
return err
}
return printer(env).PrintOne([]gitea.Field{
{Name: "name"}, {Name: "color"}, {Name: "description"},
}, gitea.Values{label.Name, label.Color, label.Description})
}
// ---- milestones ----
// runMilestones:teai milestones [list|create] …;不帶子命令時輸出
// 跨倉庫里程碑總覽(#31,對應 gitea.py milestones)。帶 --repo 直接
// 跟在 milestones 之後時維持現行單倉庫 list 語義。
func runMilestones(env *Env, args []string) error {
if len(args) == 0 {
return runMilestonesOverview(env)
}
sub, rest := args[0], args[1:]
switch sub {
case "list":
return runMilestonesList(env, rest)
case "create":
return runMilestonesCreate(env, rest)
default:
if !strings.HasPrefix(sub, "-") {
return &ErrUsage{Msg: fmt.Sprintf("milestones 未知子命令 %q(可用:list | create;或不帶參數輸出跨倉庫總覽)", sub)}
}
// milestones 之後直接跟旗標(非子命令):帶 --repo → 整串轉發,
// 維持現行單倉庫 list 語義(--state 等旗標一併生效);否則為
// 跨倉庫總覽(總覽不接受任何命令旗標,空 FlagSet 讓未知旗標
// 報用法錯誤)。
hasRepo := false
for _, a := range args {
if a == "--repo" || strings.HasPrefix(a, "--repo=") {
hasRepo = true
break
}
}
if hasRepo {
return runMilestonesList(env, args)
}
fs := flag.NewFlagSet("teai milestones", flag.ContinueOnError)
fs.SetOutput(env.Err)
if err := parseFlags(fs, args); err != nil {
return err
}
if fs.NArg() != 0 {
return &ErrUsage{Msg: "milestones 不接受位置參數(子命令:list | create)"}
}
return runMilestonesOverview(env)
}
}
// runMilestonesList:teai milestones list --repo owner/repo [--state open|closed|all]
func runMilestonesList(env *Env, args []string) error {
fs := flag.NewFlagSet("teai milestones list", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
state := fs.String("state", "open", "狀態:open|closed|all")
if err := parseFlags(fs, args); err != nil {
return err
}
owner, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "milestones list 需要 --repo owner/repo"}
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
ms, err := ops.ListMilestones(context.Background(), owner, name, *state)
if err != nil {
return err
}
rows := make([]gitea.Values, 0, len(ms))
for _, m := range ms {
rows = append(rows, gitea.Values{m.Title, m.State, m.Description, m.DueOn})
}
return printer(env).PrintList([]gitea.Field{
{Name: "title"}, {Name: "state"}, {Name: "description"}, {Name: "due_on"},
}, rows)
}
// runMilestonesCreate:teai milestones create --repo owner/repo --title … [--description …] [--due …] --yes
func runMilestonesCreate(env *Env, args []string) error {
fs := flag.NewFlagSet("teai milestones create", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
title := fs.String("title", "", "標題(必填)")
desc := fs.String("description", "", "說明")
due := fs.String("due", "", "截止日(RFC3339,可選)")
yes := fs.Bool("yes", false, "確認寫入")
if err := parseFlags(fs, args); err != nil {
return err
}
owner, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "milestones create 需要 --repo owner/repo"}
}
if *title == "" {
return &ErrUsage{Msg: "milestones create 需要 --title"}
}
if err := requireYes(*yes, "milestones create"); err != nil {
return err
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
m, err := ops.CreateMilestone(context.Background(), owner, name, *title, *desc, *due)
if err != nil {
return err
}
return printer(env).PrintOne([]gitea.Field{
{Name: "id"}, {Name: "title"}, {Name: "state"}, {Name: "due_on"},
}, gitea.Values{m.ID, m.Title, m.State, m.DueOn})
}
// ---- releases / repos / api ----
// runReleases:teai releases list --repo owner/repo
func runReleases(env *Env, args []string) error {
if len(args) == 0 || args[0] != "list" {
return &ErrUsage{Msg: "releases 目前支援子命令:list"}
}
fs := flag.NewFlagSet("teai releases list", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
if err := parseFlags(fs, args[1:]); err != nil {
return err
}
owner, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "releases list 需要 --repo owner/repo"}
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
releases, err := ops.ListReleases(context.Background(), owner, name)
if err != nil {
return err
}
rows := make([]gitea.Values, 0, len(releases))
for _, r := range releases {
rows = append(rows, gitea.Values{r.TagName, r.Name, r.Draft, r.Prerelease, r.PublishedAt})
}
return printer(env).PrintList([]gitea.Field{
{Name: "tag_name"}, {Name: "name"}, {Name: "draft"}, {Name: "prerelease"}, {Name: "published_at"},
}, rows)
}
// runRepos:teai repos list [--org <org>](未指定 --org 則列自己可存的倉庫)
func runRepos(env *Env, args []string) error {
if len(args) == 0 || args[0] != "list" {
return &ErrUsage{Msg: "repos 目前支援子命令:list"}
}
fs := flag.NewFlagSet("teai repos list", flag.ContinueOnError)
fs.SetOutput(env.Err)
org := fs.String("org", "", "組織(未指定則列自己可存取的倉庫)")
if err := parseFlags(fs, args[1:]); err != nil {
return err
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
var repos []dailyops.Repo
if *org != "" {
repos, err = ops.ListOrgRepos(context.Background(), *org)
} else {
repos, err = ops.ListMyRepos(context.Background())
}
if err != nil {
return err
}
rows := make([]gitea.Values, 0, len(repos))
for _, r := range repos {
rows = append(rows, gitea.Values{r.FullName, r.Private, r.Empty, r.UpdatedAt, r.HTMLURL})
}
return printer(env).PrintList([]gitea.Field{
{Name: "full_name"}, {Name: "private"}, {Name: "empty"}, {Name: "updated_at"}, {Name: "url"},
}, rows)
}
// runAPIDirect:teai api <path> [--method GET|POST|…] [--data <json>] [--yes]
func runAPIDirect(env *Env, args []string) error {
fs := flag.NewFlagSet("teai api", flag.ContinueOnError)
fs.SetOutput(env.Err)
method := fs.String("method", "GET", "HTTP 動詞(GET 預設;非 GET 需 --yes)")
data := fs.String("data", "", "請求 body(JSON 字串,可選)")
yes := fs.Bool("yes", false, "確認寫入")
if err := parseFlags(fs, args); err != nil {
return err
}
if fs.NArg() != 1 {
return &ErrUsage{Msg: "api 需要恰好一個 path(相對 api/v1,以 / 開頭)"}
}
path := fs.Arg(0)
// path 內含查詢字串(如 /repos/x?state=closed)時剝離到 rawQuery,
// 不可併進 URL path(會 404)。
rawQuery := ""
if q := strings.IndexByte(path, '?'); q >= 0 {
rawQuery = path[q+1:]
path = path[:q]
}
if path == "" || path[0] != '/' {
return &ErrUsage{Msg: "api path 必須以 / 開頭(相對 api/v1)"}
}
upper := strings.ToUpper(strings.TrimSpace(*method))
if upper == "" {
upper = "GET"
}
switch upper {
case "GET", "HEAD", "OPTIONS":
default:
if err := requireYes(*yes, "api "+upper); err != nil {
return err
}
}
var payload []byte
if strings.TrimSpace(*data) != "" {
if !json.Valid([]byte(*data)) {
return &ErrUsage{Msg: "--data 必須是合法 JSON"}
}
payload = []byte(*data)
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
resp, err := ops.CallAPIDirect(context.Background(), upper, path, rawQuery, payload)
if err != nil {
return err
}
// 原樣輸出(teai api 是逃生口,不做欄位挑選)。
_, err = env.Out.Write(resp)
if len(resp) > 0 && resp[len(resp)-1] != '\n' {
fmt.Fprintln(env.Out)
}
return err
}