diff --git a/doc/changes/repo-rename.md b/doc/changes/repo-rename.md new file mode 100644 index 0000000..5539139 --- /dev/null +++ b/doc/changes/repo-rename.md @@ -0,0 +1,66 @@ +# repo +rename 仓库重命名命令 + +## 背景 + +`gitlink-cli repo` 已提供创建、Fork、删除等仓库管理能力,但缺少对标 `gh repo rename` 的重命名命令。用户或 AI Agent 想改名当前仓库时,过去只能手动调用 PATCH 更新项目接口。 + +本次变更把重命名封装为 `repo +rename`,与 GitHub CLI 的 `gh repo rename` 语义对齐。 + +## 变更内容 + +- 新增 `gitlink-cli repo +rename` Shortcut。 +- 调用 `PATCH /{owner}/{repo}` 更新项目,请求体同时设置 `name`(项目名称)与 `identifier`(项目标识)。 +- 支持 `--name, -n` 指定新名称,必填;空值或纯空白会在调用接口前被拒绝。 +- 复用现有仓库上下文解析、API 调用和统一输出格式。 +- 补充中英文 i18n 文案,避免命令帮助信息硬编码。 + +## 命令示例 + +```bash +# 重命名当前仓库(owner/repo 从 git remote 自动解析) +gitlink-cli repo +rename --name new-name + +# 显式指定仓库 +gitlink-cli repo +rename --owner Gitlink --repo forgeplus --name new-name +``` + +## 参数说明 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name, -n` | 是 | 新的仓库名称与标识 | +| `--owner` | 否 | 全局参数,仓库所有者,可从 git remote 自动解析 | +| `--repo` | 否 | 全局参数,仓库名称,可从 git remote 自动解析 | +| `--format` | 否 | 全局参数,输出格式:`json`、`table` 或 `yaml` | + +## API 映射 + +| Shortcut | Method | API path | 请求体字段 | +|----------|--------|----------|-----------| +| `repo +rename` | PATCH | `/api/{owner}/{repo}.json` | `name`、`identifier` | + +参照 API 文档「PATCH 更新项目」与「PATCH 更新项目(完整)」,`name` 与 `identifier` 均为必填。`name` 是展示用项目名称,`identifier` 是 URL 标识(等价于 `gh repo rename` 改动的仓库 URL 标识),两者都从 `--name` 取值。 + +## 兼容性提示 + +重命名会同时修改 `identifier`,也就是仓库的 URL 标识,因此**克隆地址会随之变化**(与 `gh repo rename` 行为一致)。执行后本地已有的 remote 需要相应更新。 + +## 测试覆盖 + +- 表驱动单元测试断言请求方法为 `PATCH`、路径为 `/owner/repo.json`,且请求体 `name` 与 `identifier` 均为新名称。 +- 覆盖前后空白被裁剪的场景。 +- 缺失或纯空白的 `--name` 在调用接口前即返回校验错误,不触发任何 HTTP 请求。 + +验证命令: + +```bash +go build ./... +go test ./shortcuts/repo/ +``` + +## 交付要求核对 + +- 功能代码:`shortcuts/repo/repo.go` +- 单元测试:`shortcuts/repo/repo_test.go` +- i18n 文案:`internal/i18n/locales/en-US.json`、`internal/i18n/locales/zh-CN.json` +- 变更说明文档:`doc/changes/repo-rename.md` diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..6954333 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -86,6 +86,7 @@ "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.rename.short": "Rename the current repository", "cmd.repo.short": "Repository operations", "cmd.repo.tree.short": "List repository files and directories", "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", @@ -212,6 +213,7 @@ "flag.repo.description": "Repository description", "flag.repo.name": "Repository name", "flag.repo.private": "Make repository private (true/false)", + "flag.repo.rename.name": "New repository name and identifier (changes the remote URL)", "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..da780c6 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -86,6 +86,7 @@ "cmd.repo.fork.short": "Fork 仓库", "cmd.repo.info.short": "显示仓库详情", "cmd.repo.list.short": "列出用户或组织的仓库", + "cmd.repo.rename.short": "重命名当前仓库", "cmd.repo.short": "仓库操作", "cmd.repo.tree.short": "列出仓库文件和目录", "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", @@ -212,6 +213,7 @@ "flag.repo.description": "仓库描述", "flag.repo.name": "仓库名称", "flag.repo.private": "设为私有仓库(true/false)", + "flag.repo.rename.name": "新的仓库名称与标识(会改变远程 URL)", "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..faec761 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -225,6 +225,14 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "rename", + Description: tr.T("cmd.repo.rename.short"), + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: tr.T("flag.repo.rename.name"), Required: true}, + }, + Run: runRename, + }, { Name: "fork", Description: tr.T("cmd.repo.fork.short"), @@ -263,6 +271,31 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { return i18n.Default() } +func runRename(ctx *common.RuntimeContext) error { + if _, err := ctx.RequireArg("name"); err != nil { + return err + } + name := strings.TrimSpace(ctx.Arg("name")) + if name == "" { + return fmt.Errorf("invalid --name: repository name must not be empty") + } + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + // The update endpoint requires both the display name and the identifier; the + // identifier is the URL slug, so renaming it also changes the repository's + // remote clone URL, mirroring `gh repo rename`. + body := map[string]interface{}{ + "name": name, + "identifier": name, + } + env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body) + if err != nil { + return err + } + return ctx.Output(env) +} + func runLanguages(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 5c5f34b..0390178 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -488,6 +488,58 @@ func TestRepoCreateWithOptions(t *testing.T) { } } +// --- rename --- + +func TestRepoRename(t *testing.T) { + cases := []struct { + name string + arg string + wantName string + }{ + {name: "simple name", arg: "new-repo", wantName: "new-repo"}, + {name: "trims surrounding whitespace", arg: " renamed ", wantName: "renamed"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var body map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "PATCH", "/owner/repo.json") + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + writeJSON(t, w, map[string]interface{}{ + "id": float64(21), + "name": tc.wantName, + "identifier": tc.wantName, + }) + })) + defer server.Close() + + if err := runShortcut(t, server, "rename", map[string]string{"name": tc.arg}); err != nil { + t.Fatalf("rename failed: %v", err) + } + // Renaming updates both the display name and the URL identifier, so the + // PATCH payload must carry the new name in each field. + assertEqual(t, body["name"], tc.wantName) + assertEqual(t, body["identifier"], tc.wantName) + }) + } +} + +func TestRepoRenameRejectsBlankName(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("blank name should not call API, got: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + for _, args := range []map[string]string{nil, {"name": " "}} { + if err := runShortcut(t, server, "rename", args); err == nil { + t.Fatal("expected validation error for missing or blank name") + } + } +} + // --- validation/error paths --- func TestRepoInsightValidation(t *testing.T) {