From 5c2b309f38b5eddb4f126a73a9d0e7fa66292392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=99=A8=20=28Leo=20Cheng=29?= Date: Wed, 8 Jul 2026 13:49:42 +0800 Subject: [PATCH] feat(pr): add pr +checks showing CI build status for a pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 林晨 (Leo Cheng) --- doc/changes/pr-checks.md | 65 +++++++ internal/i18n/locales/en-US.json | 4 + internal/i18n/locales/zh-CN.json | 4 + shortcuts/pr/checks.go | 211 +++++++++++++++++++++ shortcuts/pr/checks_test.go | 314 +++++++++++++++++++++++++++++++ shortcuts/pr/pr.go | 35 ++++ 6 files changed, 633 insertions(+) create mode 100644 doc/changes/pr-checks.md create mode 100644 shortcuts/pr/checks.go create mode 100644 shortcuts/pr/checks_test.go diff --git a/doc/changes/pr-checks.md b/doc/changes/pr-checks.md new file mode 100644 index 0000000..d04082d --- /dev/null +++ b/doc/changes/pr-checks.md @@ -0,0 +1,65 @@ +# pr +checks 合并请求 CI 构建状态命令 + +## 背景 + +`gitlink-cli pr` 已覆盖合并请求的列表、详情、评审、评论等操作,`gitlink-cli ci +builds` 可列出仓库的 CI 构建,但两者相互独立。用户或 AI Agent 想确认「某个合并请求的最新提交是否通过了 CI」,此前需要手动读取 PR 的源分支/源提交,再逐条比对构建列表。 + +本次变更新增 `pr +checks`,对齐 `gh pr checks` 的语义:解析合并请求 head,自动关联并汇总对应的 CI 构建状态。 + +## 变更内容 + +- 新增 `gitlink-cli pr +checks --id N` Shortcut。 +- 先 `GET /{owner}/{repo}/pulls/{id}` 读取合并请求详情,取源分支 `head` 与源提交 `head_commit_sha`(字段依据 API 文档「获取一个合并请求」章节)。 +- 再 `GET /{owner}/{repo}/builds` 拉取 CI 构建列表,在客户端按 head 提交/分支筛选。 +- 输出规范化的状态摘要:`matched_by`、`total_builds` 以及每条构建的 `id / stage / status / conclusion / branch / sha`。 +- 复用现有仓库上下文解析、API 调用与统一输出封装;新增中英文 i18n 文案。 + +## 匹配策略 + +| 优先级 | 条件 | `matched_by` | +|--------|------|--------------| +| 1 | 构建提交 SHA 与 head 提交一致(支持缩写前缀比对) | `sha` | +| 2 | 无 SHA 命中,但构建分支等于 head 分支 | `branch` | +| 3 | 构建未暴露任何分支/提交字段,无法建立关联 | `unlinkable` | +| 4 | 构建暴露了分支/提交字段但均不匹配 | `none` | + +## 命令示例 + +```bash +gitlink-cli pr +checks --owner Gitlink --repo forgeplus --id 42 +gitlink-cli pr +checks --id 42 --format json +``` + +## 已知限制 + +GitLink 的 `/{owner}/{repo}/builds` 端点未纳入官方 OpenAPI 参考文档,构建对象中承载分支与提交的字段名无法从文档确证。为避免臆造字段: + +- 分支字段按 `branch / head_branch / source_branch / ref` 依次探测(`ref` 会去除 `refs/heads/` 前缀)。 +- 提交字段按 `head_commit_sha / commit_sha / commit_id / sha / after / revision` 依次探测。 +- 若某次构建两类字段均缺失,则判定为无法关联(`matched_by = unlinkable`),此时**降级返回全部最近构建并附带说明**,由使用者依据 `head_sha` 手动核对,而非丢弃结果或猜测字段。 + +后续若 `/builds` 响应结构被官方文档化,可据实收敛探测键集合。 + +## 测试覆盖 + +- 表驱动 httptest:先 mock `GET pulls/{id}`、再 mock `GET builds`,断言四种 `matched_by` 分支(sha 优先于 branch、缩写 SHA 命中、branch 回退、unlinkable 全量降级、none 无命中)与选中的构建 id。 +- head 字段缺失、builds 请求 HTTP 失败的错误路径。 +- 纯函数单测:`extractPullRequestHead`、`buildsFromEnvelope`(含客户端把顶层数组作为字符串返回的情形)、`commitMatches`、仅有分支的 PR。 + +验证命令: + +```bash +go build ./... +go test ./shortcuts/pr/ ./shortcuts/ci/ +``` + +## 交付要求核对 + +- 功能代码:`shortcuts/pr/pr.go`、`shortcuts/pr/checks.go` +- 单元测试:`shortcuts/pr/checks_test.go` +- i18n 文案:`internal/i18n/locales/en-US.json`、`internal/i18n/locales/zh-CN.json` +- 变更说明文档:`doc/changes/pr-checks.md` + +## 兼容性 + +该变更只新增 Shortcut、辅助函数、单元测试、i18n 文案与文档,不修改任何已有命令的参数或输出结构。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..0b8c998 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -52,6 +52,8 @@ "cmd.org.list.short": "List organizations", "cmd.org.members.short": "List organization members", "cmd.org.short": "Organization operations", + "cmd.pr.checks.long": "Resolve a pull request's head branch and head commit, then report the CI build(s) that match. A matching commit SHA is authoritative; the head branch is used as a fallback. If builds do not expose branch or commit fields, all recent builds are shown with a note.", + "cmd.pr.checks.short": "Show CI build status for a pull request", "cmd.pr.close.short": "Close a pull request", "cmd.pr.comment.short": "Add a comment to a pull request", "cmd.pr.create.short": "Create a pull request", @@ -255,6 +257,8 @@ "output.doctor.suggestion.check_token": "Check whether the stored token is valid, or run gitlink-cli auth login again.", "output.doctor.suggestion.fix_config_yaml": "Fix the YAML syntax in the gitlink-cli config file.", "output.doctor.suggestion.pass_owner_repo": "Run the command with --owner and --repo when not inside a GitLink repository.", + "output.pr.checks.no_match": "No CI build was found for this pull request's head branch or commit.", + "output.pr.checks.unlinkable": "CI builds do not expose a branch or commit field, so they could not be linked to this pull request; showing all recent builds. Correlate manually using the head commit above.", "output.version": "gitlink-cli {version}", "prompt.auth.password": "Password: ", "prompt.auth.token": "Paste your access token: ", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..ce9af78 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -52,6 +52,8 @@ "cmd.org.list.short": "列出组织", "cmd.org.members.short": "列出组织成员", "cmd.org.short": "组织操作", + "cmd.pr.checks.long": "解析拉取请求的源分支与源提交,再显示与之匹配的 CI 构建。提交 SHA 匹配为准,源分支作为回退。若构建未暴露分支或提交字段,则显示全部最近构建并附带说明。", + "cmd.pr.checks.short": "查看拉取请求的 CI 构建状态", "cmd.pr.close.short": "关闭拉取请求", "cmd.pr.comment.short": "给拉取请求添加评论", "cmd.pr.create.short": "创建拉取请求", @@ -255,6 +257,8 @@ "output.doctor.suggestion.check_token": "检查已保存的 Token 是否有效,或重新运行 gitlink-cli auth login。", "output.doctor.suggestion.fix_config_yaml": "修复 gitlink-cli 配置文件中的 YAML 语法。", "output.doctor.suggestion.pass_owner_repo": "不在 GitLink 仓库目录内时,请通过 --owner 和 --repo 指定仓库。", + "output.pr.checks.no_match": "未找到与该拉取请求源分支或源提交对应的 CI 构建。", + "output.pr.checks.unlinkable": "CI 构建未暴露分支或提交字段,无法与该拉取请求关联;已显示全部最近构建。请依据上方的源提交手动核对。", "output.version": "gitlink-cli {version}", "prompt.auth.password": "密码:", "prompt.auth.token": "粘贴你的访问 Token:", diff --git a/shortcuts/pr/checks.go b/shortcuts/pr/checks.go new file mode 100644 index 0000000..adc0db2 --- /dev/null +++ b/shortcuts/pr/checks.go @@ -0,0 +1,211 @@ +package pr + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/internal/output" +) + +// The GitLink builds payload is not covered by the OpenAPI reference, so the +// exact field names for a build's branch/commit/status are not guaranteed. +// We probe the conventional keys instead of hard-coding a single name the +// server may not emit, and degrade gracefully when none are present. +var ( + prHeadBranchKeys = []string{"head", "head_branch"} + prHeadSHAKeys = []string{"head_commit_sha", "head_sha", "sha"} + buildBranchKeys = []string{"branch", "head_branch", "source_branch", "ref"} + buildSHAKeys = []string{"head_commit_sha", "commit_sha", "commit_id", "sha", "after", "revision"} + buildIDKeys = []string{"id", "number", "build_id", "build_number"} + buildStatusKeys = []string{"status", "state", "build_status", "phase"} + buildStageKeys = []string{"stage", "stage_name", "name"} + buildConclusionKeys = []string{"conclusion", "result"} +) + +type checkBuild struct { + ID interface{} `json:"id,omitempty"` + Stage string `json:"stage,omitempty"` + Status string `json:"status,omitempty"` + Conclusion string `json:"conclusion,omitempty"` + Branch string `json:"branch,omitempty"` + SHA string `json:"sha,omitempty"` +} + +type checksResult struct { + PullRequest string `json:"pull_request"` + HeadBranch string `json:"head_branch,omitempty"` + HeadSHA string `json:"head_sha,omitempty"` + MatchedBy string `json:"matched_by"` + TotalBuilds int `json:"total_builds"` + Builds []checkBuild `json:"builds"` + Note string `json:"note,omitempty"` +} + +// extractPullRequestHead reads the PR's source branch and source commit. The +// single-PR endpoint returns the PR at the top level; a nested pull_request +// object is tolerated for deployments that wrap it. +func extractPullRequestHead(env *output.Envelope) (string, string, error) { + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "", "", fmt.Errorf("unexpected PR response format") + } + branch := firstString(data, prHeadBranchKeys) + sha := firstString(data, prHeadSHAKeys) + if branch == "" && sha == "" { + if nested, ok := data["pull_request"].(map[string]interface{}); ok { + branch = firstString(nested, prHeadBranchKeys) + sha = firstString(nested, prHeadSHAKeys) + } + } + if branch == "" && sha == "" { + return "", "", fmt.Errorf("PR response missing head branch and commit fields") + } + return branch, sha, nil +} + +// buildsFromEnvelope normalizes the builds payload. A top-level JSON array is +// delivered by the client as a raw string (its map unmarshal fails), so string, +// array, and wrapped-object shapes all have to be handled. +func buildsFromEnvelope(env *output.Envelope) []map[string]interface{} { + return normalizeBuildList(env.Data) +} + +func normalizeBuildList(data interface{}) []map[string]interface{} { + switch v := data.(type) { + case string: + var parsed interface{} + if err := json.Unmarshal([]byte(v), &parsed); err != nil { + return nil + } + return normalizeBuildList(parsed) + case []interface{}: + out := make([]map[string]interface{}, 0, len(v)) + for _, item := range v { + if m, ok := item.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out + case map[string]interface{}: + for _, key := range []string{"builds", "data", "list", "items", "runs"} { + if arr, ok := v[key].([]interface{}); ok { + return normalizeBuildList(arr) + } + } + return nil + default: + return nil + } +} + +// selectPullRequestChecks links CI builds to a PR head. A commit-sha match is +// authoritative; branch is the fallback. When builds expose neither field the +// linkage cannot be trusted, so every build is returned with an explanatory note. +func selectPullRequestChecks(tr *i18n.Translator, id, headBranch, headSHA string, builds []map[string]interface{}) checksResult { + res := checksResult{ + PullRequest: id, + HeadBranch: headBranch, + HeadSHA: headSHA, + TotalBuilds: len(builds), + Builds: []checkBuild{}, + } + + var shaMatches, branchMatches []checkBuild + recognizable := false + for _, b := range builds { + cb := summarizeBuild(b) + if cb.Branch != "" || cb.SHA != "" { + recognizable = true + } + if headSHA != "" && cb.SHA != "" && commitMatches(cb.SHA, headSHA) { + shaMatches = append(shaMatches, cb) + continue + } + if headBranch != "" && cb.Branch != "" && cb.Branch == headBranch { + branchMatches = append(branchMatches, cb) + } + } + + switch { + case len(shaMatches) > 0: + res.MatchedBy = "sha" + res.Builds = shaMatches + case len(branchMatches) > 0: + res.MatchedBy = "branch" + res.Builds = branchMatches + case !recognizable && len(builds) > 0: + res.MatchedBy = "unlinkable" + res.Builds = summarizeBuilds(builds) + res.Note = tr.T("output.pr.checks.unlinkable") + default: + res.MatchedBy = "none" + res.Note = tr.T("output.pr.checks.no_match") + } + return res +} + +func summarizeBuilds(builds []map[string]interface{}) []checkBuild { + out := make([]checkBuild, 0, len(builds)) + for _, b := range builds { + out = append(out, summarizeBuild(b)) + } + return out +} + +func summarizeBuild(b map[string]interface{}) checkBuild { + return checkBuild{ + ID: firstValue(b, buildIDKeys), + Stage: firstString(b, buildStageKeys), + Status: firstString(b, buildStatusKeys), + Conclusion: firstString(b, buildConclusionKeys), + Branch: buildBranch(b), + SHA: firstString(b, buildSHAKeys), + } +} + +func buildBranch(b map[string]interface{}) string { + for _, k := range buildBranchKeys { + if s, ok := b[k].(string); ok && s != "" { + return strings.TrimPrefix(s, "refs/heads/") + } + } + return "" +} + +// commitMatches compares two commit ids allowing an abbreviated form on either +// side, since builds may record a short SHA while the PR carries the full one. +func commitMatches(a, b string) bool { + a = strings.ToLower(strings.TrimSpace(a)) + b = strings.ToLower(strings.TrimSpace(b)) + if a == "" || b == "" { + return false + } + if a == b { + return true + } + const minPrefix = 7 + if len(a) >= minPrefix && len(b) >= minPrefix { + return strings.HasPrefix(a, b) || strings.HasPrefix(b, a) + } + return false +} + +func firstString(m map[string]interface{}, keys []string) string { + for _, k := range keys { + if s, ok := m[k].(string); ok && s != "" { + return s + } + } + return "" +} + +func firstValue(m map[string]interface{}, keys []string) interface{} { + for _, k := range keys { + if v, ok := m[k]; ok && v != nil { + return v + } + } + return nil +} diff --git a/shortcuts/pr/checks_test.go b/shortcuts/pr/checks_test.go new file mode 100644 index 0000000..f1a9aeb --- /dev/null +++ b/shortcuts/pr/checks_test.go @@ -0,0 +1,314 @@ +package pr + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/internal/output" +) + +const ( + checksHeadBranch = "feature/x" + checksHeadSHA = "82861402ada099d3e288fc41680596dde297d022" +) + +type checksEnvelope struct { + OK bool `json:"ok"` + Data checksResult `json:"data"` +} + +// TestPRChecksSelection mocks the single-PR GET followed by the builds GET and +// asserts which build(s) the shortcut links to the PR head under each shape of +// the (undocumented) builds payload. +func TestPRChecksSelection(t *testing.T) { + cases := []struct { + name string + builds []interface{} + wantMatchedBy string + wantIDs []float64 + wantNote bool + }{ + { + name: "sha match wins over branch", + builds: []interface{}{ + map[string]interface{}{"id": float64(1), "branch": checksHeadBranch, "sha": "deadbeef1234567", "status": "failure"}, + map[string]interface{}{"id": float64(2), "branch": checksHeadBranch, "sha": checksHeadSHA, "status": "success", "stage": "build"}, + }, + wantMatchedBy: "sha", + wantIDs: []float64{2}, + }, + { + name: "abbreviated sha still matches", + builds: []interface{}{ + map[string]interface{}{"number": float64(9), "commit_id": checksHeadSHA[:8], "state": "success"}, + }, + wantMatchedBy: "sha", + wantIDs: []float64{9}, + }, + { + name: "branch fallback when no sha field present", + builds: []interface{}{ + map[string]interface{}{"number": float64(7), "ref": "refs/heads/feature/x", "status": "running"}, + map[string]interface{}{"number": float64(8), "ref": "refs/heads/other", "status": "success"}, + }, + wantMatchedBy: "branch", + wantIDs: []float64{7}, + }, + { + name: "unlinkable builds return all with a note", + builds: []interface{}{ + map[string]interface{}{"id": float64(1), "status": "success"}, + map[string]interface{}{"id": float64(2), "status": "failure"}, + }, + wantMatchedBy: "unlinkable", + wantIDs: []float64{1, 2}, + wantNote: true, + }, + { + name: "recognizable builds but none match the head", + builds: []interface{}{ + map[string]interface{}{"id": float64(1), "branch": "other", "sha": "aaaaaaa1111111", "status": "success"}, + }, + wantMatchedBy: "none", + wantIDs: nil, + wantNote: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/42.json": + writeJSON(t, w, map[string]interface{}{ + "id": float64(42), + "status": "open", + "head": checksHeadBranch, + "head_commit_sha": checksHeadSHA, + "issue": map[string]interface{}{"id": float64(100)}, + }) + case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json": + writeJSON(t, w, tc.builds) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + out, err := captureStdout(t, func() error { + return runPRShortcut(t, server, "checks", map[string]string{"id": "42"}) + }) + if err != nil { + t.Fatalf("checks failed: %v", err) + } + + result := decodeChecksEnvelope(t, out).Data + if result.MatchedBy != tc.wantMatchedBy { + t.Fatalf("matched_by = %q, want %q", result.MatchedBy, tc.wantMatchedBy) + } + if result.HeadBranch != checksHeadBranch || result.HeadSHA != checksHeadSHA { + t.Fatalf("head = %q/%q, want %q/%q", result.HeadBranch, result.HeadSHA, checksHeadBranch, checksHeadSHA) + } + if result.TotalBuilds != len(tc.builds) { + t.Fatalf("total_builds = %d, want %d", result.TotalBuilds, len(tc.builds)) + } + assertBuildIDs(t, result.Builds, tc.wantIDs) + if tc.wantNote && result.Note == "" { + t.Fatalf("expected a note for matched_by=%s", tc.wantMatchedBy) + } + if !tc.wantNote && result.Note != "" { + t.Fatalf("unexpected note: %q", result.Note) + } + }) + } +} + +func TestPRChecksErrorsWhenHeadMissing(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/owner/repo/pulls/42.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"id": float64(42), "status": "open"}) + })) + defer server.Close() + + _, err := captureStdout(t, func() error { + return runPRShortcut(t, server, "checks", map[string]string{"id": "42"}) + }) + if err == nil { + t.Fatal("expected error when PR response lacks head fields") + } +} + +func TestPRChecksBuildsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/owner/repo/pulls/42.json": + writeJSON(t, w, map[string]interface{}{ + "head": checksHeadBranch, + "head_commit_sha": checksHeadSHA, + }) + default: + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + } + })) + defer server.Close() + + _, err := captureStdout(t, func() error { + return runPRShortcut(t, server, "checks", map[string]string{"id": "42"}) + }) + if err == nil { + t.Fatal("expected error when builds request fails") + } +} + +func TestExtractPullRequestHead(t *testing.T) { + cases := []struct { + name string + data interface{} + wantBranch string + wantSHA string + wantErr bool + }{ + { + name: "top level fields", + data: map[string]interface{}{"head": "feature/x", "head_commit_sha": "abc123def4567"}, + wantBranch: "feature/x", + wantSHA: "abc123def4567", + }, + { + name: "nested pull_request wrapper", + data: map[string]interface{}{"pull_request": map[string]interface{}{"head": "feature/y", "head_commit_sha": "def456"}}, + wantBranch: "feature/y", + wantSHA: "def456", + }, + { + name: "missing head fields", + data: map[string]interface{}{"id": float64(1)}, + wantErr: true, + }, + { + name: "not a map", + data: "raw", + wantErr: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + branch, sha, err := extractPullRequestHead(&output.Envelope{Data: tc.data}) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if branch != tc.wantBranch || sha != tc.wantSHA { + t.Fatalf("= %q/%q, want %q/%q", branch, sha, tc.wantBranch, tc.wantSHA) + } + }) + } +} + +func TestBuildsFromEnvelope(t *testing.T) { + cases := []struct { + name string + data interface{} + want int + }{ + {name: "raw json string array (client array quirk)", data: `[{"id":1},{"id":2}]`, want: 2}, + {name: "already parsed array", data: []interface{}{map[string]interface{}{"id": float64(1)}}, want: 1}, + {name: "wrapped under builds key", data: map[string]interface{}{"builds": []interface{}{map[string]interface{}{"id": float64(1)}}}, want: 1}, + {name: "non json string", data: "not json", want: 0}, + {name: "unrelated map", data: map[string]interface{}{"message": "ok"}, want: 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := buildsFromEnvelope(&output.Envelope{Data: tc.data}) + if len(got) != tc.want { + t.Fatalf("len = %d, want %d", len(got), tc.want) + } + }) + } +} + +func TestCommitMatches(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"82861402ada099d3e288fc41680596dde297d022", "82861402ada099d3e288fc41680596dde297d022", true}, + {"82861402ada099d3e288fc41680596dde297d022", "8286140", true}, + {"8286140", "82861402ada099d3e288fc41680596dde297d022", true}, + {"82861402", "deadbeef", false}, + {"abc", "abc123", false}, + {"", "abc1234", false}, + } + for _, tc := range cases { + if got := commitMatches(tc.a, tc.b); got != tc.want { + t.Fatalf("commitMatches(%q,%q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + } +} + +func TestSelectPullRequestChecksBranchOnlyPR(t *testing.T) { + // A PR with only a head branch (no SHA) still links via branch. + builds := []map[string]interface{}{ + {"id": float64(1), "branch": "feature/x", "status": "success"}, + } + res := selectPullRequestChecks(i18n.Default(), "42", "feature/x", "", builds) + if res.MatchedBy != "branch" || len(res.Builds) != 1 { + t.Fatalf("matched_by=%q builds=%d, want branch/1", res.MatchedBy, len(res.Builds)) + } +} + +func captureStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + runErr := fn() + w.Close() + os.Stdout = orig + data, readErr := io.ReadAll(r) + if readErr != nil { + t.Fatalf("read captured output: %v", readErr) + } + return string(data), runErr +} + +func decodeChecksEnvelope(t *testing.T, out string) checksEnvelope { + t.Helper() + var env checksEnvelope + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("decode output %q: %v", out, err) + } + return env +} + +func assertBuildIDs(t *testing.T, builds []checkBuild, want []float64) { + t.Helper() + if len(builds) != len(want) { + t.Fatalf("got %d builds, want %d (%v)", len(builds), len(want), want) + } + for i, b := range builds { + got, ok := b.ID.(float64) + if !ok { + t.Fatalf("build[%d].ID = %v (%T), want float64", i, b.ID, b.ID) + } + if got != want[i] { + t.Fatalf("build[%d].ID = %v, want %v", i, got, want[i]) + } + } +} diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 03f537f..76dc33a 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -431,6 +431,41 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "checks", + Description: tr.T("cmd.pr.checks.short"), + Long: tr.T("cmd.pr.checks.long"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) + if err != nil { + return err + } + headBranch, headSHA, err := extractPullRequestHead(prEnv) + if err != nil { + return err + } + buildsEnv, err := ctx.CallAPI("GET", ctx.RepoPath()+"/builds", nil) + if err != nil { + return err + } + tr := ctx.Tr + if tr == nil { + tr = i18n.Default() + } + result := selectPullRequestChecks(tr, id, headBranch, headSHA, buildsFromEnvelope(buildsEnv)) + return ctx.OutputData(result) + }, + }, } }