diff --git a/README.md b/README.md index eed6478..cd3652d 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,9 @@ 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 +# Fetch and checkout a PR branch locally +gitlink-cli pr +checkout --owner Gitlink --repo forgeplus -i 42 --branch review/pr-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 7336d68..3f75523 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -368,6 +368,9 @@ gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 新功能" --h # 查看 PR gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42 +# 拉取并切换到 PR 分支 +gitlink-cli pr +checkout --owner Gitlink --repo forgeplus -i 42 --branch review/pr-42 + # 合并 PR gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42 diff --git a/doc/changes/pr-checkout-shortcut.md b/doc/changes/pr-checkout-shortcut.md new file mode 100644 index 0000000..83b9331 --- /dev/null +++ b/doc/changes/pr-checkout-shortcut.md @@ -0,0 +1,7 @@ +# PR 本地检出命令 + +新增 `gitlink-cli pr +checkout`,用于把指定 PR 的源分支拉取到本地并切换到 review 分支。维护者和贡献者在审查 PR 时,经常需要从“查看 PR 元信息”进入“本地构建、测试、复现”的流程,之前只能手动查看 fork 信息、拼接远端地址、fetch 分支,再 checkout;新命令把这套流程收敛为一个 Shortcut。 + +命令会读取 GitLink PR 详情,自动识别同仓库 PR 与 fork PR 的源仓库和源分支,不依赖本地 remote 必须叫 `origin`。默认创建 `pr-` 本地分支,也支持 `--branch` 指定分支名、`--force` 重置已存在分支、`--dry-run` 预览将执行的 git 命令。 + +本次变更补充了单元测试、README 示例、PR Skill 说明和独立参考文档,覆盖 fork PR 解析、同仓库 head 解析、dry-run、force checkout 以及非法分支名校验。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 55ff7b7..95605c8 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -41,6 +41,8 @@ "cmd.org.list.short": "List organizations", "cmd.org.members.short": "List organization members", "cmd.org.short": "Organization operations", + "cmd.pr.checkout.long": "Fetch a pull request head branch and check it out locally.\n\nThe command reads PR source repository information from GitLink, so it works for both same-repository PRs and fork PRs without requiring a local remote named origin.\n\nExamples:\n gitlink-cli pr +checkout -i 42\n gitlink-cli pr +checkout -i 42 --branch review/pr-42\n gitlink-cli pr +checkout -i 42 --force", + "cmd.pr.checkout.short": "Fetch and checkout a pull request 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", @@ -89,13 +91,13 @@ "error.config.save_failed": "failed to save config: {message}", "error.missing_required_flag": "required flag --{name} is missing", "error.unsupported_language": "unsupported language: {lang}", - "flag.api.body": "Request body (JSON string)", - "flag.api.body_file": "Read request body JSON from a file", - "flag.api.body_stdin": "Read request body JSON from stdin", "flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure", "flag.api.batch_dry_run": "Preview batch requests without sending remote requests", "flag.api.batch_file": "Read an API batch plan from a JSON file", "flag.api.batch_var": "Override a batch template variable (key=value, repeatable)", + "flag.api.body": "Request body (JSON string)", + "flag.api.body_file": "Read request body JSON from a file", + "flag.api.body_stdin": "Read request body JSON from stdin", "flag.api.header": "Additional headers (key:value)", "flag.api.query": "Query parameters (key=val&key2=val2)", "flag.auth.token": "Login by pasting an existing token", @@ -112,6 +114,8 @@ "flag.format": "Output format: json, table, yaml (default: table)", "flag.issue.add_label": "Label to add to each matching issue", "flag.issue.assignee": "Assignee login", + "flag.issue.assignee_id": "Assignee user ID", + "flag.issue.author_id": "Author user ID", "flag.issue.batch.reason": "Optional reason shown in the batch result", "flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.", "flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7", @@ -119,8 +123,6 @@ "flag.issue.batch_label.state": "Filter by issue state", "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", - "flag.issue.assignee_id": "Assignee user ID", - "flag.issue.author_id": "Author user ID", "flag.issue.body": "Issue description", "flag.issue.label": "Label ID", "flag.issue.label_filter": "Filter by existing label", @@ -145,17 +147,20 @@ "flag.pr.assignee_id": "Assignee user ID", "flag.pr.base": "Target branch", "flag.pr.body": "PR description", + "flag.pr.checkout_branch": "Local branch name to create (default: pr-)", + "flag.pr.checkout_dry_run": "Print the git commands without modifying the local repository", + "flag.pr.checkout_force": "Reset the local branch if it already exists", "flag.pr.file": "Filter diff by file path", "flag.pr.head": "Source branch", "flag.pr.id": "PR number", - "flag.pr.milestone_id": "Milestone ID", "flag.pr.merge_method": "Merge method: merge, rebase, squash", + "flag.pr.milestone_id": "Milestone ID", "flag.pr.priority_id": "Priority ID", "flag.pr.review_commit": "Commit SHA to attach the review to", "flag.pr.review_content": "Review content", - "flag.pr.reviewer_id": "Reviewer user ID", "flag.pr.review_status": "Review status: common, approved, rejected", "flag.pr.review_status_filter": "Filter review status: common, approved, rejected", + "flag.pr.reviewer_id": "Reviewer user ID", "flag.pr.state": "Filter: open, merged, closed", "flag.pr.tag_id": "Issue tag ID", "flag.pr.title": "PR title", @@ -191,7 +196,6 @@ "output.auth.login_hint": " Run: gitlink-cli auth login", "output.config.file": "Config file: {path}", "output.config.not_set": "(not set)", - "output.version": "gitlink-cli {version}", "output.doctor.api_auth.config_skipped": "API authentication check skipped because the configuration file is invalid.", "output.doctor.api_auth.failed": "Authenticated API request failed: {message}", "output.doctor.api_auth.no_login": "Authenticated API response did not include a login field.", @@ -214,6 +218,7 @@ "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.version": "gitlink-cli {version}", "prompt.auth.password": "Password: ", "prompt.auth.token": "Paste your access token: ", "prompt.auth.username": "Username/Email/Phone: ", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 1409414..929db0b 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -41,6 +41,8 @@ "cmd.org.list.short": "列出组织", "cmd.org.members.short": "列出组织成员", "cmd.org.short": "组织操作", + "cmd.pr.checkout.long": "拉取拉取请求的源分支并切换到本地分支。\n\n命令会从 GitLink 读取 PR 的源仓库信息,因此同仓库 PR 和 fork PR 都可以使用,不依赖本地 remote 必须命名为 origin。\n\n示例:\n gitlink-cli pr +checkout -i 42\n gitlink-cli pr +checkout -i 42 --branch review/pr-42\n gitlink-cli pr +checkout -i 42 --force", + "cmd.pr.checkout.short": "拉取并切换到拉取请求分支", "cmd.pr.close.short": "关闭拉取请求", "cmd.pr.comment.short": "给拉取请求添加评论", "cmd.pr.create.short": "创建拉取请求", @@ -89,13 +91,13 @@ "error.config.save_failed": "保存配置失败:{message}", "error.missing_required_flag": "缺少必需参数 --{name}", "error.unsupported_language": "不支持的语言:{lang}", - "flag.api.body": "请求体(JSON 字符串)", - "flag.api.body_file": "从文件读取 JSON 请求体", - "flag.api.body_stdin": "从标准输入读取 JSON 请求体", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", "flag.api.batch_dry_run": "预览批处理请求,不发送远端请求", "flag.api.batch_file": "从 JSON 文件读取 API 批处理计划", "flag.api.batch_var": "覆盖批处理模板变量(key=value,可重复)", + "flag.api.body": "请求体(JSON 字符串)", + "flag.api.body_file": "从文件读取 JSON 请求体", + "flag.api.body_stdin": "从标准输入读取 JSON 请求体", "flag.api.header": "附加请求头(key:value)", "flag.api.query": "查询参数(key=val&key2=val2)", "flag.auth.token": "通过粘贴已有 Token 登录", @@ -112,6 +114,8 @@ "flag.format": "输出格式:json、table、yaml(默认:table)", "flag.issue.add_label": "要添加到每个匹配议题的标签", "flag.issue.assignee": "负责人登录名", + "flag.issue.assignee_id": "负责人用户 ID", + "flag.issue.author_id": "作者用户 ID", "flag.issue.batch.reason": "批量结果中显示的可选原因", "flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。", "flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7", @@ -119,8 +123,6 @@ "flag.issue.batch_label.state": "按议题状态筛选", "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", - "flag.issue.assignee_id": "负责人用户 ID", - "flag.issue.author_id": "作者用户 ID", "flag.issue.body": "议题描述", "flag.issue.label": "标签 ID", "flag.issue.label_filter": "按已有标签筛选", @@ -145,17 +147,20 @@ "flag.pr.assignee_id": "指派人用户 ID", "flag.pr.base": "目标分支", "flag.pr.body": "PR 描述", + "flag.pr.checkout_branch": "要创建的本地分支名(默认:pr-)", + "flag.pr.checkout_dry_run": "只打印 git 命令,不修改本地仓库", + "flag.pr.checkout_force": "本地分支已存在时重置该分支", "flag.pr.file": "按文件路径筛选 diff", "flag.pr.head": "源分支", "flag.pr.id": "PR 编号", - "flag.pr.milestone_id": "里程碑 ID", "flag.pr.merge_method": "合并方式:merge、rebase、squash", + "flag.pr.milestone_id": "里程碑 ID", "flag.pr.priority_id": "优先级 ID", "flag.pr.review_commit": "关联评审的 Commit SHA", "flag.pr.review_content": "评审内容", - "flag.pr.reviewer_id": "评审人用户 ID", "flag.pr.review_status": "评审状态:common、approved、rejected", "flag.pr.review_status_filter": "按评审状态筛选:common、approved、rejected", + "flag.pr.reviewer_id": "评审人用户 ID", "flag.pr.state": "筛选:open、merged、closed", "flag.pr.tag_id": "议题标签 ID", "flag.pr.title": "PR 标题", @@ -191,7 +196,6 @@ "output.auth.login_hint": " 运行:gitlink-cli auth login", "output.config.file": "配置文件:{path}", "output.config.not_set": "(未设置)", - "output.version": "gitlink-cli {version}", "output.doctor.api_auth.config_skipped": "配置文件无效,已跳过 API 认证检查。", "output.doctor.api_auth.failed": "认证 API 请求失败:{message}", "output.doctor.api_auth.no_login": "认证 API 响应中缺少 login 字段。", @@ -214,6 +218,7 @@ "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.version": "gitlink-cli {version}", "prompt.auth.password": "密码:", "prompt.auth.token": "粘贴你的访问 Token:", "prompt.auth.username": "用户名/邮箱/手机号:", diff --git a/shortcuts/pr/checkout.go b/shortcuts/pr/checkout.go new file mode 100644 index 0000000..7933f2e --- /dev/null +++ b/shortcuts/pr/checkout.go @@ -0,0 +1,205 @@ +package pr + +import ( + "fmt" + "net/url" + "os/exec" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type prCheckoutDetail struct { + ID string `json:"id"` + SourceOwner string `json:"source_owner"` + SourceRepo string `json:"source_repo"` + SourceBranch string `json:"source_branch"` + SourceURL string `json:"source_url"` +} + +type prCheckoutPlan struct { + PullRequest string `json:"pull_request"` + SourceOwner string `json:"source_owner"` + SourceRepo string `json:"source_repo"` + SourceBranch string `json:"source_branch"` + SourceURL string `json:"source_url"` + LocalBranch string `json:"local_branch"` + Force bool `json:"force"` + DryRun bool `json:"dry_run"` + Commands [][]string `json:"commands"` +} + +type gitCommandRunner func(args ...string) (string, error) + +var runGitCommand gitCommandRunner = defaultRunGitCommand + +func runCheckout(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", prV1Path(ctx, id), nil) + if err != nil { + return err + } + detail, err := extractPRCheckoutDetail(ctx, env, id) + if err != nil { + return err + } + localBranch := strings.TrimSpace(ctx.Arg("branch")) + if localBranch == "" { + localBranch = "pr-" + id + } + force := ctx.Arg("force") == "true" + dryRun := ctx.Arg("dry-run") == "true" + plan, err := buildPRCheckoutPlan(detail, localBranch, force, dryRun) + if err != nil { + return err + } + if dryRun { + return ctx.OutputData(plan) + } + for _, args := range plan.Commands { + if out, err := runGitCommand(args...); err != nil { + if strings.TrimSpace(out) != "" { + return fmt.Errorf("git %s failed: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(out)) + } + return fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err) + } + } + return ctx.OutputData(plan) +} + +func buildPRCheckoutPlan(detail prCheckoutDetail, localBranch string, force, dryRun bool) (prCheckoutPlan, error) { + if err := validateGitRef(localBranch, "local branch"); err != nil { + return prCheckoutPlan{}, err + } + if err := validateGitRef(detail.SourceBranch, "source branch"); err != nil { + return prCheckoutPlan{}, err + } + if detail.SourceURL == "" { + return prCheckoutPlan{}, fmt.Errorf("PR response missing source repository URL") + } + checkoutFlag := "-b" + if force { + checkoutFlag = "-B" + } + return prCheckoutPlan{ + PullRequest: detail.ID, + SourceOwner: detail.SourceOwner, + SourceRepo: detail.SourceRepo, + SourceBranch: detail.SourceBranch, + SourceURL: detail.SourceURL, + LocalBranch: localBranch, + Force: force, + DryRun: dryRun, + Commands: [][]string{ + {"fetch", "--no-tags", detail.SourceURL, detail.SourceBranch}, + {"checkout", checkoutFlag, localBranch, "FETCH_HEAD"}, + }, + }, nil +} + +func extractPRCheckoutDetail(ctx *common.RuntimeContext, env *output.Envelope, id string) (prCheckoutDetail, error) { + data, ok := env.Data.(map[string]interface{}) + if !ok { + return prCheckoutDetail{}, fmt.Errorf("unexpected PR response format") + } + head := firstStringField(data, "head", "pull_request_head", "source_branch") + if head == "" { + if pr, ok := data["pull_request"].(map[string]interface{}); ok { + head = firstStringField(pr, "head", "pull_request_head", "source_branch") + } + } + sourceOwner, sourceRepo, sourceBranch := parsePRHeadForCheckout(head) + fork, _ := data["fork_project"].(map[string]interface{}) + if login := stringField(fork, "login"); login != "" { + sourceOwner = login + } + if identifier := stringField(fork, "identifier"); identifier != "" { + sourceRepo = identifier + } + if sourceOwner == "" { + sourceOwner = ctx.Owner + } + if sourceRepo == "" { + sourceRepo = ctx.Repo + } + if sourceBranch == "" { + return prCheckoutDetail{}, fmt.Errorf("PR response missing head branch") + } + return prCheckoutDetail{ + ID: id, + SourceOwner: sourceOwner, + SourceRepo: sourceRepo, + SourceBranch: sourceBranch, + SourceURL: gitlinkRepoURL(ctx.Client.BaseURL, sourceOwner, sourceRepo), + }, nil +} + +func parsePRHeadForCheckout(head string) (owner, repo, branch string) { + head = strings.TrimSpace(head) + if head == "" { + return "", "", "" + } + left, right, hasOwner := strings.Cut(head, ":") + if !hasOwner { + return "", "", head + } + branch = strings.TrimSpace(right) + parts := strings.Split(strings.Trim(left, "/"), "/") + if len(parts) >= 2 { + return parts[0], parts[1], branch + } + if len(parts) == 1 { + return parts[0], "", branch + } + return "", "", branch +} + +func gitlinkRepoURL(apiBaseURL, owner, repo string) string { + base := strings.TrimRight(apiBaseURL, "/") + for _, suffix := range []string{"/api/v1", "/api"} { + if strings.HasSuffix(base, suffix) { + base = strings.TrimSuffix(base, suffix) + break + } + } + if base == "" { + base = "https://www.gitlink.org.cn" + } + return fmt.Sprintf("%s/%s/%s.git", base, url.PathEscape(owner), url.PathEscape(repo)) +} + +func validateGitRef(ref, label string) error { + ref = strings.TrimSpace(ref) + if ref == "" { + return fmt.Errorf("%s cannot be empty", label) + } + if strings.HasPrefix(ref, "-") { + return fmt.Errorf("%s cannot start with '-'", label) + } + if strings.ContainsAny(ref, "\x00\r\n") { + return fmt.Errorf("%s contains unsupported control characters", label) + } + return nil +} + +func firstStringField(m map[string]interface{}, keys ...string) string { + for _, key := range keys { + if v := stringField(m, key); v != "" { + return v + } + } + return "" +} + +func defaultRunGitCommand(args ...string) (string, error) { + cmd := exec.Command("git", args...) + out, err := cmd.CombinedOutput() + return string(out), err +} diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 03f537f..84868dc 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -144,6 +144,18 @@ 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")}, + {Name: "force", Short: "f", Usage: tr.T("flag.pr.checkout_force"), Bool: true, Default: "false"}, + {Name: "dry-run", Usage: tr.T("flag.pr.checkout_dry_run"), Bool: true, Default: "false"}, + }, + Run: runCheckout, + }, { 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..b9a8ef6 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -249,6 +249,143 @@ func TestPRView(t *testing.T) { } } +// --- checkout --- + +func TestPRCheckoutDryRunUsesForkSource(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + 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": "feature/gitlink-pr", + "fork_project": map[string]interface{}{ + "login": "contributor", + "identifier": "repo-fork", + }, + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "checkout", map[string]string{ + "id": "42", + "branch": "review/pr-42", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("checkout dry-run failed: %v", err) + } +} + +func TestPRCheckoutRunsFetchAndCheckout(t *testing.T) { + var calls [][]string + oldRunner := runGitCommand + runGitCommand = func(args ...string) (string, error) { + calls = append(calls, append([]string(nil), args...)) + return "", nil + } + defer func() { runGitCommand = oldRunner }() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/owner/repo/pulls/43.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "head": "contributor/repo:feature/pr-checkout", + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "checkout", map[string]string{ + "id": "43", + "branch": "review/pr-43", + }) + if err != nil { + t.Fatalf("checkout failed: %v", err) + } + if len(calls) != 2 { + t.Fatalf("expected 2 git calls, got %d: %#v", len(calls), calls) + } + assertStringSlice(t, calls[0], []string{"fetch", "--no-tags", server.URL + "/contributor/repo.git", "feature/pr-checkout"}) + assertStringSlice(t, calls[1], []string{"checkout", "-b", "review/pr-43", "FETCH_HEAD"}) +} + +func TestPRCheckoutForceUsesResetBranch(t *testing.T) { + plan, err := buildPRCheckoutPlan(prCheckoutDetail{ + ID: "44", + SourceOwner: "owner", + SourceRepo: "repo", + SourceBranch: "feature/x", + SourceURL: "https://www.gitlink.org.cn/owner/repo.git", + }, "review/pr-44", true, false) + if err != nil { + t.Fatalf("build plan failed: %v", err) + } + assertStringSlice(t, plan.Commands[1], []string{"checkout", "-B", "review/pr-44", "FETCH_HEAD"}) +} + +func TestPRCheckoutDefaultBranch(t *testing.T) { + plan, err := buildPRCheckoutPlan(prCheckoutDetail{ + ID: "45", + SourceOwner: "owner", + SourceRepo: "repo", + SourceBranch: "feature/default", + SourceURL: "https://www.gitlink.org.cn/owner/repo.git", + }, "pr-45", false, false) + if err != nil { + t.Fatalf("build plan failed: %v", err) + } + if plan.LocalBranch != "pr-45" { + t.Fatalf("local branch = %q, want pr-45", plan.LocalBranch) + } +} + +func TestPRCheckoutRejectsUnsafeBranch(t *testing.T) { + _, err := buildPRCheckoutPlan(prCheckoutDetail{ + ID: "46", + SourceOwner: "owner", + SourceRepo: "repo", + SourceBranch: "feature/x", + SourceURL: "https://www.gitlink.org.cn/owner/repo.git", + }, "-bad", false, false) + if err == nil { + t.Fatal("expected unsafe local branch error") + } +} + +func TestParsePRHeadForCheckout(t *testing.T) { + cases := []struct { + head string + wantOwner string + wantRepo string + wantBranch string + }{ + {head: "feature/x", wantBranch: "feature/x"}, + {head: "alice:feature/x", wantOwner: "alice", wantBranch: "feature/x"}, + {head: "alice/repo:feature/x", wantOwner: "alice", wantRepo: "repo", wantBranch: "feature/x"}, + } + for _, tc := range cases { + owner, repo, branch := parsePRHeadForCheckout(tc.head) + if owner != tc.wantOwner || repo != tc.wantRepo || branch != tc.wantBranch { + t.Fatalf("parse %q = (%q, %q, %q), want (%q, %q, %q)", + tc.head, owner, repo, branch, tc.wantOwner, tc.wantRepo, tc.wantBranch) + } + } +} + +func TestGitlinkRepoURLUsesConfiguredAPIBase(t *testing.T) { + got := gitlinkRepoURL("https://gitlink.example.com/api", "owner", "repo") + if got != "https://gitlink.example.com/owner/repo.git" { + t.Fatalf("url = %q", got) + } + got = gitlinkRepoURL("https://gitlink.example.com/api/v1", "owner", "repo") + if got != "https://gitlink.example.com/owner/repo.git" { + t.Fatalf("url with api/v1 = %q", got) + } +} + // --- merge --- func TestPRMerge(t *testing.T) { @@ -531,3 +668,15 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) { t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) } } + +func assertStringSlice(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} diff --git a/skills/README.md b/skills/README.md index c7f2a23..a36e9ff 100644 --- a/skills/README.md +++ b/skills/README.md @@ -136,7 +136,7 @@ skills/ | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | | **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like` | | **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` | -| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | +| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +checkout`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | | **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` | | **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | | **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` | diff --git a/skills/gitlink-pr/SKILL.md b/skills/gitlink-pr/SKILL.md index d00091d..97bddf5 100644 --- a/skills/gitlink-pr/SKILL.md +++ b/skills/gitlink-pr/SKILL.md @@ -22,6 +22,7 @@ metadata: | `pr +list` | PR 列表 | 否(公开项目) | | `pr +create` | 创建 PR | 是 | | `pr +view` | PR 详情 | 否(公开项目) | +| `pr +checkout` | 拉取并切换到 PR 分支 | 否(公开项目,本地需要 git) | | `pr +merge` | 合并 PR | 是 | | `pr +refuse` | 拒绝并关闭 PR | 是 | | `pr +reopen` | 重开已关闭的 PR | 是 | @@ -48,6 +49,10 @@ gitlink-cli pr +create --title "feat: 新增搜索功能" --head feature/search # 查看 PR 详情(使用 pull_request_number,即网页 URL 中的序号) gitlink-cli pr +view --id 3 +# 拉取 PR 源分支到本地 review 分支,适合本地跑测试或复现问题 +gitlink-cli pr +checkout --id 3 --branch review/pr-3 +gitlink-cli pr +checkout --id 3 --force + # 合并 PR(支持 merge/rebase/squash) gitlink-cli pr +merge --id 3 gitlink-cli pr +merge --id 3 --method squash diff --git a/skills/gitlink-pr/references/gitlink-pr-checkout.md b/skills/gitlink-pr/references/gitlink-pr-checkout.md new file mode 100644 index 0000000..3b3920f --- /dev/null +++ b/skills/gitlink-pr/references/gitlink-pr-checkout.md @@ -0,0 +1,36 @@ +# pr +checkout + +将指定 PR 的源分支拉取到本地,并切换到一个本地 review 分支。 + +```bash +gitlink-cli pr +checkout --id 3 +gitlink-cli pr +checkout -i 3 --branch review/pr-3 +gitlink-cli pr +checkout -i 3 --force +gitlink-cli pr +checkout -i 3 --dry-run +``` + +## 参数 + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--id` / `-i` | 是 | PR 序号,即网页 URL `/pulls/N` 中的数字 | +| `--branch` / `-b` | 否 | 本地分支名,默认 `pr-` | +| `--force` / `-f` | 否 | 本地分支已存在时使用 `git checkout -B` 重置 | +| `--dry-run` | 否 | 只输出将执行的 git 命令,不修改本地仓库 | + +## 行为 + +命令会先读取 PR 详情,解析源 fork、源仓库和源分支,然后执行: + +```bash +git fetch --no-tags +git checkout -b FETCH_HEAD +``` + +如果传入 `--force`,第二步会改为 `git checkout -B FETCH_HEAD`。 + +## 使用建议 + +- 审查 PR 前可以先执行 `pr +view` 和 `pr +files` 获取上下文,再用 `pr +checkout` 拉到本地跑测试。 +- fork PR 不需要手动添加 contributor remote,命令会从 PR 详情里解析源仓库地址。 +- 不确定会执行哪些本地 git 操作时,先加 `--dry-run`。