From 487f9132e2472168ac91635a66eb27242ce469ba Mon Sep 17 00:00:00 2001 From: weidongde Date: Fri, 26 Jun 2026 17:13:13 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(repo):=20=E6=B7=BB=E5=8A=A0=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E8=AE=BE=E7=BD=AE=E5=BF=AB=E6=8D=B7=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 11 个仓库管理命令: - repo +detail: 获取仓库详情 - repo +simple: 获取仓库简要信息 - repo +settings: 获取仓库设置 - repo +units: 获取导航单元列表 - repo +units-update: 更新导航单元 - repo +topics: 获取项目标签列表 - repo +topic-add: 添加项目标签 - repo +topic-delete: 删除项目标签 - repo +transfer-orgs: 获取可转移组织列表 - repo +transfer: 申请转移仓库 - repo +transfer-cancel: 取消转移申请 包含完整的单元测试和变更文档 --- doc/changes/repo-settings-shortcuts.md | 99 ++++++- shortcuts/repo/repo.go | 280 +++++++++++++++++++ shortcuts/repo/repo_test.go | 370 +++++++++++++++++++++++++ 3 files changed, 736 insertions(+), 13 deletions(-) diff --git a/doc/changes/repo-settings-shortcuts.md b/doc/changes/repo-settings-shortcuts.md index 8c1e140..3534ac0 100644 --- a/doc/changes/repo-settings-shortcuts.md +++ b/doc/changes/repo-settings-shortcuts.md @@ -6,17 +6,19 @@ This change expands repository shortcut coverage for repository metadata, settin ## Commands -- `repo +detail` -- `repo +simple` -- `repo +settings` -- `repo +units` -- `repo +units-update` -- `repo +topics` -- `repo +topic-add` -- `repo +topic-delete` -- `repo +transfer-orgs` -- `repo +transfer` -- `repo +transfer-cancel` +| Shortcut | 说明 | 需要认证 | +|----------|------|----------| +| `repo +detail` | 获取完整项目详情 | 否(公开项目) | +| `repo +simple` | 获取简版项目详情 | 否(公开项目) | +| `repo +settings` | 获取项目设置 | 是 | +| `repo +units` | 获取导航单元配置 | 是 | +| `repo +units-update` | 更新导航单元配置 | 是 | +| `repo +topics` | 搜索项目标签 | 否(公开项目) | +| `repo +topic-add` | 添加项目标签 | 是 | +| `repo +topic-delete` | 删除项目标签 | 是 | +| `repo +transfer-orgs` | 获取可转移组织列表 | 是 | +| `repo +transfer` | 发起仓库转移 | 是 | +| `repo +transfer-cancel` | 取消仓库转移 | 是 | ## API Mapping @@ -34,7 +36,78 @@ This change expands repository shortcut coverage for repository metadata, settin | `repo +transfer` | POST | `/api/{owner}/{repo}/applied_transfer_projects.json` | | `repo +transfer-cancel` | POST | `/api/{owner}/{repo}/applied_transfer_projects/cancel.json` | +## 参数说明 + +### repo +detail / +simple / +settings / +units +- `--owner`: 仓库所有者(可从 git remote 自动推断) +- `--repo`: 仓库名称(可从 git remote 自动推断) + +### repo +units-update +- `--owner`: 仓库所有者 +- `--repo`: 仓库名称 +- `--unit-types`: 导航单元类型列表,逗号分隔(如 `code,issues,pulls`) +- 有效值:`code`, `issues`, `pulls`, `wiki`, `devops`, `versions`, `services` + +### repo +topics +- `--keyword`: 搜索关键词(可选) + +### repo +topic-add +- `--name`: 标签名称 +- `--project-id`: 项目 ID(必需) + +### repo +topic-delete +- `--id`: 标签 ID +- `--project-id`: 项目 ID(必需) + +### repo +transfer-orgs +- `--owner`: 仓库所有者 +- `--repo`: 仓库名称 + +### repo +transfer +- `--owner`: 仓库所有者 +- `--repo`: 仓库名称 +- `--org-name`: 目标组织名称 + +### repo +transfer-cancel +- `--owner`: 仓库所有者 +- `--repo`: 仓库名称 + +## 使用示例 + +```bash +# 查看项目详情 +gitlink-cli repo +detail --owner Gitlink --repo forgeplus +gitlink-cli repo +simple --owner Gitlink --repo forgeplus + +# 查看和更新项目设置 +gitlink-cli repo +settings --owner myuser --repo myproject +gitlink-cli repo +units --owner myuser --repo myproject +gitlink-cli repo +units-update --owner myuser --repo myproject --unit-types code,issues,pulls,wiki + +# 管理项目标签 +gitlink-cli repo +topics --keyword "前端" +gitlink-cli repo +topic-add --name "Vue" --project-id 123 +gitlink-cli repo +topic-delete --id 456 --project-id 123 + +# 仓库转移 +gitlink-cli repo +transfer-orgs --owner myuser --repo myproject +gitlink-cli repo +transfer --owner myuser --repo myproject --org-name myorg --dry-run +gitlink-cli repo +transfer --owner myuser --repo myproject --org-name myorg +gitlink-cli repo +transfer-cancel --owner myuser --repo myproject +``` + +## 实现细节 + +- 所有命令遵循现有 `shortcuts/repo/repo.go` 的代码模式 +- 使用 `ctx.RequireArg()` 获取必需参数 +- 使用 `ctx.CallAPI()` 调用 API +- 写入操作支持 `--dry-run` 参数预览 +- `repo +units-update` 使用 `parseCommaSeparatedList()` 解析逗号分隔的单元类型列表,自动去重 + ## Verification -- Unit tests cover request methods, paths, query parameters, JSON payloads, dry-run behavior, CSV de-duplication, and invalid project ID validation. -- Write and state-changing commands support `--dry-run`. +- 单元测试覆盖所有 11 个新命令 +- 测试用例包括:请求方法、路径、查询参数、JSON 请求体、dry-run 行为、CSV 去重、无效 project-id 验证 +- 所有写入和状态变更命令支持 `--dry-run` +- 测试文件:`shortcuts/repo/repo_test.go` +- 测试命令:`go test ./shortcuts/repo/...` diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 06774a6..ff8ec87 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -253,6 +253,274 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + // --- Repository Settings Shortcuts --- + { + Name: "detail", + Description: "Show repository full details", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/detail", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "simple", + Description: "Show repository simple details", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/simple", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "settings", + Description: "Show repository settings", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/edit", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "units", + Description: "Show repository navigation units", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/project_units", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "units-update", + Description: "Update repository navigation units", + Flags: []common.Flag{ + {Name: "units", Usage: "Comma-separated list of unit types (code,issues,pulls,wiki,devops,versions,services)", Required: true}, + {Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + unitsRaw, err := ctx.RequireArg("units") + if err != nil { + return err + } + unitTypes := parseCommaSeparatedList(unitsRaw) + if len(unitTypes) == 0 { + return fmt.Errorf("--units must contain at least one unit type") + } + validUnits := map[string]bool{ + "code": true, "issues": true, "pulls": true, "wiki": true, + "devops": true, "versions": true, "services": true, + } + for _, u := range unitTypes { + if !validUnits[u] { + return fmt.Errorf("invalid unit type %q: must be one of code, issues, pulls, wiki, devops, versions, services", u) + } + } + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "units-update", + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "unit_types": unitTypes, + }) + } + body := map[string]interface{}{ + "unit_types": unitTypes, + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/project_units", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "topics", + Description: "List project topics", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Filter topics by keyword"}, + }, + Run: func(ctx *common.RuntimeContext) error { + q := url.Values{} + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + env, err := ctx.CallAPIWithQuery("GET", "/v1/project_topics", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "topic-add", + Description: "Add a topic to the repository", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Topic name", Required: true}, + {Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + projectID, err := resolveRepoProjectID(ctx) + if err != nil { + return err + } + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "topic-add", + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "project_id": projectID, + "name": name, + }) + } + body := map[string]interface{}{ + "name": name, + "project_id": projectID, + } + env, err := ctx.CallAPI("POST", "/v1/project_topics", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "topic-delete", + Description: "Delete a topic from the repository", + Flags: []common.Flag{ + {Name: "id", Usage: "Topic ID", Required: true}, + {Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + topicID, err := ctx.RequireArg("id") + if err != nil { + return err + } + projectID, err := resolveRepoProjectID(ctx) + if err != nil { + return err + } + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "topic-delete", + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "project_id": projectID, + "topic_id": topicID, + }) + } + q := url.Values{} + q.Set("project_id", projectID) + env, err := ctx.CallAPIWithQuery("DELETE", fmt.Sprintf("/v1/project_topics/%s", topicID), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "transfer-orgs", + Description: "List organizations that can receive the repository transfer", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/applied_transfer_projects/organizations", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "transfer", + Description: "Transfer repository to another owner", + Flags: []common.Flag{ + {Name: "owner", Short: "o", Usage: "Target owner name (user or organization)", Required: true}, + {Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + ownerName, err := ctx.RequireArg("owner") + if err != nil { + return err + } + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "transfer", + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "target_owner": ownerName, + }) + } + body := map[string]interface{}{ + "owner_name": ownerName, + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "transfer-cancel", + Description: "Cancel pending repository transfer", + Flags: []common.Flag{ + {Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "transfer-cancel", + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + }) + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects/cancel", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } @@ -500,3 +768,15 @@ func parseRepoPositiveInt(value, name string) (int, error) { } return parsed, nil } + +func parseCommaSeparatedList(s string) []string { + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 5c5f34b..e329614 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -634,3 +634,373 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) { t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) } } + +// --- Repository Settings Shortcuts Tests --- + +func TestRepoDetail(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/detail.json") + writeJSON(t, w, map[string]interface{}{ + "name": "repo", + "description": "test repo", + "identifier": "repo", + }) + })) + defer server.Close() + + err := runShortcut(t, server, "detail", nil) + if err != nil { + t.Fatalf("detail failed: %v", err) + } +} + +func TestRepoSimple(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/simple.json") + writeJSON(t, w, map[string]interface{}{ + "name": "repo", + }) + })) + defer server.Close() + + err := runShortcut(t, server, "simple", nil) + if err != nil { + t.Fatalf("simple failed: %v", err) + } +} + +func TestRepoSettings(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/edit.json") + writeJSON(t, w, map[string]interface{}{ + "name": "repo", + "description": "test repo", + "private": false, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "settings", nil) + if err != nil { + t.Fatalf("settings failed: %v", err) + } +} + +func TestRepoUnits(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, []interface{}{ + map[string]interface{}{"type": "code"}, + map[string]interface{}{"type": "issues"}, + map[string]interface{}{"type": "pulls"}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "units", nil) + if err != nil { + t.Fatalf("units failed: %v", err) + } +} + +func TestRepoUnitsUpdate(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/project_units.json") + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + writeJSON(t, w, map[string]interface{}{"status": 0}) + })) + defer server.Close() + + err := runShortcut(t, server, "units-update", map[string]string{ + "units": "code,issues,pulls", + }) + if err != nil { + t.Fatalf("units-update failed: %v", err) + } + + unitTypes, ok := body["unit_types"].([]interface{}) + if !ok { + t.Fatalf("unit_types not found in body") + } + if len(unitTypes) != 3 { + t.Fatalf("expected 3 unit types, got %d", len(unitTypes)) + } +} + +func TestRepoUnitsUpdateDryRun(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, "units-update", map[string]string{ + "units": "code,issues", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("units-update dry-run failed: %v", err) + } +} + +func TestRepoUnitsUpdateValidation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("validation error should not call API, got: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + cases := []struct { + name string + args map[string]string + }{ + { + name: "empty units", + args: map[string]string{"units": ""}, + }, + { + name: "invalid unit type", + args: map[string]string{"units": "code,invalid"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := runShortcut(t, server, "units-update", tc.args) + if err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func TestRepoTopics(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/project_topics.json") + assertEqual(t, r.URL.Query().Get("keyword"), "go") + writeJSON(t, w, map[string]interface{}{ + "topics": []interface{}{ + map[string]interface{}{"id": float64(1), "name": "golang"}, + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "topics", map[string]string{"keyword": "go"}) + if err != nil { + t.Fatalf("topics failed: %v", err) + } +} + +func TestRepoTopicAdd(t *testing.T) { + requests := 0 + var body map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + switch requests { + case 1: + assertRequest(t, r, "GET", "/owner/repo.json") + writeJSON(t, w, map[string]interface{}{"id": float64(123)}) + case 2: + assertRequest(t, r, "POST", "/v1/project_topics.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(1), "name": "test-topic"}) + default: + t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runShortcut(t, server, "topic-add", map[string]string{"name": "test-topic"}) + if err != nil { + t.Fatalf("topic-add failed: %v", err) + } + assertEqual(t, body["name"], "test-topic") + assertEqual(t, body["project_id"], "123") +} + +func TestRepoTopicAddDryRun(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + switch requests { + case 1: + assertRequest(t, r, "GET", "/owner/repo.json") + writeJSON(t, w, map[string]interface{}{"id": float64(123)}) + default: + t.Fatalf("dry-run should not make extra requests, got: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runShortcut(t, server, "topic-add", map[string]string{ + "name": "test-topic", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("topic-add dry-run failed: %v", err) + } + assertEqual(t, requests, 1) +} + +func TestRepoTopicDelete(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + switch requests { + case 1: + assertRequest(t, r, "GET", "/owner/repo.json") + writeJSON(t, w, map[string]interface{}{"id": float64(123)}) + case 2: + assertRequest(t, r, "DELETE", "/v1/project_topics/456.json") + assertEqual(t, r.URL.Query().Get("project_id"), "123") + writeJSON(t, w, map[string]interface{}{"status": 0}) + default: + t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runShortcut(t, server, "topic-delete", map[string]string{"id": "456"}) + if err != nil { + t.Fatalf("topic-delete failed: %v", err) + } + assertEqual(t, requests, 2) +} + +func TestRepoTopicDeleteDryRun(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + switch requests { + case 1: + assertRequest(t, r, "GET", "/owner/repo.json") + writeJSON(t, w, map[string]interface{}{"id": float64(123)}) + default: + t.Fatalf("dry-run should not make extra requests, got: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runShortcut(t, server, "topic-delete", map[string]string{ + "id": "456", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("topic-delete dry-run failed: %v", err) + } + assertEqual(t, requests, 1) +} + +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, []interface{}{ + map[string]interface{}{"name": "org1"}, + map[string]interface{}{"name": "org2"}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "transfer-orgs", nil) + if 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 request body: %v", err) + } + writeJSON(t, w, map[string]interface{}{"status": 0}) + })) + defer server.Close() + + err := runShortcut(t, server, "transfer", map[string]string{"owner": "new-owner"}) + if err != nil { + t.Fatalf("transfer failed: %v", err) + } + assertEqual(t, body["owner_name"], "new-owner") +} + +func TestRepoTransferDryRun(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{ + "owner": "new-owner", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("transfer dry-run failed: %v", err) + } +} + +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": 0}) + })) + defer server.Close() + + err := runShortcut(t, server, "transfer-cancel", nil) + if err != nil { + t.Fatalf("transfer-cancel failed: %v", err) + } +} + +func TestRepoTransferCancelDryRun(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 TestRepoTopicAddFailsWithoutName(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, "topic-add", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing name") + } +} + +func TestRepoTopicDeleteFailsWithoutID(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, "topic-delete", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing id") + } +} + +func TestRepoTransferFailsWithoutOwner(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 owner") + } +} From f03c87e3064862ccb95880ff723be28450b0e12f Mon Sep 17 00:00:00 2001 From: weidongde Date: Fri, 26 Jun 2026 17:15:13 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20README=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BB=93=E5=BA=93=E8=AE=BE=E7=BD=AE=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E5=91=BD=E4=BB=A4=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++-- README.zh-CN.md | 42 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 71837ae..714b2c6 100644 --- a/README.md +++ b/README.md @@ -253,8 +253,46 @@ gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123 gitlink-cli repo +create -n my-project -d "Project description" # Fork a repository -gitlink-cli repo +fork --owner Gitlink --repo forgeplus -``` + gitlink-cli repo +fork --owner Gitlink --repo forgeplus + + # View repository details + gitlink-cli repo +detail --owner Gitlink --repo forgeplus + + # View repository simple info + gitlink-cli repo +simple --owner Gitlink --repo forgeplus + + # View repository settings + gitlink-cli repo +settings --owner Gitlink --repo forgeplus + + # List navigation units + gitlink-cli repo +units --owner Gitlink --repo forgeplus + + # Update navigation units + gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --unit-types code,issues,pulls --dry-run + gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --unit-types code,issues,pulls + + # List project topics + gitlink-cli repo +topics --owner Gitlink --repo forgeplus + + # Add a project topic + gitlink-cli repo +topic-add --owner Gitlink --repo forgeplus --name "machine-learning" --dry-run + gitlink-cli repo +topic-add --owner Gitlink --repo forgeplus --name "machine-learning" + + # Delete a project topic + gitlink-cli repo +topic-delete --owner Gitlink --repo forgeplus --id 123 --dry-run + gitlink-cli repo +topic-delete --owner Gitlink --repo forgeplus --id 123 + + # List organizations available for transfer + gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus + + # Apply for repository transfer + gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --org-id 456 --dry-run + gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --org-id 456 + + # Cancel transfer application + gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run + gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus + ``` ### Webhook Management diff --git a/README.zh-CN.md b/README.zh-CN.md index 1661a1e..59df40a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -264,8 +264,46 @@ gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123 gitlink-cli repo +create -n my-project -d "项目描述" # Fork 仓库 -gitlink-cli repo +fork --owner Gitlink --repo forgeplus -``` + gitlink-cli repo +fork --owner Gitlink --repo forgeplus + + # 查看仓库详情 + gitlink-cli repo +detail --owner Gitlink --repo forgeplus + + # 查看仓库简要信息 + gitlink-cli repo +simple --owner Gitlink --repo forgeplus + + # 查看仓库设置 + gitlink-cli repo +settings --owner Gitlink --repo forgeplus + + # 列出导航单元 + gitlink-cli repo +units --owner Gitlink --repo forgeplus + + # 更新导航单元 + gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --unit-types code,issues,pulls --dry-run + gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --unit-types code,issues,pulls + + # 列出项目标签 + gitlink-cli repo +topics --owner Gitlink --repo forgeplus + + # 添加项目标签 + gitlink-cli repo +topic-add --owner Gitlink --repo forgeplus --name "machine-learning" --dry-run + gitlink-cli repo +topic-add --owner Gitlink --repo forgeplus --name "machine-learning" + + # 删除项目标签 + gitlink-cli repo +topic-delete --owner Gitlink --repo forgeplus --id 123 --dry-run + gitlink-cli repo +topic-delete --owner Gitlink --repo forgeplus --id 123 + + # 列出可转移的组织 + gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus + + # 申请转移仓库 + gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --org-id 456 --dry-run + gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --org-id 456 + + # 取消转移申请 + gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run + gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus + ``` ### Webhook 管理