// 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 於需要時)