diff --git a/README.md b/README.md index e5e4318..5332662 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,10 @@ gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: New feature" - # View a PR gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42 +# Check out a pull request branch locally (mirrors `gh pr checkout`; run inside a git clone) +gitlink-cli pr +checkout --owner Gitlink --repo forgeplus -i 42 +gitlink-cli pr +checkout --owner Gitlink --repo forgeplus -i 42 -b review-42 + # Merge a PR gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42 diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..5a6a46a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -421,6 +421,10 @@ gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 新功能" --h # 查看 PR gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42 +# 在本地检出 PR 分支(对标 `gh pr checkout`;需在 git 克隆目录内执行) +gitlink-cli pr +checkout --owner Gitlink --repo forgeplus -i 42 +gitlink-cli pr +checkout --owner Gitlink --repo forgeplus -i 42 -b review-42 + # 合并 PR gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..ea67b56 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.checkout.long": "Fetch the head branch of a pull request into the current git repository and check it out, mirroring `gh pr checkout`. Fork pull requests are fetched from the fork clone URL automatically.", + "cmd.pr.checkout.short": "Check out a pull request branch locally", "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", @@ -112,6 +114,7 @@ "error.config.save_failed": "failed to save config: {message}", "error.dataset.delete_confirm": "dataset attachment deletion is destructive; run --dry-run first, then pass --yes to confirm", "error.missing_required_flag": "required flag --{name} is missing", + "error.pr.checkout_head_missing": "pull request response did not include a head branch; check the PR id", "error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login", "error.unsupported_language": "unsupported language: {lang}", "flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure", @@ -181,6 +184,7 @@ "flag.pr.assignee_id": "Assignee user ID", "flag.pr.base": "Target branch", "flag.pr.body": "PR description", + "flag.pr.checkout_branch": "Local branch name (defaults to the PR head branch)", "flag.pr.file": "Filter diff by file path", "flag.pr.head": "Source branch", "flag.pr.id": "PR number", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..6cf8d73 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.checkout.long": "把 PR 的 head 分支抓取到当前 git 仓库并检出,对标 `gh pr checkout`。来自 fork 的 PR 会自动从 fork 克隆地址抓取。", + "cmd.pr.checkout.short": "在本地检出 PR 分支", "cmd.pr.close.short": "关闭拉取请求", "cmd.pr.comment.short": "给拉取请求添加评论", "cmd.pr.create.short": "创建拉取请求", @@ -112,6 +114,7 @@ "error.config.save_failed": "保存配置失败:{message}", "error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认", "error.missing_required_flag": "缺少必需参数 --{name}", + "error.pr.checkout_head_missing": "PR 响应缺少 head 分支,请检查 PR id 是否正确", "error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", "error.unsupported_language": "不支持的语言:{lang}", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", @@ -181,6 +184,7 @@ "flag.pr.assignee_id": "指派人用户 ID", "flag.pr.base": "目标分支", "flag.pr.body": "PR 描述", + "flag.pr.checkout_branch": "本地分支名(默认使用 PR 的 head 分支名)", "flag.pr.file": "按文件路径筛选 diff", "flag.pr.head": "源分支", "flag.pr.id": "PR 编号", diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 03f537f..f352587 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -1,8 +1,11 @@ package pr import ( + "errors" "fmt" "net/url" + "os" + "os/exec" "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" @@ -144,6 +147,59 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "checkout", + Description: tr.T("cmd.pr.checkout.short"), + Long: tr.T("cmd.pr.checkout.long"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "branch", Short: "b", Usage: tr.T("flag.pr.checkout_branch")}, + }, + 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", v1RepoPath(ctx), id), nil) + if err != nil { + return err + } + data, _ := env.Data.(map[string]interface{}) + head, _ := data["head"].(string) + if head == "" { + return errors.New(tr.T("error.pr.checkout_head_missing")) + } + local := ctx.Arg("branch") + if local == "" { + local = head + } + remote := "origin" + if fork, ok := data["fork_project"].(map[string]interface{}); ok { + login, _ := fork["login"].(string) + identifier, _ := fork["identifier"].(string) + if login != "" && identifier != "" && login != ctx.Owner { + remote = fmt.Sprintf("https://gitlink.org.cn/%s/%s.git", login, identifier) + } + } + for _, args := range [][]string{ + {"fetch", remote, fmt.Sprintf("%s:%s", head, local)}, + {"checkout", local}, + } { + gitCmd := exec.Command("git", args...) + gitCmd.Stdout = os.Stderr + gitCmd.Stderr = os.Stderr + if err := gitCmd.Run(); err != nil { + return fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err) + } + } + return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ + "message": "checked out", + "pull": id, + "branch": local, + "remote": remote, + }, nil)) + }, + }, { Name: "merge", Description: tr.T("cmd.pr.merge.short"), diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index eece6d9..7321c14 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -5,6 +5,10 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -531,3 +535,60 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) { t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) } } + +func TestPRCheckoutFetchesHeadBranch(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + // Upstream repo with a PR head branch. + upstream := t.TempDir() + run := func(args ...string) { + t.Helper() + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v (%s)", args, err, out) + } + } + run("init", "-q", "--initial-branch=master", upstream) + run("-C", upstream, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "init") + run("-C", upstream, "branch", "feat/x") + + // Local clone where checkout happens. + local := filepath.Join(t.TempDir(), "local") + run("clone", "-q", upstream, local) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/owner/repo/pulls/42.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"head": "feat/x", "base": "master"}) + })) + defer server.Close() + + cwd, _ := os.Getwd() + if err := os.Chdir(local); err != nil { + t.Fatal(err) + } + defer os.Chdir(cwd) + + if err := runPRShortcut(t, server, "checkout", map[string]string{"id": "42"}); err != nil { + t.Fatalf("checkout shortcut failed: %v", err) + } + out, err := exec.Command("git", "-C", local, "branch", "--show-current").Output() + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(out)); got != "feat/x" { + t.Fatalf("current branch = %q, want feat/x", got) + } +} + +func TestPRCheckoutMissingHead(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]interface{}{"base": "master"}) + })) + defer server.Close() + + if err := runPRShortcut(t, server, "checkout", map[string]string{"id": "42"}); err == nil { + t.Fatal("expected error when head missing") + } +}