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:
commit
daf1ea4f97
|
|
@ -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 文案与文档,不修改任何已有命令的参数或输出结构。
|
||||
|
|
@ -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: ",
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue