feat(repo): 增强仓库转移快捷命令安全保护

This commit is contained in:
Mengz 2026-06-12 11:07:25 +08:00
parent 10d88360d9
commit bcbcf3fb13
8 changed files with 98 additions and 39 deletions

View File

@ -251,8 +251,14 @@ gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus
# Preview a repository transfer without changing data
gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --dry-run
# Cancel a pending repository transfer
# Confirm and send a repository transfer request
gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --yes
# Preview canceling a pending repository transfer
gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run
# Confirm canceling a pending repository transfer
gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --yes
```
### Webhook Management

View File

@ -262,8 +262,14 @@ gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus
# 预览仓库转移请求,不修改线上数据
gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --dry-run
# 取消待处理的仓库转移
# 确认后发起仓库转移
gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --yes
# 预览取消待处理的仓库转移
gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run
# 确认后取消待处理的仓库转移
gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --yes
```
### Webhook 管理

View File

@ -8,6 +8,6 @@
这组命令覆盖仓库所有者侧的转移流程,和用户待办审批侧的接受/拒绝转移申请互补。维护者在迁移项目归属、把个人仓库移交给组织、或由 Agent 编排仓库治理流程时,不再需要手写 Raw API。
`repo +transfer``repo +transfer-cancel` 都支持 `--dry-run`,会输出将要请求的 method、path 和 payload不修改线上数据便于在执行高风险操作前确认目标仓库和目标所有者。
`repo +transfer``repo +transfer-cancel` 都支持 `--dry-run`,会输出将要请求的 method、path 和 payload不修改线上数据便于在执行高风险操作前确认目标仓库和目标所有者。真正执行时还要求显式传入 `--yes`,避免在脚本或误操作场景下直接改动仓库归属。
单元测试覆盖了 API method/path、转移 payload、缺少目标所有者校验以及 dry-run 不触发远端请求README、中文 README 和 `gitlink-repo` Skill 已同步补充示例。
单元测试覆盖了 API method/path、转移 payload、缺少目标所有者校验、缺少 `--yes` 时拒绝执行,以及 dry-run 不触发远端请求README、中文 README 和 `gitlink-repo` Skill 已同步补充示例。

View File

@ -65,6 +65,9 @@
"cmd.repo.info.short": "Show repository details",
"cmd.repo.list.short": "List repositories for a user or organization",
"cmd.repo.short": "Repository operations",
"cmd.repo.transfer.short": "Transfer a repository to another owner",
"cmd.repo.transfer_cancel.short": "Cancel a pending repository transfer",
"cmd.repo.transfer_orgs.short": "List organizations that can receive this repository",
"cmd.repo.tree.short": "List repository files and directories",
"cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.",
"cmd.root.short": "GitLink CLI - command-line tool for GitLink",
@ -90,13 +93,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",
@ -113,6 +116,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",
@ -120,8 +125,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",
@ -149,14 +152,14 @@
"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",
@ -173,6 +176,10 @@
"flag.repo.description": "Repository description",
"flag.repo.name": "Repository name",
"flag.repo.private": "Make repository private (true/false)",
"flag.repo.target_owner": "Target user or organization login",
"flag.repo.transfer_cancel_dry_run": "Preview the cancel request without changing repository transfer state",
"flag.repo.transfer_dry_run": "Preview the transfer request without changing repository ownership",
"flag.repo.transfer_yes": "Confirm and execute the repository transfer action",
"flag.repo.tree.path": "Directory path to list (default: repository root)",
"flag.repo.tree.ref": "Branch, tag, or commit ref",
"flag.search.keyword": "Search keyword",
@ -194,7 +201,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.",
@ -217,6 +223,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: ",
@ -228,11 +235,5 @@
"success.config.set": "✓ {key} = {value}",
"warning.auth.not_logged_in": "✗ Not logged in",
"warning.auth.token_unverified": "✓ Token stored (but cannot verify: {message})",
"warning.auth.user_unavailable": "✓ Token stored (user info unavailable)",
"cmd.repo.transfer_orgs.short": "List organizations that can receive this repository",
"cmd.repo.transfer.short": "Transfer a repository to another owner",
"cmd.repo.transfer_cancel.short": "Cancel a pending repository transfer",
"flag.repo.target_owner": "Target user or organization login",
"flag.repo.transfer_dry_run": "Preview the transfer request without changing repository ownership",
"flag.repo.transfer_cancel_dry_run": "Preview the cancel request without changing repository transfer state"
"warning.auth.user_unavailable": "✓ Token stored (user info unavailable)"
}

View File

