dailyops:補齊 tea 對等指令(#40)— pulls approve/close/checkout、通用 comment、issues list --labels
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user