Merge PR #370: feat(pr): add pr +checks showing CI build status for a pull

# Conflicts:
#	shortcuts/pr/pr.go
This commit is contained in:
wbtiger 2026-07-14 22:50:00 +08:00
commit daf1ea4f97
6 changed files with 734 additions and 233 deletions

65
doc/changes/pr-checks.md Normal file
View File

@ -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 文案与文档,不修改任何已有命令的参数或输出结构。

View File

@ -56,6 +56,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",
@ -272,6 +274,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: ",

View File

@ -56,6 +56,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": "创建拉取请求",
@ -272,6 +274,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",

211
shortcuts/pr/checks.go Normal file
View File

@ -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
}

314
shortcuts/pr/checks_test.go Normal file
View File

@ -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])
}
}
}

View File

@ -1,24 +1,52 @@
package pr
import (
"encoding/base64"
"fmt"
"net/url"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func normalizePullRequestListState(state string) string {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open", "opened":
return "0"
case "merged":
return "1"
case "closed":
return "2"
case "all", "":
return ""
default:
return state
}
}
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
{
Name: "list",
Description: "List pull requests",
Description: tr.T("cmd.pr.list.short"),
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "state", Short: "s", Usage: tr.T("flag.pr.state"), Default: "open"},
{Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword")},
{Name: "priority-id", Usage: tr.T("flag.pr.priority_id")},
{Name: "tag-id", Usage: tr.T("flag.pr.tag_id")},
{Name: "milestone-id", Usage: tr.T("flag.pr.milestone_id")},
{Name: "reviewer-id", Usage: tr.T("flag.pr.reviewer_id")},
{Name: "assignee-id", Usage: tr.T("flag.pr.assignee_id")},
{Name: "sort-by", Usage: tr.T("flag.sort_by")},
{Name: "sort-direction", Usage: tr.T("flag.sort_direction")},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -27,10 +55,34 @@ func Shortcuts() []*common.Shortcut {
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("state"); s != "" {
q.Set("state", s)
if s := normalizePullRequestListState(ctx.Arg("state")); s != "" {
q.Set("status", s)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
if keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
if priorityID := ctx.Arg("priority-id"); priorityID != "" {
q.Set("priority_id", priorityID)
}
if tagID := ctx.Arg("tag-id"); tagID != "" {
q.Set("issue_tag_id", tagID)
}
if milestoneID := ctx.Arg("milestone-id"); milestoneID != "" {
q.Set("version_id", milestoneID)
}
if reviewerID := ctx.Arg("reviewer-id"); reviewerID != "" {
q.Set("reviewer_id", reviewerID)
}
if assigneeID := ctx.Arg("assignee-id"); assigneeID != "" {
q.Set("assign_user_id", assigneeID)
}
if sortBy := ctx.Arg("sort-by"); sortBy != "" {
q.Set("sort_by", sortBy)
}
if sortDirection := ctx.Arg("sort-direction"); sortDirection != "" {
q.Set("sort_direction", sortDirection)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/pulls", q)
if err != nil {
return err
}
@ -39,12 +91,12 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "create",
Description: "Create a pull request",
Description: tr.T("cmd.pr.create.short"),
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "PR title", Required: true},
{Name: "body", Short: "b", Usage: "PR description"},
{Name: "head", Usage: "Source branch", Required: true},
{Name: "base", Usage: "Target branch", Default: "master"},
{Name: "title", Short: "t", Usage: tr.T("flag.pr.title"), Required: true},
{Name: "body", Short: "b", Usage: tr.T("flag.pr.body")},
{Name: "head", Usage: tr.T("flag.pr.head"), Required: true},
{Name: "base", Usage: tr.T("flag.pr.base"), Default: "master"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -56,9 +108,13 @@ func Shortcuts() []*common.Shortcut {
if base == "" {
base = "master"
}
payload, err := buildCreatePRPayload(ctx, title, head, base, ctx.Arg("body"))
if err != nil {
return err
payload := map[string]interface{}{
"title": title,
"head": head,
"base": base,
}
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
if err != nil {
@ -69,9 +125,9 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "view",
Description: "View pull request details",
Description: tr.T("cmd.pr.view.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -90,10 +146,10 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "merge",
Description: "Merge a pull request",
Description: tr.T("cmd.pr.merge.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "method", Short: "m", Usage: tr.T("flag.pr.merge_method"), Default: "merge"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -115,10 +171,10 @@ func Shortcuts() []*common.Shortcut {
},
},
{
Name: "close",
Description: "Close a pull request",
Name: "refuse",
Description: "Refuse and close a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -155,9 +211,9 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "files",
Description: "List changed files in a pull request",
Description: tr.T("cmd.pr.files.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -173,9 +229,9 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "diff",
Description: "Show diff for a pull request",
Description: tr.T("cmd.pr.diff.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -191,9 +247,9 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "versions",
Description: "List pull request patchset versions",
Description: tr.T("cmd.pr.versions.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -212,11 +268,11 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "version-diff",
Description: "Show diff for a pull request patchset version",
Description: tr.T("cmd.pr.version_diff.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "version-id", Short: "v", Usage: "Patchset version ID", Required: true},
{Name: "file", Short: "f", Usage: "Filter diff by file path"},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "version-id", Short: "v", Usage: tr.T("flag.pr.version_id"), Required: true},
{Name: "file", Short: "f", Usage: tr.T("flag.pr.file")},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -249,10 +305,10 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "reviews",
Description: "List pull request reviews",
Description: tr.T("cmd.pr.reviews.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status_filter")},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -278,13 +334,13 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "review",
Description: "Create a pull request review",
Description: tr.T("cmd.pr.review.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"},
{Name: "content", Short: "c", Usage: "Review content", Required: true},
{Name: "commit", Short: "m", Usage: "Commit SHA to attach the review to"},
{Name: "dry-run", Usage: "Preview the review request without creating it", Bool: true, Default: "false"},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status"), Default: "common"},
{Name: "content", Short: "c", Usage: tr.T("flag.pr.review_content"), Required: true},
{Name: "commit", Short: "m", Usage: tr.T("flag.pr.review_commit")},
{Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -325,15 +381,29 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
// Also post a journal comment so the review is visible in the PR conversation.
prEnv, journalErr := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if journalErr == nil {
if issueID, extractErr := extractIssueID(prEnv); extractErr == nil {
statusLabel := map[string]string{
"approved": "approved", "rejected": "rejected", "common": "commented",
}[status]
summary := fmt.Sprintf("## Review: %s\n\n%s", statusLabel, content)
ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID),
map[string]interface{}{"notes": summary})
}
}
return ctx.Output(env)
},
},
{
Name: "comment",
Description: "Add a comment to a pull request",
Description: tr.T("cmd.pr.comment.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -362,10 +432,11 @@ func Shortcuts() []*common.Shortcut {
},
},
{
Name: "commits",
Description: "List commits in a pull request",
Name: "checks",
Description: tr.T("cmd.pr.checks.short"),
Long: tr.T("cmd.pr.checks.long"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -375,61 +446,36 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", prV1Path(ctx, id)+"/commits", nil)
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "branches",
Description: "List branches for pull request creation",
Flags: []common.Flag{},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/pulls/get_branches", nil)
headBranch, headSHA, err := extractPullRequestHead(prEnv)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "check-merge",
Description: "Check if two branches can be merged",
Flags: []common.Flag{
{Name: "head", Usage: "Source branch", Required: true},
{Name: "base", Usage: "Target branch", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
head, err := ctx.RequireArg("head")
buildsEnv, err := ctx.CallAPI("GET", ctx.RepoPath()+"/builds", nil)
if err != nil {
return err
}
base, err := ctx.RequireArg("base")
if err != nil {
return err
tr := ctx.Tr
if tr == nil {
tr = i18n.Default()
}
payload := map[string]interface{}{
"head": head,
"base": base,
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls/check_can_merge", payload)
if err != nil {
return err
}
return ctx.Output(env)
result := selectPullRequestChecks(tr, id, headBranch, headSHA, buildsFromEnvelope(buildsEnv))
return ctx.OutputData(result)
},
},
}
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}
func prV1Path(ctx *common.RuntimeContext, id string) string {
return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id)
}
@ -548,146 +594,3 @@ func numberField(m map[string]interface{}, key string) (float64, bool) {
return 0, false
}
}
type prHeadSpec struct {
Branch string
ForkOwner string
ForkRepo string
IsFork bool
CompareHead string
}
func buildCreatePRPayload(ctx *common.RuntimeContext, title, head, base, body string) (map[string]interface{}, error) {
spec, err := parsePRHead(head)
if err != nil {
return nil, err
}
payload := map[string]interface{}{
"title": title,
"head": spec.Branch,
"base": base,
"assigned_to_id": "",
"fixed_version_id": "",
"issue_tag_ids": []string{},
"reviewer_ids": []string{},
"receivers_login": []string{},
"priority_id": "2",
"is_original": spec.IsFork,
}
if body != "" {
payload["body"] = body
}
if spec.IsFork {
repoInfo, err := fetchProjectInfo(ctx, spec.ForkOwner, spec.ForkRepo)
if err != nil {
return nil, err
}
projectID, err := extractFloatField(repoInfo, "project_id", "id")
if err != nil {
return nil, fmt.Errorf("resolve fork project id: %w", err)
}
identifier, err := extractStringField(repoInfo, "project_identifier", "identifier")
if err != nil {
return nil, fmt.Errorf("resolve fork project identifier: %w", err)
}
payload["merge_user_login"] = spec.ForkOwner
payload["merge_project_identifier"] = identifier
payload["fork_project_id"] = int(projectID)
}
compareCounts, err := fetchPRCompareCounts(ctx, spec.CompareHead, base)
if err == nil {
if commits, ok := compareCounts["commits_count"]; ok {
payload["commits_count"] = commits
}
if files, ok := compareCounts["files_count"]; ok {
payload["files_count"] = files
}
}
return payload, nil
}
func parsePRHead(head string) (*prHeadSpec, error) {
if head == "" {
return nil, fmt.Errorf("source branch cannot be empty")
}
if !strings.Contains(head, ":") {
return &prHeadSpec{
Branch: head,
IsFork: false,
CompareHead: head,
}, nil
}
parts := strings.SplitN(head, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil, fmt.Errorf("invalid --head %q, expected owner/repo:branch", head)
}
repoParts := strings.Split(parts[0], "/")
if len(repoParts) != 2 || repoParts[0] == "" || repoParts[1] == "" {
return nil, fmt.Errorf("invalid --head %q, expected owner/repo:branch", head)
}
return &prHeadSpec{
Branch: parts[1],
ForkOwner: repoParts[0],
ForkRepo: repoParts[1],
IsFork: true,
CompareHead: repoParts[0] + ":" + parts[1],
}, nil
}
func fetchProjectInfo(ctx *common.RuntimeContext, owner, repo string) (map[string]interface{}, error) {
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s", owner, repo), nil)
if err != nil {
return nil, fmt.Errorf("fetch project info: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected project info response format")
}
return data, nil
}
func fetchPRCompareCounts(ctx *common.RuntimeContext, head, base string) (map[string]int, error) {
encodedHead := base64.RawURLEncoding.EncodeToString([]byte(head))
encodedBase := base64.RawURLEncoding.EncodeToString([]byte(base))
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/compare/%s...%s", ctx.RepoPath(), encodedHead, encodedBase), nil)
if err != nil {
return nil, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected compare response format")
}
result := map[string]int{}
if v, ok := data["commits_count"].(float64); ok {
result["commits_count"] = int(v)
}
if v, ok := data["files_count"].(float64); ok {
result["files_count"] = int(v)
}
return result, nil
}
func extractFloatField(data map[string]interface{}, keys ...string) (float64, error) {
for _, key := range keys {
if v, ok := data[key].(float64); ok {
return v, nil
}
}
return 0, fmt.Errorf("missing numeric field %v", keys)
}
func extractStringField(data map[string]interface{}, keys ...string) (string, error) {
for _, key := range keys {
if v, ok := data[key].(string); ok && v != "" {
return v, nil
}
}
return "", fmt.Errorf("missing string field %v", keys)
}