diff --git a/.gitignore b/.gitignore index dc9a3eb..acbecb1 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ coverage.* # 作業系統 .DS_Store Thumbs.db +teai-bin diff --git a/README.md b/README.md index d49457f..decd33f 100644 --- a/README.md +++ b/README.md @@ -72,17 +72,22 @@ issues/pulls/labels/milestones/releases/repos/api 的日常操作, ```sh # issues -teai issues list --repo / [--state open|closed|all] +teai issues list --repo / [--state open|closed|all] [--labels a,b] # 多標籤 AND teai issues view --repo / [--comments] teai issues create --repo / --title … [--body …] [--assignees a,b] --yes teai issues close --repo / --yes -teai issues comment --repo / --body … --yes +teai issues comment --repo / [--body …] --yes [留言…] # 位置參數優先(#40) # pulls(無子命令仍是工作流清單:--mine/--reviewer/--repo) teai pulls list --repo / [--state open|closed|all] teai pulls view --repo / [--comments] teai pulls create --repo / --head [--base main] --title … [--body …] --yes teai pulls merge --repo / [--style merge|rebase|rebase-merge|squash] --yes -teai pulls comment --repo / --body … --yes +teai pulls approve --repo / [--body …] --yes [意見…] # POST reviews,event=APPROVED(#40) +teai pulls close --repo / --yes # 關閉不合併(PATCH state=closed;#40) +teai pulls checkout --repo / [--dir ] # 本機 git 取得 head(#40) +teai pulls comment --repo / [--body …] --yes [留言…] +# 通用留言(tea comment 對等;issue 與 PR 共用端點,#40) +teai comment --repo / [--body …] --yes [留言…] # labels/milestones/releases/repos teai labels list --repo / teai labels create --repo / --name … [--color #RRGGBB] [--description …] --yes @@ -96,8 +101,9 @@ teai repos list [--org ] teai api [--method GET|POST|PATCH|DELETE] [--data ''] [--yes] ``` -> `--yes` 閘門:所有寫入類命令(create/close/comment/merge/labels create/milestones create/api 非 GET 動詞)未帶 `--yes` 時回 exit 2(用法錯誤),且不發出任何 HTTP 請求。 +> `--yes` 閘門:所有寫入類命令(create/close/comment/merge/approve/labels create/milestones create/api 非 GET 動詞)未帶 `--yes` 時回 exit 2(用法錯誤),且不發出任何 HTTP 請求。 > `teai api` 的 path 內含查詢字串(如 `/repos/a/b/issues?state=closed`)時會正確傳遞。 +> `teai pulls checkout` 是本機唯讀輔助:同倉庫 head 在 `--dir`(預設當前目錄)的既有工作樹 `git fetch origin && git checkout -B FETCH_HEAD`;跨倉庫 head 需 `--dir`(新目錄)執行 `git clone --branch `。teai 不代管 git 認證,私有倉庫請自備 credential helper。 ### 全域介面 diff --git a/internal/cli/dailyops_commands.go b/internal/cli/dailyops_commands.go index 4155a40..18e47a8 100644 --- a/internal/cli/dailyops_commands.go +++ b/internal/cli/dailyops_commands.go @@ -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 --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 "內容"), +// 亦可 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 + " 需要恰好一個編號"} + // 位置參數: [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 [--yes]。 +// tea 的通用留言指令:issue 與 PR 共用同一端點(Gitea 以 number 定位, +// issues//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 。 +// tea 相容:意見也可作為位置參數(teai pulls approve --repo a/b "驗證通過。")。 +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 。 +// 關閉不合併;工作流約定 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 [--dir ]。 +// 唯讀輔助指令:取得 PR head 資訊,在本機以 git 取得工作副本—— +// 同倉庫分支 → git fetch --quiet origin && git checkout -B FETCH_HEAD +// (在 --dir 指定的既有 git 工作樹內執行;預設當前目錄); +// 跨倉庫 head → git clone --branch (--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 diff --git a/internal/cli/dailyops_parity_commands_test.go b/internal/cli/dailyops_parity_commands_test.go new file mode 100644 index 0000000..943c044 --- /dev/null +++ b/internal/cli/dailyops_parity_commands_test.go @@ -0,0 +1,289 @@ +// dailyops_parity_commands_test.go — #40 的驗收測試:補齊 tea 對等指令 +// pulls approve/close/checkout、通用 comment、issues list --labels 過濾。 +// 寫入命令未帶 --yes 不得發出任何請求;帶 --yes 時打對端點與 payload。 +package cli + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestParityWriteCommandsRequireYes 驗證新增寫入命令未帶 --yes: +// 回 2 且不發請求(#40 驗收:寫入閘門慣例)。 +func TestParityWriteCommandsRequireYes(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"pulls approve", []string{"pulls", "approve", "--repo", "a/b", "1"}}, + {"pulls close", []string{"pulls", "close", "--repo", "a/b", "1"}}, + {"comment direct", []string{"comment", "--repo", "a/b", "1", "內容"}}, + {"comment direct body flag", []string{"comment", "--repo", "a/b", "--body", "內容", "1"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("%s:未帶 --yes 不得發出請求(%s %s)", tc.name, r.Method, r.URL.Path) + }) + _, stderr, code := run(tc.args...) + if code != 2 { + t.Errorf("%s 未帶 --yes 應回 2,得到 %d", tc.name, code) + } + if !strings.Contains(stderr, "--yes") { + t.Errorf("%s 錯誤訊息應提及 --yes,得到:%s", tc.name, stderr) + } + }) + } +} + +// TestPullsApprovePostsReview 驗證 pulls approve --yes: +// POST /repos/{owner}/{repo}/pulls/{n}/reviews,payload 帶 event=APPROVED 與意見; +// 意見可用位置參數(tea 相容:teai pulls approve --repo a/b "驗證通過。")。 +func TestPullsApprovePostsReview(t *testing.T) { + var gotMethod, gotPath, gotBody string + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + buf := make([]byte, 512) + n, _ := r.Body.Read(buf) + gotBody = string(buf[:n]) + fmt.Fprint(w, `{"ID":42,"number":13,"state":"APPROVED","body":"驗證通過。","submitted_at":"t","user":{"login":"max"}}`) + }) + stdout, _, code := run("pulls", "approve", "--repo", "alterminal/teai", "--yes", "13", "驗證通過。") + if code != 0 { + t.Fatalf("approve --yes 應回 0,得到 %d", code) + } + if gotMethod != "POST" || !strings.HasSuffix(gotPath, "/repos/alterminal/teai/pulls/13/reviews") { + t.Errorf("應 POST /repos/alterminal/teai/pulls/13/reviews,得到 %s %s", gotMethod, gotPath) + } + if !strings.Contains(gotBody, `"event":"APPROVED"`) || !strings.Contains(gotBody, "驗證通過。") { + t.Errorf("body 應含 event=APPROVED 與意見,得到:%s", gotBody) + } + if !strings.Contains(stdout, `"state":"APPROVED"`) { + t.Errorf("輸出應含 APPROVED,得到:%s", stdout) + } +} + +// TestPullsClosePatchesState 驗證 pulls close --yes: +// PATCH /repos/{owner}/{repo}/pulls/{n},payload 帶 state=closed。 +func TestPullsClosePatchesState(t *testing.T) { + var gotMethod, gotPath, gotBody string + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + buf := make([]byte, 512) + n, _ := r.Body.Read(buf) + gotBody = string(buf[:n]) + fmt.Fprint(w, `{"number":13,"title":"PR","state":"closed","html_url":"https://x/13"}`) + }) + _, _, code := run("pulls", "close", "--repo", "alterminal/teai", "--yes", "13") + if code != 0 { + t.Fatalf("close --yes 應回 0,得到 %d", code) + } + if gotMethod != "PATCH" || !strings.HasSuffix(gotPath, "/repos/alterminal/teai/pulls/13") { + t.Errorf("應 PATCH /repos/alterminal/teai/pulls/13,得到 %s %s", gotMethod, gotPath) + } + if !strings.Contains(gotBody, `"state":"closed"`) { + t.Errorf("body 應含 state=closed,得到:%s", gotBody) + } +} + +// TestCommentDirectPostsComment 驗證通用 comment(tea 相容形式): +// --repo a/b --yes → POST issues//comments, +// issue 與 PR 共用(不需要 type 旗標)。 +func TestCommentDirectPostsComment(t *testing.T) { + var gotMethod, gotPath, gotBody string + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + buf := make([]byte, 512) + n, _ := r.Body.Read(buf) + gotBody = string(buf[:n]) + fmt.Fprint(w, `{"body":"已依評論更新:commit abc1234。","created_at":"t","user":{"login":"max"}}`) + }) + stdout, _, code := run("comment", "--repo", "alterminal/teai", "--yes", "13", "已依評論更新:commit abc1234。") + if code != 0 { + t.Fatalf("comment --yes 應回 0,得到 %d", code) + } + if gotMethod != "POST" || !strings.HasSuffix(gotPath, "/repos/alterminal/teai/issues/13/comments") { + t.Errorf("應 POST /repos/alterminal/teai/issues/13/comments,得到 %s %s", gotMethod, gotPath) + } + if !strings.Contains(gotBody, "已依評論更新:commit abc1234。") { + t.Errorf("body 應含留言內容,得到:%s", gotBody) + } + if !strings.Contains(stdout, "已依評論更新") { + t.Errorf("輽出應含留言,得到:%s", stdout) + } +} + +// TestCommentDirectBodyFlag 驗證 comment 也接受 --body(teai 慣用), +// 且位置參數與 --body 同時存在時位置參數優先。 +func TestCommentDirectBodyFlag(t *testing.T) { + var gotBody string + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 512) + n, _ := r.Body.Read(buf) + gotBody = string(buf[:n]) + fmt.Fprint(w, `{"body":"x","created_at":"t","user":{"login":"max"}}`) + }) + if _, _, code := run("comment", "--repo", "a/b", "--body", "旗標版", "--yes", "5"); code != 0 { + t.Fatalf("--body 版應回 0,得到 %d", code) + } + if !strings.Contains(gotBody, "旗標版") { + t.Errorf("body 應為 --body 值,得到:%s", gotBody) + } + if _, _, code := run("comment", "--repo", "a/b", "--body", "旗標版", "--yes", "5", "位置版"); code != 0 { + t.Fatalf("位置參數版應回 0,得到 %d", code) + } + if !strings.Contains(gotBody, "位置版") || strings.Contains(gotBody, "旗標版") { + t.Errorf("位置參數應優先於 --body,得到:%s", gotBody) + } +} + +// TestIssuesListLabelsFilter 驗證 issues list --labels: +// 查詢參數帶 labels=a,b(多標籤 AND 語義),輸出仍為 JSON 陣列。 +func TestIssuesListLabelsFilter(t *testing.T) { + var gotQuery string + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + fmt.Fprint(w, `[{"number":9,"title":"标签過濾","state":"open","user":{"login":"ceo"},"updated_at":"t"}]`) + }) + stdout, _, code := run("issues", "list", "--repo", "alterminal/teai", "--labels", "bug, help wanted") + if code != 0 { + t.Fatalf("--labels 應回 0,得到 %d", code) + } + if !strings.Contains(gotQuery, "labels=bug%2Chelp+wanted") && !strings.Contains(gotQuery, "labels=bug%2C+help+wanted") { + t.Errorf("查詢應帶 labels=bug,help+wanted(URL 編碼後),得到 %q", gotQuery) + } + if !strings.Contains(stdout, `"number":9`) { + t.Errorf("輽出應含過濾後的 issue,得到:%s", stdout) + } +} + +// TestPullsCheckoutSameRepo 驗證 checkout 同倉庫分支: +// 在 --dir 的 git 工作樹 fetch origin 並 checkout -B 。 +func TestPullsCheckoutSameRepo(t *testing.T) { + // 建立裸「origin」倉庫與分支 feat/x。 + origin := t.TempDir() + gitRun := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = origin + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v 失敗:%v\n%s", args, err, out) + } + } + gitRun("init", "-q", "-b", "main", ".") + gitRun("config", "user.email", "t@t") + gitRun("config", "user.name", "t") + gitRun("commit", "--allow-empty", "-m", "init") + gitRun("checkout", "-q", "-b", "feat/x") + gitRun("commit", "--allow-empty", "-m", "x1") + gitRun("checkout", "-q", "main") + // 工作樹 clone 自 origin(file:// 協定讓 fetch 生效)。 + work := t.TempDir() + if out, err := exec.Command("git", "clone", "-q", "file://"+origin, work).CombinedOutput(); err != nil { + t.Fatalf("clone 失敗:%v\n%s", err, out) + } + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"number":7,"title":"PR","state":"open","head":{"ref":"feat/x","repo":{"full_name":"alterminal/teai","clone_url":"https://gitea.alterminal.com/alterminal/teai.git"}},"base":{"ref":"main"}}`) + }) + stdout, _, code := run("pulls", "checkout", "--repo", "alterminal/teai", "--dir", work, "7") + if code != 0 { + t.Fatalf("checkout 應回 0,得到 %d", code) + } + brOut, err := exec.Command("git", "-C", work, "branch", "--show-current").Output() + if err != nil || strings.TrimSpace(string(brOut)) != "feat/x" { + t.Errorf("工作樹應在 feat/x,得到 %q(err=%v)", string(brOut), err) + } + if !strings.Contains(stdout, `"branch":"feat/x"`) { + t.Errorf("輽出應含 branch 欄位,得到:%s", stdout) + } +} + +// TestPullsCheckoutCrossRepo 驗證 checkout 跨倉庫 head: +// 需 --dir(新目錄),git clone --branch 。 +func TestPullsCheckoutCrossRepo(t *testing.T) { + // 假伺服器同時當 git remote:把 PR head 指向本地裸倉庫。 + origin := t.TempDir() + gitRun := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = origin + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v 失敗:%v\n%s", args, err, out) + } + } + gitRun("init", "-q", "-b", "main", "--bare", ".") + seed := t.TempDir() + cmd := exec.Command("git", "clone", "-q", "file://"+origin, seed) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("clone seed 失敗:%v\n%s", err, out) + } + for _, args := range [][]string{ + {"config", "user.email", "t@t"}, + {"config", "user.name", "t"}, + {"commit", "--allow-empty", "-m", "init"}, + {"checkout", "-q", "-b", "feat/y"}, + {"commit", "--allow-empty", "-m", "y1"}, + {"push", "-q", "origin", "feat/y"}, + } { + c := exec.Command("git", args...) + c.Dir = seed + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %v 失敗:%v\n%s", args, err, out) + } + } + target := filepath.Join(t.TempDir(), "wk") + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"number":8,"title":"PR","state":"open","head":{"ref":"feat/y","repo":{"full_name":"other/fork","clone_url":"file://%s"}},"base":{"ref":"main"}}`, origin) + }) + stdout, _, code := run("pulls", "checkout", "--repo", "alterminal/teai", "--dir", target, "8") + if code != 0 { + t.Fatalf("checkout 跨倉庫應回 0,得到 %d", code) + } + brOut, err := exec.Command("git", "-C", target, "branch", "--show-current").Output() + if err != nil || strings.TrimSpace(string(brOut)) != "feat/y" { + t.Errorf("工作樹應在 feat/y,得到 %q(err=%v)", string(brOut), err) + } + if !strings.Contains(stdout, `"repo":"other/fork"`) { + t.Errorf("輽出應含 head 倉庫,得到:%s", stdout) + } +} + +// TestPullsCheckoutCrossRepoRequiresDir 驗證跨倉庫 head 未帶 --dir: +// 用法錯誤(exit 2)。 +func TestPullsCheckoutCrossRepoRequiresDir(t *testing.T) { + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"number":8,"title":"PR","state":"open","head":{"ref":"feat/y","repo":{"full_name":"other/fork","clone_url":"https://x/y.git"}},"base":{"ref":"main"}}`) + }) + _, stderr, code := run("pulls", "checkout", "--repo", "alterminal/teai", "8") + if code != 2 { + t.Errorf("跨倉庫 head 未帶 --dir 應回 2,得到 %d", code) + } + if !strings.Contains(stderr, "--dir") { + t.Errorf("錯誤訊息應提及 --dir,得到:%s", stderr) + } +} + +// TestIssuesCommentPositionalBody 驗證 issues comment 也吃位置參數留言 +// (tea comment 相容语法在子命令下的行為)。 +func TestIssuesCommentPositionalBody(t *testing.T) { + var gotBody string + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 512) + n, _ := r.Body.Read(buf) + gotBody = string(buf[:n]) + fmt.Fprint(w, `{"body":"x","created_at":"t","user":{"login":"max"}}`) + }) + if _, _, code := run("issues", "comment", "--repo", "a/b", "--yes", "3", "多餘", "引數", "串留言"); code != 0 { + t.Fatalf("issues comment 位置參數應回 0,得到 %d", code) + } + if !strings.Contains(gotBody, "多餘 引數 串留言") { + t.Errorf("尾端引數應串成留言,得到:%s", gotBody) + } +} + +var _ = os.Getenv // 保持 import 一致(checkout 測試用 exec,此檔亦引用 os 於需要時) diff --git a/internal/dailyops/dailyops.go b/internal/dailyops/dailyops.go index f50fecc..f2d83e6 100644 --- a/internal/dailyops/dailyops.go +++ b/internal/dailyops/dailyops.go @@ -11,6 +11,7 @@ import ( "fmt" "net/url" "strconv" + "strings" "gitea.alterminal.com/alterminal/teai/internal/gitea" ) @@ -119,8 +120,10 @@ type Pull struct { // 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"` } @@ -165,8 +168,9 @@ func repoPath(owner, repo string) string { } // 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) { +// 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" } @@ -174,6 +178,9 @@ func (o *Ops) ListIssues(ctx context.Context, owner, repo, state string) ([]Issu // 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 @@ -259,12 +266,93 @@ func (o *Ops) ListPulls(ctx context.Context, owner, repo, state string) ([]Pull, // GetPull 取得單一 PR。 func (o *Ops) GetPull(ctx context.Context, owner, repo string, number int64) (*Pull, error) { - var pull Pull + var pr Pull path := repoPath(owner, repo) + "/pulls/" + strconv.FormatInt(number, 10) - if err := o.Client.GetJSON(ctx, path, &pull); err != nil { + if err := o.Client.GetJSON(ctx, path, &pr); err != nil { return nil, err } - return &pull, nil + 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。