@ -65,6 +65,9 @@
"cmd.repo.info.short": "显示仓库详情",
"cmd.repo.list.short": "列出用户或组织的仓库",
"cmd.repo.short": "仓库操作",
"cmd.repo.transfer.short": "将仓库转移给其他所有者",
"cmd.repo.transfer_cancel.short": "取消待处理的仓库转移",
"cmd.repo.transfer_orgs.short": "列出可接收该仓库的组织",
"cmd.repo.tree.short": "列出仓库文件和目录",
"cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。",
"cmd.root.short": "GitLink CLI - GitLink 命令行工具",
@ -90,13 +93,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 登录",
@ -113,6 +116,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",
@ -120,8 +125,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": "按已有标签筛选",
@ -149,14 +152,14 @@
"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 标题",
@ -173,6 +176,10 @@
"flag.repo.description": "仓库描述",
"flag.repo.name": "仓库名称",
"flag.repo.private": "设为私有仓库true/false",
"flag.repo.target_owner": "目标用户或组织登录名",
"flag.repo.transfer_cancel_dry_run": "预览取消请求,不修改仓库转移状态",
"flag.repo.transfer_dry_run": "预览转移请求,不修改仓库所有者",
"flag.repo.transfer_yes": "确认并执行仓库转移相关操作",
"flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)",
"flag.repo.tree.ref": "分支、标签或提交引用",
"flag.search.keyword": "搜索关键词",
@ -194,7 +201,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 字段。",
@ -217,6 +223,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": "用户名/邮箱/手机号:",
@ -228,11 +235,5 @@
"success.config.set": "✓ 已设置 {key} = {value}",
"warning.auth.not_logged_in": "✗ 未登录",
"warning.auth.token_unverified": "✓ Token 已保存(但无法验证:{message}",
"warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)",
"cmd.repo.transfer_orgs.short": "列出可接收该仓库的组织",
"cmd.repo.transfer.short": "将仓库转移给其他所有者",
"cmd.repo.transfer_cancel.short": "取消待处理的仓库转移",
"flag.repo.target_owner": "目标用户或组织登录名",
"flag.repo.transfer_dry_run": "预览转移请求,不修改仓库所有者",
"flag.repo.transfer_cancel_dry_run": "预览取消请求,不修改仓库转移状态"
"warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)"
}

View File

@ -260,6 +260,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
Flags: []common.Flag{
{Name: "target-owner", Usage: tr.T("flag.repo.target_owner"), Required: true},
{Name: "dry-run", Usage: tr.T("flag.repo.transfer_dry_run"), Bool: true, Default: "false"},
{Name: "yes", Usage: tr.T("flag.repo.transfer_yes"), Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -283,6 +284,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
"payload": payload,
})
}
if err := requireRepoTransferConfirmation(ctx, "transfer"); err != nil {
return err
}
env, err := ctx.CallAPI("POST", path, payload)
if err != nil {
return err
@ -295,6 +299,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
Description: tr.T("cmd.repo.transfer_cancel.short"),
Flags: []common.Flag{
{Name: "dry-run", Usage: tr.T("flag.repo.transfer_cancel_dry_run"), Bool: true, Default: "false"},
{Name: "yes", Usage: tr.T("flag.repo.transfer_yes"), Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -308,6 +313,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
"path": path,
})
}
if err := requireRepoTransferConfirmation(ctx, "transfer-cancel"); err != nil {
return err
}
env, err := ctx.CallAPI("POST", path, nil)
if err != nil {
return err
@ -340,6 +348,13 @@ func repoTransferPath(ctx *common.RuntimeContext, action string) string {
return fmt.Sprintf("%s/%s", base, action)
}
func requireRepoTransferConfirmation(ctx *common.RuntimeContext, shortcut string) error {
if ctx.Arg("yes") == "true" {
return nil
}
return fmt.Errorf("refusing to run repo +%s without --yes; use --dry-run to preview the request first", shortcut)
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]

View File

@ -456,7 +456,10 @@ func TestRepoTransfer(t *testing.T) {
}))
defer server.Close()
err := runShortcut(t, server, "transfer", map[string]string{"target-owner": " target-org "})
err := runShortcut(t, server, "transfer", map[string]string{
"target-owner": " target-org ",
"yes": "true",
})
if err != nil {
t.Fatalf("transfer failed: %v", err)
}
@ -478,6 +481,18 @@ func TestRepoTransferDryRunDoesNotCallAPI(t *testing.T) {
}
}
func TestRepoTransferRequiresExplicitYes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("transfer without --yes should not call API, got %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "transfer", map[string]string{"target-owner": "target-org"})
if err == nil {
t.Fatal("expected confirmation error for missing --yes")
}
}
func TestRepoTransferFailsWithoutTargetOwner(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call should be made")
@ -509,7 +524,7 @@ func TestRepoTransferCancel(t *testing.T) {
}))
defer server.Close()
if err := runShortcut(t, server, "transfer-cancel", nil); err != nil {
if err := runShortcut(t, server, "transfer-cancel", map[string]string{"yes": "true"}); err != nil {
t.Fatalf("transfer-cancel failed: %v", err)
}
}
@ -526,6 +541,18 @@ func TestRepoTransferCancelDryRunDoesNotCallAPI(t *testing.T) {
}
}
func TestRepoTransferCancelRequiresExplicitYes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("transfer-cancel without --yes should not call API, got %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "transfer-cancel", nil)
if err == nil {
t.Fatal("expected confirmation error for missing --yes")
}
}
func TestRepoCreate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {

View File

@ -88,11 +88,14 @@ gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus
gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --dry-run
# 确认后发起仓库转移
gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org
gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --yes
# 取消待处理的仓库转移
gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run
# 确认后取消待处理的仓库转移
gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --yes
# 删除仓库(⚠️ 危险操作)
gitlink-cli repo +delete --owner myuser --repo old-project
```
@ -115,6 +118,6 @@ gitlink-cli api GET /:owner/:repo/raw/main/README.md
## 注意事项
- `repo +delete` 是不可逆操作,执行前必须确认用户意图
- `repo +transfer` 会改变仓库所有者,执行前先使用 `repo +transfer-orgs` 确认可转移目标,并用 `--dry-run` 预览请求
- `repo +transfer-cancel` 只用于取消已发起且未处理的转移申请,建议先用 `--dry-run` 预览
- `repo +transfer` 会改变仓库所有者,只有显式传入 `--yes` 才会真正发起;执行前先使用 `repo +transfer-orgs` 确认可转移目标,并用 `--dry-run` 预览请求
- `repo +transfer-cancel` 只用于取消已发起且未处理的转移申请,只有显式传入 `--yes` 才会真正执行,建议先用 `--dry-run` 预览
- 创建仓库默认为公开,使用 `--private true` 创建私有仓库