Merge pull request '新增仓库转移快捷命令' (#119) from Mengz/gitlink-cli:mengz/repo-transfer-shortcuts into master

This commit is contained in:
wbtiger 2026-07-14 22:25:42 +08:00
commit d40deaaf23
8 changed files with 299 additions and 0 deletions

View File

@ -293,6 +293,21 @@ gitlink-cli repo +create -n my-project -d "Project description"
# Fork a repository
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
# List organizations that can receive a repository transfer
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
# 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

@ -299,6 +299,21 @@ gitlink-cli repo +create -n my-project -d "项目描述"
# Fork 仓库
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
# 列出可接收仓库转移的组织
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

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

View File

@ -102,6 +102,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",
@ -239,6 +242,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",

View File

@ -102,6 +102,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 命令行工具",
@ -239,6 +242,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": "搜索关键词",

View File

@ -383,6 +383,90 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "transfer-orgs",
Description: tr.T("cmd.repo.transfer_orgs.short"),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", repoTransferPath(ctx, "organizations"), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "transfer",
Description: tr.T("cmd.repo.transfer.short"),
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 {
return err
}
targetOwner, err := ctx.RequireArg("target-owner")
if err != nil {
return err
}
targetOwner = strings.TrimSpace(targetOwner)
if targetOwner == "" {
return fmt.Errorf("required flag --target-owner is missing")
}
payload := map[string]interface{}{"owner_name": targetOwner}
path := repoTransferPath(ctx, "")
if ctx.Arg("dry-run") == "true" {
return ctx.OutputData(map[string]interface{}{
"dry_run": true,
"method": "POST",
"path": path,
"payload": payload,
})
}
if err := requireRepoTransferConfirmation(ctx, "transfer"); err != nil {
return err
}
env, err := ctx.CallAPI("POST", path, payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "transfer-cancel",
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 {
return err
}
path := repoTransferPath(ctx, "cancel")
if ctx.Arg("dry-run") == "true" {
return ctx.OutputData(map[string]interface{}{
"dry_run": true,
"method": "POST",
"path": path,
})
}
if err := requireRepoTransferConfirmation(ctx, "transfer-cancel"); err != nil {
return err
}
env, err := ctx.CallAPI("POST", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: tr.T("cmd.repo.delete.short"),
@ -400,6 +484,21 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
}
}
func repoTransferPath(ctx *common.RuntimeContext, action string) string {
base := ctx.RepoPath() + "/applied_transfer_projects"
if action == "" {
return base
}
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

@ -515,6 +515,129 @@ func TestRepoDelete(t *testing.T) {
}
}
func TestRepoTransferOrgs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/owner/repo/applied_transfer_projects/organizations.json")
writeJSON(t, w, map[string]interface{}{
"total_count": float64(1),
"organizations": []interface{}{map[string]interface{}{"name": "target-org"}},
})
}))
defer server.Close()
if err := runShortcut(t, server, "transfer-orgs", nil); err != nil {
t.Fatalf("transfer-orgs failed: %v", err)
}
}
func TestRepoTransfer(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/owner/repo/applied_transfer_projects.json")
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
writeJSON(t, w, map[string]interface{}{"status": "common"})
}))
defer server.Close()
err := runShortcut(t, server, "transfer", map[string]string{
"target-owner": " target-org ",
"yes": "true",
})
if err != nil {
t.Fatalf("transfer failed: %v", err)
}
assertEqual(t, body["owner_name"], "target-org")
}
func TestRepoTransferDryRunDoesNotCallAPI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run 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",
"dry-run": "true",
})
if err != nil {
t.Fatalf("transfer dry-run failed: %v", err)
}
}
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")
}))
defer server.Close()
err := runShortcut(t, server, "transfer", map[string]string{})
if err == nil {
t.Fatal("expected error for missing target owner")
}
}
func TestRepoTransferRejectsBlankTargetOwner(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call should be made")
}))
defer server.Close()
err := runShortcut(t, server, "transfer", map[string]string{"target-owner": " "})
if err == nil {
t.Fatal("expected error for blank target owner")
}
}
func TestRepoTransferCancel(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/owner/repo/applied_transfer_projects/cancel.json")
writeJSON(t, w, map[string]interface{}{"status": "canceled"})
}))
defer server.Close()
if err := runShortcut(t, server, "transfer-cancel", map[string]string{"yes": "true"}); err != nil {
t.Fatalf("transfer-cancel failed: %v", err)
}
}
func TestRepoTransferCancelDryRunDoesNotCallAPI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "transfer-cancel", map[string]string{"dry-run": "true"})
if err != nil {
t.Fatalf("transfer-cancel dry-run failed: %v", err)
}
}
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

@ -38,6 +38,9 @@ metadata:
| `repo +create` | 创建仓库 | 是 |
| `repo +fork` | Fork 仓库 | 是 |
| `repo +delete` | 删除仓库 | 是 |
| `repo +transfer-orgs` | 列出可接收仓库转移的组织 | 是 |
| `repo +transfer` | 发起仓库转移 | 是 |
| `repo +transfer-cancel` | 取消待处理的仓库转移 | 是 |
## 使用示例
@ -81,6 +84,21 @@ gitlink-cli repo +create --name my-project --description "项目描述"
# Fork 仓库
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
# 查看仓库可转移到哪些组织
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
# 删除仓库(⚠️ 危险操作)
gitlink-cli repo +delete --owner myuser --repo old-project
```
@ -106,5 +124,7 @@ gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=README.md&ref=ma
## 注意事项
- `repo +delete` 是不可逆操作,执行前必须确认用户意图
- `repo +transfer` 会改变仓库所有者,只有显式传入 `--yes` 才会真正发起;执行前先使用 `repo +transfer-orgs` 确认可转移目标,并用 `--dry-run` 预览请求
- `repo +transfer-cancel` 只用于取消已发起且未处理的转移申请,只有显式传入 `--yes` 才会真正执行,建议先用 `--dry-run` 预览
- 创建仓库默认为公开,使用 `--private true` 创建私有仓库
- `repo +file` 只接受文件路径;如果目标是目录,请改用 `repo +tree`