From cc35687887cce3365478dcf5ffeadffeee72cd08 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Fri, 12 Jun 2026 11:07:25 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(repo):=20=E5=A2=9E=E5=BC=BA=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E8=BD=AC=E7=A7=BB=E5=BF=AB=E6=8D=B7=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 +++ README.zh-CN.md | 15 +++ doc/changes/repo-transfer-shortcuts.md | 13 +++ internal/i18n/locales/en-US.json | 7 ++ internal/i18n/locales/zh-CN.json | 7 ++ shortcuts/repo/repo.go | 98 ++++++++++++++++++++ shortcuts/repo/repo_test.go | 123 +++++++++++++++++++++++++ skills/gitlink-repo/SKILL.md | 18 ++++ 8 files changed, 296 insertions(+) create mode 100644 doc/changes/repo-transfer-shortcuts.md diff --git a/README.md b/README.md index e5e4318..5a2cb50 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..0cd4010 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -266,6 +266,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 管理 diff --git a/doc/changes/repo-transfer-shortcuts.md b/doc/changes/repo-transfer-shortcuts.md new file mode 100644 index 0000000..392d1aa --- /dev/null +++ b/doc/changes/repo-transfer-shortcuts.md @@ -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 已同步补充示例。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..354d779 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -87,6 +87,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", @@ -212,6 +215,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", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..9f6b78f 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -87,6 +87,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 命令行工具", @@ -212,6 +215,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": "搜索关键词", diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 06774a6..5a2aac1 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -239,6 +239,89 @@ 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"), @@ -256,6 +339,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] diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 5c5f34b..80da629 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -430,6 +430,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 { diff --git a/skills/gitlink-repo/SKILL.md b/skills/gitlink-repo/SKILL.md index 5d59705..0528cb1 100644 --- a/skills/gitlink-repo/SKILL.md +++ b/skills/gitlink-repo/SKILL.md @@ -36,6 +36,9 @@ metadata: | `repo +unlike` | 取消点赞仓库 | 是 | | `repo +create` | 创建仓库 | 是 | | `repo +fork` | Fork 仓库 | 是 | +| `repo +transfer-orgs` | 查看可接收仓库转移的组织 | 是 | +| `repo +transfer` | 发起仓库转移请求 | 是 | +| `repo +transfer-cancel` | 取消待处理的仓库转移 | 是 | | `repo +delete` | 删除仓库 | 是 | ## 使用示例 @@ -78,6 +81,19 @@ 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 ``` @@ -100,4 +116,6 @@ gitlink-cli api GET /:owner/:repo/raw/main/README.md ## 注意事项 - `repo +delete` 是不可逆操作,执行前必须确认用户意图 +- `repo +transfer` 会改变仓库所有者,只有显式传入 `--yes` 才会真正发起;执行前先使用 `repo +transfer-orgs` 确认可转移目标,并用 `--dry-run` 预览请求 +- `repo +transfer-cancel` 只用于取消已发起且未处理的转移申请,只有显式传入 `--yes` 才会真正执行,建议先用 `--dry-run` 预览 - 创建仓库默认为公开,使用 `--private true` 创建私有仓库 From ed9a35af5b292b6683df3e541e91a57aa6122e8f Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Wed, 27 May 2026 22:11:07 +0800 Subject: [PATCH 2/2] feat(repo): add repository units shortcuts --- README.md | 4 ++ README.zh-CN.md | 4 ++ doc/changes/repo-units-shortcut.md | 27 +++++++++++ internal/i18n/locales/en-US.json | 3 ++ internal/i18n/locales/zh-CN.json | 3 ++ shortcuts/repo/repo.go | 73 ++++++++++++++++++++++++++++++ shortcuts/repo/repo_test.go | 63 ++++++++++++++++++++++++++ skills/README.md | 2 +- skills/gitlink-repo/SKILL.md | 11 +++++ 9 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 doc/changes/repo-units-shortcut.md diff --git a/README.md b/README.md index 5a2cb50..f254b53 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,10 @@ gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123 gitlink-cli repo +like --owner Gitlink --repo forgeplus gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123 +# List and update repository navigation units +gitlink-cli repo +units --owner Gitlink --repo forgeplus +gitlink-cli repo +set-units --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki + # Create a repository gitlink-cli repo +create -n my-project -d "Project description" diff --git a/README.zh-CN.md b/README.zh-CN.md index 0cd4010..c710e2e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -261,6 +261,10 @@ gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123 gitlink-cli repo +like --owner Gitlink --repo forgeplus gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123 +# 查看和更新仓库导航模块 +gitlink-cli repo +units --owner Gitlink --repo forgeplus +gitlink-cli repo +set-units --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki + # 创建仓库 gitlink-cli repo +create -n my-project -d "项目描述" diff --git a/doc/changes/repo-units-shortcut.md b/doc/changes/repo-units-shortcut.md new file mode 100644 index 0000000..779d32e --- /dev/null +++ b/doc/changes/repo-units-shortcut.md @@ -0,0 +1,27 @@ +# Repository Units Shortcut + +## Summary + +Adds repository navigation unit shortcuts for the GitLink project settings API. + +## Commands + +```bash +gitlink-cli repo +units --owner Gitlink --repo forgeplus +gitlink-cli repo +set-units --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki +``` + +## Behavior + +- `repo +units` calls `GET /{owner}/{repo}/project_units`. +- `repo +set-units` calls `POST /{owner}/{repo}/project_units` with `unit_types`. +- `--units` accepts a comma-separated list and validates values before making a request. +- Duplicate unit names are removed while preserving order. + +## Validation + +Allowed units are `code`, `issues`, `pulls`, `devops`, `versions`, `wiki`, `services`, and `resources`. + +## Tests + +- Unit tests cover the read endpoint, update request body, duplicate handling, and invalid input rejection. diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 354d779..089d02f 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -86,11 +86,13 @@ "cmd.repo.fork.short": "Fork a repository", "cmd.repo.info.short": "Show repository details", "cmd.repo.list.short": "List repositories for a user or organization", + "cmd.repo.set_units.short": "Set enabled repository navigation units", "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.repo.units.short": "List enabled repository navigation units", "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", "cmd.root.short": "GitLink CLI - command-line tool for GitLink", "cmd.search.repos.short": "Search repositories", @@ -221,6 +223,7 @@ "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.repo.units": "Comma-separated units: code,issues,pulls,devops,versions,wiki,services,resources", "flag.search.keyword": "Search keyword", "flag.sort_by": "Sort field", "flag.sort_direction": "Sort direction: asc, desc", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 9f6b78f..786a9a6 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -86,11 +86,13 @@ "cmd.repo.fork.short": "Fork 仓库", "cmd.repo.info.short": "显示仓库详情", "cmd.repo.list.short": "列出用户或组织的仓库", + "cmd.repo.set_units.short": "设置启用的仓库导航模块", "cmd.repo.short": "仓库操作", "cmd.repo.transfer.short": "将仓库转移给其他所有者", "cmd.repo.transfer_cancel.short": "取消待处理的仓库转移", "cmd.repo.transfer_orgs.short": "列出可接收该仓库的组织", "cmd.repo.tree.short": "列出仓库文件和目录", + "cmd.repo.units.short": "列出启用的仓库导航模块", "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", "cmd.root.short": "GitLink CLI - GitLink 命令行工具", "cmd.search.repos.short": "搜索仓库", @@ -221,6 +223,7 @@ "flag.repo.transfer_yes": "确认并执行仓库转移相关操作", "flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)", "flag.repo.tree.ref": "分支、标签或提交引用", + "flag.repo.units": "逗号分隔的模块:code,issues,pulls,devops,versions,wiki,services,resources", "flag.search.keyword": "搜索关键词", "flag.sort_by": "排序字段", "flag.sort_direction": "排序方向:asc、desc", diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 5a2aac1..d02f855 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -81,6 +81,41 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "units", + Description: tr.T("cmd.repo.units.short"), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", repoUnitsPath(ctx), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "set-units", + Description: tr.T("cmd.repo.set_units.short"), + Flags: []common.Flag{ + {Name: "units", Short: "u", Usage: tr.T("flag.repo.units"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + units, err := parseRepoUnits(ctx.Arg("units")) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", repoUnitsPath(ctx), map[string]interface{}{"unit_types": units}) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "tree", Description: tr.T("cmd.repo.tree.short"), @@ -598,3 +633,41 @@ func parseRepoPositiveInt(value, name string) (int, error) { } return parsed, nil } + +func repoUnitsPath(ctx *common.RuntimeContext) string { + return ctx.RepoPath() + "/project_units" +} + +func parseRepoUnits(raw string) ([]string, error) { + allowed := map[string]bool{ + "code": true, + "issues": true, + "pulls": true, + "devops": true, + "versions": true, + "wiki": true, + "services": true, + "resources": true, + } + + seen := map[string]bool{} + units := []string{} + for _, part := range strings.Split(raw, ",") { + unit := strings.ToLower(strings.TrimSpace(part)) + if unit == "" { + continue + } + if !allowed[unit] { + return nil, fmt.Errorf("invalid repository unit %q; allowed values: code,issues,pulls,devops,versions,wiki,services,resources", unit) + } + if seen[unit] { + continue + } + seen[unit] = true + units = append(units, unit) + } + if len(units) == 0 { + return nil, fmt.Errorf("at least one repository unit is required") + } + return units, nil +} diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 80da629..23cb09c 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "reflect" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -152,6 +153,68 @@ func TestRepoReadmeUsesRepositoryReadmeEndpoint(t *testing.T) { } } +func TestRepoUnitsUsesProjectUnitsEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/project_units.json") + writeJSON(t, w, []string{"code", "issues", "pulls"}) + })) + defer server.Close() + + if err := runShortcut(t, server, "units", nil); err != nil { + t.Fatalf("units shortcut failed: %v", err) + } +} + +func TestRepoSetUnitsSendsValidatedUnitTypes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/project_units.json") + var body struct { + UnitTypes []string `json:"unit_types"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + want := []string{"code", "issues", "pulls"} + if !reflect.DeepEqual(body.UnitTypes, want) { + t.Fatalf("unit_types = %#v, want %#v", body.UnitTypes, want) + } + writeJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "success", + }) + })) + defer server.Close() + + err := runShortcut(t, server, "set-units", map[string]string{ + "units": " code,issues,pulls,issues ", + }) + if err != nil { + t.Fatalf("set-units shortcut failed: %v", err) + } +} + +func TestRepoSetUnitsRejectsInvalidUnitBeforeRequest(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + defer server.Close() + + err := runShortcut(t, server, "set-units", map[string]string{"units": "code,invalid"}) + if err == nil { + t.Fatalf("expected invalid unit error") + } + if called { + t.Fatalf("server was called for invalid unit") + } +} + +func TestParseRepoUnitsRejectsEmptyInput(t *testing.T) { + if _, err := parseRepoUnits(" , "); err == nil { + t.Fatalf("expected empty unit list error") + } +} + func TestRepoTreeListsRootOnDefaultRef(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertRequest(t, r, "GET", "/owner/repo/sub_entries.json") diff --git a/skills/README.md b/skills/README.md index d507074..eaf1799 100644 --- a/skills/README.md +++ b/skills/README.md @@ -127,7 +127,7 @@ skills/ | Skill | 说明 | 常用命令 | |-------|------|----------| | **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-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like`, `repo +units`, `repo +set-units` | | **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete` | | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `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` | diff --git a/skills/gitlink-repo/SKILL.md b/skills/gitlink-repo/SKILL.md index 0528cb1..4006a2d 100644 --- a/skills/gitlink-repo/SKILL.md +++ b/skills/gitlink-repo/SKILL.md @@ -98,6 +98,17 @@ gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --yes gitlink-cli repo +delete --owner myuser --repo old-project ``` +## Repository Units + +Use these shortcuts to inspect or update the repository navigation modules shown in GitLink: + +```bash +gitlink-cli repo +units --owner Gitlink --repo forgeplus +gitlink-cli repo +set-units --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki +``` + +Allowed unit values: `code`, `issues`, `pulls`, `devops`, `versions`, `wiki`, `services`, `resources`. + ## Raw API 补充 Shortcuts 未覆盖的仓库操作可用 Raw API: