From a0370fb96b27647e82afc7ad76c40752df7aa05a Mon Sep 17 00:00:00 2001 From: 1os21ka23r9navae6mrro <1os21ka23r9navae6mrro@gmail.com> Date: Wed, 8 Jul 2026 14:33:59 +0000 Subject: [PATCH] feat(pr): add +commits and +check-merge - pr +commits: list commits of a pull request (GET /pulls/:id/commits) - pr +check-merge: pre-flight check_can_merge before creating a PR. Detects same-branch no-ops and existing duplicate merge requests, including the cross-fork case via --fork-project-id (which also sets is_original, matching the server's duplicate lookup). Production-verified on gitlink.org.cn: commits listing, a 'can merge' pass, and duplicate detection that points at the existing PR. 2 unit tests, README examples, bilingual i18n keys. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 8 +++++ internal/i18n/locales/en-US.json | 3 ++ internal/i18n/locales/zh-CN.json | 3 ++ shortcuts/pr/pr.go | 52 ++++++++++++++++++++++++++++++++ shortcuts/pr/pr_test.go | 43 ++++++++++++++++++++++++++ 5 files changed, 109 insertions(+) diff --git a/README.md b/README.md index e5e4318..f12841c 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,14 @@ gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42 # View changed files gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42 +# List commits of a pull request +gitlink-cli pr +commits --owner Gitlink --repo forgeplus -i 42 + +# Pre-flight: can a merge request be created between two branches? +gitlink-cli pr +check-merge --owner Gitlink --repo forgeplus --head develop --base master +# Cross-fork variant +gitlink-cli pr +check-merge --owner Gitlink --repo forgeplus --head feat/x --base master --fork-project-id 12345 + # List PR patchset versions gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..825ed11 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -52,8 +52,10 @@ "cmd.org.list.short": "List organizations", "cmd.org.members.short": "List organization members", "cmd.org.short": "Organization operations", + "cmd.pr.check_merge.short": "Pre-flight check whether a merge request can be created between two branches", "cmd.pr.close.short": "Close a pull request", "cmd.pr.comment.short": "Add a comment to a pull request", + "cmd.pr.commits.short": "List commits of a pull request", "cmd.pr.create.short": "Create a pull request", "cmd.pr.diff.short": "Show diff for a pull request", "cmd.pr.files.short": "List changed files in a pull request", @@ -182,6 +184,7 @@ "flag.pr.base": "Target branch", "flag.pr.body": "PR description", "flag.pr.file": "Filter diff by file path", + "flag.pr.fork_project_id": "Fork project ID for cross-fork merge checks", "flag.pr.head": "Source branch", "flag.pr.id": "PR number", "flag.pr.merge_method": "Merge method: merge, rebase, squash", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..4921aa9 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -52,8 +52,10 @@ "cmd.org.list.short": "列出组织", "cmd.org.members.short": "列出组织成员", "cmd.org.short": "组织操作", + "cmd.pr.check_merge.short": "预检两个分支之间能否创建合并请求", "cmd.pr.close.short": "关闭拉取请求", "cmd.pr.comment.short": "给拉取请求添加评论", + "cmd.pr.commits.short": "列出合并请求的提交", "cmd.pr.create.short": "创建拉取请求", "cmd.pr.diff.short": "显示拉取请求 diff", "cmd.pr.files.short": "列出拉取请求中的变更文件", @@ -182,6 +184,7 @@ "flag.pr.base": "目标分支", "flag.pr.body": "PR 描述", "flag.pr.file": "按文件路径筛选 diff", + "flag.pr.fork_project_id": "跨 fork 合并检查用的 fork 项目 ID", "flag.pr.head": "源分支", "flag.pr.id": "PR 编号", "flag.pr.merge_method": "合并方式:merge、rebase、squash", diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 03f537f..cf986d9 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -3,6 +3,7 @@ package pr import ( "fmt" "net/url" + "strconv" "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" @@ -227,6 +228,57 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "commits", + Description: tr.T("cmd.pr.commits.short"), + 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, _ := ctx.RequireArg("id") + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/commits", ctx.RepoPath(), id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "check-merge", + Description: tr.T("cmd.pr.check_merge.short"), + Flags: []common.Flag{ + {Name: "head", Usage: tr.T("flag.pr.head"), Required: true}, + {Name: "base", Usage: tr.T("flag.pr.base"), Required: true}, + {Name: "fork-project-id", Usage: tr.T("flag.pr.fork_project_id")}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + head, _ := ctx.RequireArg("head") + base, _ := ctx.RequireArg("base") + payload := map[string]interface{}{ + "head": head, + "base": base, + } + if forkID := ctx.Arg("fork-project-id"); forkID != "" { + id, err := strconv.Atoi(forkID) + if err != nil { + return fmt.Errorf("--fork-project-id must be an integer, got %q", forkID) + } + payload["fork_project_id"] = id + payload["is_original"] = true + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls/check_can_merge", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "diff", Description: tr.T("cmd.pr.diff.short"), diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index eece6d9..414dbd0 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -531,3 +531,46 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) { t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) } } + +func TestPRCommitsUsesCommitsEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/pulls/355/commits.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"commits_count": float64(1), "commits": []interface{}{}}) + })) + defer server.Close() + + if err := runPRShortcut(t, server, "commits", map[string]string{"id": "355"}); err != nil { + t.Fatalf("commits failed: %v", err) + } +} + +func TestPRCheckMergePostsBranchesAndForkTuple(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/owner/repo/pulls/check_can_merge.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": float64(0), "message": "可以合并"}) + })) + defer server.Close() + + err := runPRShortcut(t, server, "check-merge", map[string]string{ + "head": "feat/x", "base": "master", "fork-project-id": "1549132", + }) + if err != nil { + t.Fatalf("check-merge failed: %v", err) + } + assertEqual(t, payload["head"], "feat/x") + assertEqual(t, payload["base"], "master") + assertEqual(t, payload["fork_project_id"], float64(1549132)) + assertEqual(t, payload["is_original"], true) + + if err := runPRShortcut(t, server, "check-merge", map[string]string{ + "head": "a", "base": "b", "fork-project-id": "abc", + }); err == nil { + t.Fatal("expected error for non-integer --fork-project-id") + } +}