dailyops:補齊 tea 對等指令(#40)— pulls approve/close/checkout、通用 comment、issues list --labels

This commit is contained in:
max
2026-09-13 00:00:40 +08:00
parent d689107eea
commit 3470a96392
5 changed files with 610 additions and 21 deletions
+218 -12
View File
@@ -14,6 +14,9 @@ import (
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
@@ -23,8 +26,9 @@ import (
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: "issues", usage: "issues 管理(list/view/create/close/comment;list 支援 --labels 過濾)", run: runIssues})
register(&command{name: "pulls", usage: "PR 管理(list/view/create/merge/approve/close/checkout/comment;無子命令=工作流清單)", run: runPullsDaily})
register(&command{name: "comment", usage: "對 issue 或 PR 留言(--repo owner/repo <number> --body …)", run: runCommentDirect})
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})
@@ -122,12 +126,13 @@ func runIssues(env *Env, args []string) error {
}
}
// runIssuesList:teai issues list --repo owner/repo [--state open|closed|all]
// runIssuesList:teai issues list --repo owner/repo [--state open|closed|all] [--labels a,b]
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")
labels := fs.String("labels", "", "標籤過濾(逗號分隔,可選;多標籤為 AND)")
if err := parseFlags(fs, args); err != nil {
return err
}
@@ -141,11 +146,19 @@ func runIssuesList(env *Env, args []string) error {
if owner == "" {
return &ErrUsage{Msg: "issues list 需要 --repo owner/repo"}
}
var labelList []string
if strings.TrimSpace(*labels) != "" {
for _, l := range strings.Split(*labels, ",") {
if l = strings.TrimSpace(l); l != "" {
labelList = append(labelList, l)
}
}
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
issues, err := ops.ListIssues(context.Background(), owner, name, *state)
issues, err := ops.ListIssues(context.Background(), owner, name, *state, labelList)
if err != nil {
return err
}
@@ -316,6 +329,8 @@ func runIssuesClose(env *Env, args []string) error {
}
// runComment:teai issues comment / teai pulls comment 共用。
// 相容 tea 用法:留言可作為位置參數(tea comment --repo a/b <index> "內容"),
// 亦可 teai 慣用的 --body;兩者同時給時位置參數優先(與 tea 行為一致)。
func runComment(env *Env, args []string, isPR bool) error {
name := "issues comment"
if isPR {
@@ -324,7 +339,7 @@ func runComment(env *Env, args []string, isPR bool) error {
fs := flag.NewFlagSet("teai "+name, flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
body := fs.String("body", "", "留言內容(必填)")
body := fs.String("body", "", "留言內容(可改用位置參數)")
yes := fs.Bool("yes", false, "確認寫入")
if err := parseFlags(fs, args); err != nil {
return err
@@ -336,16 +351,21 @@ func runComment(env *Env, args []string, isPR bool) error {
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 + " 需要恰好一個編號"}
// 位置參數:<number> [body…];尾端多餘引數以空格接成一則留言(tea 相容)。
if fs.NArg() < 1 {
return &ErrUsage{Msg: name + " 需要恰好一個編號(可選加留言位置參數)"}
}
num, err := parseNumber(fs.Arg(0))
if err != nil {
return err
}
text := strings.TrimSpace(*body)
if fs.NArg() > 1 {
text = strings.TrimSpace(strings.Join(fs.Args()[1:], " "))
}
if text == "" {
return &ErrUsage{Msg: name + " 需要留言內容(--body 或位置參數)"}
}
if err := requireYes(*yes, name); err != nil {
return err
}
@@ -353,7 +373,7 @@ func runComment(env *Env, args []string, isPR bool) error {
if err != nil {
return err
}
comment, err := ops.AddComment(context.Background(), owner, rname, num, *body)
comment, err := ops.AddComment(context.Background(), owner, rname, num, text)
if err != nil {
return err
}
@@ -381,13 +401,26 @@ func runPullsDaily(env *Env, args []string) error {
return runPullsCreate(env, rest)
case "merge":
return runPullsMerge(env, rest)
case "approve":
return runPullsApprove(env, rest)
case "close":
return runPullsClose(env, rest)
case "checkout":
return runPullsCheckout(env, rest)
case "comment":
return runComment(env, rest, true)
default:
return &ErrUsage{Msg: fmt.Sprintf("pulls 未知子命令 %q(可用:list | view | create | merge | comment;或無子命令=工作流清單)", sub)}
return &ErrUsage{Msg: fmt.Sprintf("pulls 未知子命令 %q(可用:list | view | create | merge | approve | close | checkout | comment;或無子命令=工作流清單)", sub)}
}
}
// runCommentDirect:teai comment --repo owner/repo <number> <body> [--yes]。
// tea 的通用留言指令:issue 與 PR 共用同一端點(Gitea 以 number 定位,
// issues/<n>/comments 對兩者皆有效),故不需 --type 旗標。
func runCommentDirect(env *Env, args []string) error {
return runComment(env, args, false)
}
// 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)
@@ -560,6 +593,179 @@ func runPullsMerge(env *Env, args []string) error {
}, gitea.Values{num, "merged"})
}
// runPullsApprove:teai pulls approve --repo owner/repo [--body …] --yes <number>。
// tea 相容:意見也可作為位置參數(teai pulls approve --repo a/b <n> "驗證通過。")。
func runPullsApprove(env *Env, args []string) error {
fs := flag.NewFlagSet("teai pulls approve", 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, name, err := splitRepoFlag(*repo)
if err != nil {
return err
}
if owner == "" {
return &ErrUsage{Msg: "pulls approve 需要 --repo owner/repo"}
}
if fs.NArg() < 1 {
return &ErrUsage{Msg: "pulls approve 需要恰好一個編號(可選加意見位置參數)"}
}
num, err := parseNumber(fs.Arg(0))
if err != nil {
return err
}
text := strings.TrimSpace(*body)
if fs.NArg() > 1 {
text = strings.TrimSpace(strings.Join(fs.Args()[1:], " "))
}
if err := requireYes(*yes, "pulls approve"); err != nil {
return err
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
review, err := ops.ApprovePull(context.Background(), owner, name, num, text)
if err != nil {
return err
}
return printer(env).PrintOne([]gitea.Field{
{Name: "number"}, {Name: "state"}, {Name: "author"}, {Name: "body"},
}, gitea.Values{review.Number, review.State, review.Author(), review.Body})
}
// runPullsClose:teai pulls close --repo owner/repo --yes <number>。
// 關閉不合併;工作流約定 merge 才是正途(AGENTS.md 6.2),本指令提供語義對等。
func runPullsClose(env *Env, args []string) error {
fs := flag.NewFlagSet("teai pulls 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: "pulls close 需要 --repo owner/repo"}
}
if fs.NArg() != 1 {
return &ErrUsage{Msg: "pulls close 需要恰好一個編號"}
}
num, err := parseNumber(fs.Arg(0))
if err != nil {
return err
}
if err := requireYes(*yes, "pulls close"); err != nil {
return err
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
pr, err := ops.ClosePull(context.Background(), owner, name, num)
if err != nil {
return err
}
return printer(env).PrintOne([]gitea.Field{
{Name: "number"}, {Name: "title"}, {Name: "state"}, {Name: "url"},
}, gitea.Values{pr.Number, pr.Title, pr.State, pr.HTMLURL})
}
// runPullsCheckout:teai pulls checkout --repo owner/repo <number> [--dir <path>]。
// 唯讀輔助指令:取得 PR head 資訊,在本機以 git 取得工作副本——
// 同倉庫分支 → git fetch --quiet origin <ref> && git checkout -B <ref> FETCH_HEAD
// (在 --dir 指定的既有 git 工作樹內執行;預設當前目錄);
// 跨倉庫 head → git clone --branch <ref> <clone_url> <dir>(--dir 必填,目錄不得已存在)。
// teai 不碰認證:clone 走 https 公開網址,私有倉庫時請自備 git 認證(credential helper)。
func runPullsCheckout(env *Env, args []string) error {
fs := flag.NewFlagSet("teai pulls checkout", flag.ContinueOnError)
fs.SetOutput(env.Err)
repo := fs.String("repo", "", "倉庫(owner/repo,必填)")
dir := fs.String("dir", "", "工作樹目錄(預設當前目錄;跨倉庫 head 時必填且不得已存在)")
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 checkout 需要 --repo owner/repo"}
}
if fs.NArg() != 1 {
return &ErrUsage{Msg: "pulls checkout 需要恰好一個編號"}
}
num, err := parseNumber(fs.Arg(0))
if err != nil {
return err
}
ops, err := newOpsClient(env)
if err != nil {
return err
}
info, err := ops.PullHeadInfo(context.Background(), owner, name, num)
if err != nil {
return err
}
if info.Ref == "" {
return fmt.Errorf("PR #%d 缺少 head 分支資訊", num)
}
workDir := *dir
if workDir == "" {
workDir = "."
}
// git 指令一律固定參數(不經 shell、不插入使用者字串),分支名僅作為
// git 自身剖析的引數傳入;錯誤訊息往 env.Err,成功輸出到 env.Out。
// 注意:git clone 的目標目錄是位置參數,cmd.Dir 不可指向不存在目錄,
// 故 clone 一律在當前目錄執行(--dir 以絕對路徑傳入)。
runGit := func(dir string, args ...string) error {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Stdout = env.Out
cmd.Stderr = env.Err
return cmd.Run()
}
if info.Repo == "" {
// 同倉庫:fetch 指定分支並建立/更新本地分支。
if err := runGit(workDir, "fetch", "--quiet", "origin", "--", info.Ref); err != nil {
return fmt.Errorf("git fetch origin %s 失敗(在 %s):%w", info.Ref, workDir, err)
}
if err := runGit(workDir, "checkout", "-q", "-B", info.Ref, "FETCH_HEAD"); err != nil {
return fmt.Errorf("git checkout -B %s 失敗:%w", info.Ref, err)
}
return printer(env).PrintOne([]gitea.Field{
{Name: "number"}, {Name: "branch"}, {Name: "workdir"},
}, gitea.Values{num, info.Ref, workDir})
}
// 跨倉庫 head:clone 到 --dir(不得已存在)。
if *dir == "" {
return &ErrUsage{Msg: "pulls checkout:跨倉庫 head(" + info.Repo + ")需要 --dir <目錄>"}
}
if st, err := os.Stat(*dir); err == nil && st.IsDir() {
return &ErrUsage{Msg: "pulls checkout:--dir 已存在(跨倉庫 head 需要新目錄):" + *dir}
}
if info.CloneURL == "" {
return fmt.Errorf("PR #%d 的 head 倉庫 %s 未提供 clone 網址", num, info.Repo)
}
abs, err := filepath.Abs(*dir)
if err != nil {
return &ErrUsage{Msg: "pulls checkout:無效的 --dir:" + err.Error()}
}
if err := runGit(".", "clone", "--quiet", "--branch", info.Ref, "--", info.CloneURL, abs); err != nil {
return fmt.Errorf("git clone %s 失敗:%w", info.CloneURL, err)
}
return printer(env).PrintOne([]gitea.Field{
{Name: "number"}, {Name: "branch"}, {Name: "repo"}, {Name: "workdir"},
}, gitea.Values{num, info.Ref, info.Repo, *dir})
}
// ---- labels ----
// runLabels:teai labels list --repo owner/repo|teai labels create … --yes