feat: 新增 PR 本地检出命令
This commit is contained in:
parent
7947d4dfd6
commit
f67f723f2a
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 以及非法分支名校验。
|
||||
|
|
@ -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: ",
|
||||
|
|
|
|||
|
|
@ -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": "用户名/邮箱/手机号:",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`。
|
||||
Loading…
Reference in New Issue