feat: 新增 PR 本地检出命令

This commit is contained in:
Mengz 2026-06-08 11:50:13 +08:00
parent 7947d4dfd6
commit f67f723f2a
11 changed files with 447 additions and 17 deletions

View File

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

View File

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

View File

@ -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-<id>` 本地分支,也支持 `--branch` 指定分支名、`--force` 重置已存在分支、`--dry-run` 预览将执行的 git 命令。
本次变更补充了单元测试、README 示例、PR Skill 说明和独立参考文档,覆盖 fork PR 解析、同仓库 head 解析、dry-run、force checkout 以及非法分支名校验。

View File

@ -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-<id>)",
"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: ",

View File

@ -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-<id>",
"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": "用户名/邮箱/手机号:",

205
shortcuts/pr/checkout.go Normal file
View File

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

View File

@ -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"),

View File

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

View File

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

View File

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

View File

@ -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-<id>` |
| `--force` / `-f` | 否 | 本地分支已存在时使用 `git checkout -B` 重置 |
| `--dry-run` | 否 | 只输出将执行的 git 命令,不修改本地仓库 |
## 行为
命令会先读取 PR 详情,解析源 fork、源仓库和源分支然后执行
```bash
git fetch --no-tags <source-url> <source-branch>
git checkout -b <local-branch> FETCH_HEAD
```
如果传入 `--force`,第二步会改为 `git checkout -B <local-branch> FETCH_HEAD`
## 使用建议
- 审查 PR 前可以先执行 `pr +view``pr +files` 获取上下文,再用 `pr +checkout` 拉到本地跑测试。
- fork PR 不需要手动添加 contributor remote命令会从 PR 详情里解析源仓库地址。
- 不确定会执行哪些本地 git 操作时,先加 `--dry-run`