diff --git a/README.md b/README.md index 797e6c7..7cc3504 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | Category | Capabilities | |----------|-------------| -| 📦 Repo | List, create, fork, delete repositories, view repo info | +| 📦 Repo | List, create, fork, delete repositories, manage settings, topics, transfer | | 🐛 Issue | Create, update, close, batch close, comment on issues | | 🔖 Label | Create, list, update, delete issue labels | | 🔀 PR | Create, merge, review pull requests, view changed files | @@ -193,6 +193,10 @@ gitlink-cli repo +list # View repository info gitlink-cli repo +info --owner Gitlink --repo forgeplus +# View repository metadata and settings +gitlink-cli repo +detail --owner Gitlink --repo forgeplus +gitlink-cli repo +settings --owner Gitlink --repo forgeplus + # Read repository README gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master @@ -201,6 +205,15 @@ gitlink-cli repo +create -n my-project -d "Project description" # Fork a repository gitlink-cli repo +fork --owner Gitlink --repo forgeplus + +# Update navigation units and manage topics, previewing writes first +gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki --dry-run +gitlink-cli repo +topics --keyword go +gitlink-cli repo +topic-add --project-id 17 --name go --dry-run + +# Transfer workflow helpers +gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus +gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --owner-name target-org --dry-run ``` ### Webhook Management @@ -587,7 +600,7 @@ See [skills/README.md](skills/README.md) for details. | Skill | Description | |-------|-------------| | `gitlink-shared` | Authentication, global parameters, safety rules, API notes | -| `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) | +| `gitlink-repo` | Repository operations (create, settings, topics, transfer, fork, etc.) | | `gitlink-issue` | Issue operations (create, update, close, comment, etc.) | | `gitlink-pr` | Pull request operations (create, merge, review, etc.) | | `gitlink-member` | Repository member and invite link management | diff --git a/README.zh-CN.md b/README.zh-CN.md index 88c6119..a176bc4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -79,7 +79,7 @@ | 分类 | 能力 | |------|------| -| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 | +| 📦 仓库 | 列出、创建、Fork、删除仓库,管理设置、Topics、迁移 | | 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | @@ -204,6 +204,10 @@ gitlink-cli repo +list # 查看仓库信息 gitlink-cli repo +info --owner Gitlink --repo forgeplus +# 查看仓库详情和设置 +gitlink-cli repo +detail --owner Gitlink --repo forgeplus +gitlink-cli repo +settings --owner Gitlink --repo forgeplus + # 读取仓库 README gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master @@ -212,6 +216,15 @@ gitlink-cli repo +create -n my-project -d "项目描述" # Fork 仓库 gitlink-cli repo +fork --owner Gitlink --repo forgeplus + +# 更新导航和管理 Topics,写入前先 dry-run +gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki --dry-run +gitlink-cli repo +topics --keyword go +gitlink-cli repo +topic-add --project-id 17 --name go --dry-run + +# 仓库迁移辅助 +gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus +gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --owner-name target-org --dry-run ``` ### Webhook 管理 @@ -466,7 +479,7 @@ git push gitlink | Skill | 说明 | |-------|------| | `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 | -| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork 等) | +| `gitlink-repo` | 仓库操作(创建、设置、Topics、迁移、Fork 等) | | `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) | | `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) | | `gitlink-member` | 仓库成员与邀请链接管理 | diff --git a/doc/changes/repo-settings-shortcuts.md b/doc/changes/repo-settings-shortcuts.md new file mode 100644 index 0000000..8c1e140 --- /dev/null +++ b/doc/changes/repo-settings-shortcuts.md @@ -0,0 +1,40 @@ +# Repository Settings Shortcuts + +Submitter: Wang Yue + +This change expands repository shortcut coverage for repository metadata, settings, project topics, navigation units, and transfer OpenAPI endpoints. + +## 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` + +## API Mapping + +| Shortcut | Method | API path | +|----------|--------|----------| +| `repo +detail` | GET | `/api/{owner}/{repo}/detail.json` | +| `repo +simple` | GET | `/api/{owner}/{repo}/simple.json` | +| `repo +settings` | GET | `/api/{owner}/{repo}/edit.json` | +| `repo +units` | GET | `/api/{owner}/{repo}/project_units.json` | +| `repo +units-update` | POST | `/api/{owner}/{repo}/project_units.json` | +| `repo +topics` | GET | `/api/v1/project_topics.json` | +| `repo +topic-add` | POST | `/api/v1/project_topics.json` | +| `repo +topic-delete` | DELETE | `/api/v1/project_topics/{id}.json` | +| `repo +transfer-orgs` | GET | `/api/{owner}/{repo}/applied_transfer_projects/organizations.json` | +| `repo +transfer` | POST | `/api/{owner}/{repo}/applied_transfer_projects.json` | +| `repo +transfer-cancel` | POST | `/api/{owner}/{repo}/applied_transfer_projects/cancel.json` | + +## 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`. diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index ea4f6ee..dea2dd2 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -3,6 +3,8 @@ package repo import ( "fmt" "net/url" + "strconv" + "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -79,6 +81,260 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "detail", + Description: "Show repository detail metadata", + 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 simplified repository metadata", + 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 metadata", + 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", Short: "u", Usage: "Comma-separated units, for example: code,issues,pulls,wiki", Required: true}, + {Name: "dry-run", Usage: "Preview the update request without changing repository units", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + units, err := parseCSVArg(ctx.Arg("units"), "--units") + if err != nil { + return err + } + payload := map[string]interface{}{"unit_types": units} + path := ctx.RepoPath() + "/project_units" + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "dry_run": true, + "action": "update_repository_units", + "method": "POST", + "path": path, + "payload": payload, + }) + } + env, err := ctx.CallAPI("POST", path, payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "topics", + Description: "List project topic labels", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Topic keyword filter"}, + }, + Run: func(ctx *common.RuntimeContext) error { + q := url.Values{} + setQueryIfPresent(q, ctx, "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 label to a repository", + Flags: []common.Flag{ + {Name: "project-id", Usage: "Repository project ID", Required: true}, + {Name: "name", Short: "n", Usage: "Topic name", Required: true}, + {Name: "dry-run", Usage: "Preview the create request without changing topics", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + projectID, err := requiredPositiveIntArg(ctx, "project-id") + if err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + payload := map[string]interface{}{ + "project_id": projectID, + "name": name, + } + path := "/v1/project_topics" + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "add_repository_topic", + "method": "POST", + "path": path, + "payload": payload, + }) + } + env, err := ctx.CallAPI("POST", path, payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "topic-delete", + Description: "Remove a topic label from a repository", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Project topic ID", Required: true}, + {Name: "project-id", Usage: "Repository project ID", Required: true}, + {Name: "dry-run", Usage: "Preview the delete request without changing topics", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + projectID, err := ctx.RequireArg("project-id") + if err != nil { + return err + } + path := fmt.Sprintf("/v1/project_topics/%s", url.PathEscape(id)) + q := url.Values{"project_id": []string{projectID}} + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "delete_repository_topic", + "method": "DELETE", + "path": path, + "query": q, + }) + } + env, err := ctx.CallAPIWithQuery("DELETE", path, q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "transfer-orgs", + Description: "List organizations available for 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 a repository to another owner", + Flags: []common.Flag{ + {Name: "owner-name", Usage: "Target owner login or organization name", Required: true}, + {Name: "dry-run", Usage: "Preview the transfer request without changing ownership", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + ownerName, err := ctx.RequireArg("owner-name") + if err != nil { + return err + } + payload := map[string]interface{}{"owner_name": ownerName} + path := ctx.RepoPath() + "/applied_transfer_projects" + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "dry_run": true, + "action": "transfer_repository", + "method": "POST", + "path": path, + "payload": payload, + }) + } + env, err := ctx.CallAPI("POST", path, payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "transfer-cancel", + Description: "Cancel a pending repository transfer", + Flags: []common.Flag{ + {Name: "dry-run", Usage: "Preview the cancel request without changing transfer state", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path := ctx.RepoPath() + "/applied_transfer_projects/cancel" + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "dry_run": true, + "action": "cancel_repository_transfer", + "method": "POST", + "path": path, + }) + } + env, err := ctx.CallAPI("POST", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "create", Description: tr.T("cmd.repo.create.short"), @@ -158,3 +414,42 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { } return i18n.Default() } + +func parseCSVArg(value, flagName string) ([]string, error) { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + seen := map[string]bool{} + for _, part := range parts { + item := strings.TrimSpace(part) + if item == "" { + continue + } + if seen[item] { + continue + } + seen[item] = true + result = append(result, item) + } + if len(result) == 0 { + return nil, fmt.Errorf("%s must include at least one value", flagName) + } + return result, nil +} + +func requiredPositiveIntArg(ctx *common.RuntimeContext, flagName string) (int, error) { + value, err := ctx.RequireArg(flagName) + if err != nil { + return 0, err + } + id, err := strconv.Atoi(value) + if err != nil || id <= 0 { + return 0, fmt.Errorf("--%s must be a positive integer", flagName) + } + return id, nil +} + +func setQueryIfPresent(q url.Values, ctx *common.RuntimeContext, flagName, queryName string) { + if value := ctx.Arg(flagName); value != "" { + q.Set(queryName, value) + } +} diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 43ed15a..e3e5edd 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "reflect" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -14,12 +15,18 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st t.Helper() s := findShortcut(t, name) ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, Owner: "owner", Repo: "repo", Format: "json", Args: args, } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } return s.Run(ctx) } @@ -34,9 +41,20 @@ func findShortcut(t *testing.T, name string) *common.Shortcut { return nil } -func writeJSON(w http.ResponseWriter, v interface{}) { +func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("write response: %v", err) + } +} + +func writeText(t *testing.T, w http.ResponseWriter, code int, text string) { + t.Helper() + w.WriteHeader(code) + if _, err := w.Write([]byte(text)); err != nil { + t.Fatalf("write response: %v", err) + } } // --- list --- @@ -49,7 +67,7 @@ func TestRepoListDefault(t *testing.T) { if r.URL.Path != "/projects.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "total_count": float64(2), "data": []interface{}{map[string]interface{}{"name": "repo1"}}, }) @@ -67,7 +85,7 @@ func TestRepoListForUser(t *testing.T) { if r.URL.Path != "/users/alice/projects.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{"total_count": float64(0), "data": []interface{}{}}) + writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "data": []interface{}{}}) })) defer server.Close() @@ -82,7 +100,7 @@ func TestRepoListWithCategory(t *testing.T) { if r.URL.Query().Get("category") != "mirror" { t.Fatalf("expected category=mirror, got %s", r.URL.Query().Get("category")) } - writeJSON(w, map[string]interface{}{"data": []interface{}{}}) + writeJSON(t, w, map[string]interface{}{"data": []interface{}{}}) })) defer server.Close() @@ -92,14 +110,14 @@ func TestRepoListWithCategory(t *testing.T) { } } -// --- info --- +// --- info/read metadata --- func TestRepoInfo(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/owner/repo.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "name": "repo", "description": "test repo", }) @@ -112,7 +130,127 @@ func TestRepoInfo(t *testing.T) { } } -// --- fork --- +func TestRepoMetadataShortcuts(t *testing.T) { + tests := []struct { + name string + path string + }{ + {name: "detail", path: "/owner/repo/detail.json"}, + {name: "simple", path: "/owner/repo/simple.json"}, + {name: "settings", path: "/owner/repo/edit.json"}, + {name: "units", path: "/owner/repo/project_units.json"}, + {name: "transfer-orgs", path: "/owner/repo/applied_transfer_projects/organizations.json"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRepoRequest(t, r, "GET", tc.path) + writeJSON(t, w, map[string]interface{}{"status": 0}) + })) + defer server.Close() + + if err := runShortcut(t, server, tc.name, nil); err != nil { + t.Fatalf("%s shortcut failed: %v", tc.name, err) + } + }) + } +} + +func TestRepoUnitsUpdatePayload(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRepoRequest(t, r, "POST", "/owner/repo/project_units.json") + payload = decodeRepoJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0}) + })) + defer server.Close() + + err := runShortcut(t, server, "units-update", map[string]string{"units": "code,issues,code,wiki"}) + if err != nil { + t.Fatalf("units-update shortcut failed: %v", err) + } + assertRepoStringSlice(t, payload["unit_types"], []string{"code", "issues", "wiki"}) +} + +func TestRepoTopics(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRepoRequest(t, r, "GET", "/v1/project_topics.json") + assertRepoQuery(t, r, "keyword", "go") + writeJSON(t, w, map[string]interface{}{"total_count": 0, "project_topics": []interface{}{}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "topics", map[string]string{"keyword": "go"}); err != nil { + t.Fatalf("topics shortcut failed: %v", err) + } +} + +func TestRepoTopicAddPayload(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRepoRequest(t, r, "POST", "/v1/project_topics.json") + payload = decodeRepoJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0}) + })) + defer server.Close() + + err := runShortcut(t, server, "topic-add", map[string]string{ + "project-id": "17", + "name": "go", + }) + if err != nil { + t.Fatalf("topic-add shortcut failed: %v", err) + } + assertRepoEqual(t, payload["project_id"], float64(17)) + assertRepoEqual(t, payload["name"], "go") +} + +func TestRepoTopicDelete(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRepoRequest(t, r, "DELETE", "/v1/project_topics/8.json") + assertRepoQuery(t, r, "project_id", "17") + writeJSON(t, w, map[string]interface{}{"status": 0}) + })) + defer server.Close() + + err := runShortcut(t, server, "topic-delete", map[string]string{ + "id": "8", + "project-id": "17", + }) + if err != nil { + t.Fatalf("topic-delete shortcut failed: %v", err) + } +} + +func TestRepoTransferPayload(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRepoRequest(t, r, "POST", "/owner/repo/applied_transfer_projects.json") + payload = decodeRepoJSON(t, r) + writeJSON(t, w, map[string]interface{}{"id": 1}) + })) + defer server.Close() + + if err := runShortcut(t, server, "transfer", map[string]string{"owner-name": "target-org"}); err != nil { + t.Fatalf("transfer shortcut failed: %v", err) + } + assertRepoEqual(t, payload["owner_name"], "target-org") +} + +func TestRepoTransferCancel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRepoRequest(t, r, "POST", "/owner/repo/applied_transfer_projects/cancel.json") + writeJSON(t, w, map[string]interface{}{"status": 0}) + })) + defer server.Close() + + if err := runShortcut(t, server, "transfer-cancel", nil); err != nil { + t.Fatalf("transfer-cancel shortcut failed: %v", err) + } +} + +// --- fork/delete/create --- func TestRepoFork(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -122,7 +260,7 @@ func TestRepoFork(t *testing.T) { if r.URL.Path != "/owner/repo/forks.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{"message": "forked"}) + writeJSON(t, w, map[string]interface{}{"message": "forked"}) })) defer server.Close() @@ -132,8 +270,6 @@ func TestRepoFork(t *testing.T) { } } -// --- delete --- - func TestRepoDelete(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "DELETE" { @@ -142,7 +278,7 @@ func TestRepoDelete(t *testing.T) { if r.URL.Path != "/owner/repo.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{"message": "deleted"}) + writeJSON(t, w, map[string]interface{}{"message": "deleted"}) })) defer server.Close() @@ -152,18 +288,16 @@ func TestRepoDelete(t *testing.T) { } } -// --- create --- - func TestRepoCreate(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/users/me.json" && r.Method == "GET": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "login": "creator", "user_id": float64(42), }) case r.URL.Path == "/creator/new-repo.json" && r.Method == "POST": - writeJSON(w, map[string]interface{}{"name": "new-repo"}) + writeJSON(t, w, map[string]interface{}{"name": "new-repo"}) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } @@ -181,13 +315,15 @@ func TestRepoCreateWithOptions(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/users/me.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "login": "creator", "user_id": float64(42), }) case "/creator/my-repo.json": - json.NewDecoder(r.Body).Decode(&body) - writeJSON(w, map[string]interface{}{"name": "my-repo"}) + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + writeJSON(t, w, map[string]interface{}{"name": "my-repo"}) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } @@ -210,6 +346,49 @@ func TestRepoCreateWithOptions(t *testing.T) { } } +// --- validation/dry-run/error paths --- + +func TestRepoDryRunDoesNotCallAPI(t *testing.T) { + dryRunCases := []struct { + name string + args map[string]string + }{ + {name: "units-update", args: map[string]string{"units": "code,issues", "dry-run": "true"}}, + {name: "topic-add", args: map[string]string{"project-id": "17", "name": "go", "dry-run": "true"}}, + {name: "topic-delete", args: map[string]string{"id": "8", "project-id": "17", "dry-run": "true"}}, + {name: "transfer", args: map[string]string{"owner-name": "target-org", "dry-run": "true"}}, + {name: "transfer-cancel", args: map[string]string{"dry-run": "true"}}, + } + + for _, tc := range dryRunCases { + t.Run(tc.name, func(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() + + if err := runShortcut(t, server, tc.name, tc.args); err != nil { + t.Fatalf("%s dry-run failed: %v", tc.name, err) + } + }) + } +} + +func TestRepoTopicAddRejectsInvalidProjectID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid project id should not call API, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runShortcut(t, server, "topic-add", map[string]string{ + "project-id": "abc", + "name": "go", + }) + if err == nil { + t.Fatal("expected invalid project id to return an error") + } +} + func TestRepoCreateFailsWithoutName(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call should be made") @@ -222,12 +401,9 @@ func TestRepoCreateFailsWithoutName(t *testing.T) { } } -// --- HTTP error paths --- - func TestRepoListHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -239,8 +415,7 @@ func TestRepoListHTTPError(t *testing.T) { func TestRepoInfoHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -252,8 +427,7 @@ func TestRepoInfoHTTPError(t *testing.T) { func TestRepoForkHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -265,8 +439,7 @@ func TestRepoForkHTTPError(t *testing.T) { func TestRepoDeleteHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -278,8 +451,7 @@ func TestRepoDeleteHTTPError(t *testing.T) { func TestRepoCreateGetUserHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -291,7 +463,7 @@ func TestRepoCreateGetUserHTTPError(t *testing.T) { func TestRepoCreateUserNoLogin(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]interface{}{"user_id": float64(42)}) + writeJSON(t, w, map[string]interface{}{"user_id": float64(42)}) })) defer server.Close() @@ -300,3 +472,52 @@ func TestRepoCreateUserNoLogin(t *testing.T) { t.Fatal("expected error when user response has no login") } } + +func assertRepoRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + } +} + +func assertRepoQuery(t *testing.T, r *http.Request, key, want string) { + t.Helper() + if got := r.URL.Query().Get(key); got != want { + t.Fatalf("query %s = %q, want %q", key, got, want) + } +} + +func decodeRepoJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +func assertRepoEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} + +func assertRepoStringSlice(t *testing.T, got interface{}, want []string) { + t.Helper() + values, ok := got.([]interface{}) + if !ok { + t.Fatalf("got %T, want []interface{}", got) + } + result := make([]string, 0, len(values)) + for _, value := range values { + text, ok := value.(string) + if !ok { + t.Fatalf("got value %v (%T), want string", value, value) + } + result = append(result, text) + } + if !reflect.DeepEqual(result, want) { + t.Fatalf("got %v, want %v", result, want) + } +} diff --git a/skills/README.md b/skills/README.md index f7e10ea..7289588 100644 --- a/skills/README.md +++ b/skills/README.md @@ -134,7 +134,7 @@ skills/ | Skill | 说明 | 常用命令 | |-------|------|----------| | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | -| **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +info`, `repo +fork` | +| **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +settings`, `repo +topics`, `repo +transfer` | | **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` | | **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 9fabb8e..fd6714e 100644 --- a/skills/gitlink-repo/SKILL.md +++ b/skills/gitlink-repo/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-repo version: 1.0.0 -description: "仓库管理:创建、查看、Fork、删除仓库,查看分支、提交、贡献者等。当用户需要操作 GitLink 仓库时触发。" +description: "仓库管理:创建、查看、Fork、删除仓库,管理设置、Topics、导航和迁移。当用户需要操作 GitLink 仓库时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -22,6 +22,17 @@ metadata: |----------|------|----------| | `repo +list` | 仓库列表 | 否(公开项目) | | `repo +info` | 仓库详情 | 否(公开项目) | +| `repo +detail` | 仓库详情元数据 | 否(公开项目) | +| `repo +simple` | 仓库简版元数据 | 否(公开项目) | +| `repo +settings` | 仓库设置元数据 | 是 | +| `repo +units` | 仓库导航配置 | 是 | +| `repo +units-update` | 更新仓库导航配置 | 是 | +| `repo +topics` | 项目 Topic 标签列表 | 否 | +| `repo +topic-add` | 添加项目 Topic 标签 | 是 | +| `repo +topic-delete` | 删除项目 Topic 标签 | 是 | +| `repo +transfer-orgs` | 可迁移组织列表 | 是 | +| `repo +transfer` | 发起仓库迁移 | 是 | +| `repo +transfer-cancel` | 取消仓库迁移 | 是 | | `repo +create` | 创建仓库 | 是 | | `repo +fork` | Fork 仓库 | 是 | | `repo +delete` | 删除仓库 | 是 | @@ -45,6 +56,22 @@ gitlink-cli repo +create --name my-project --description "项目描述" # Fork 仓库 gitlink-cli repo +fork --owner Gitlink --repo forgeplus +# 查看仓库设置、导航和主题 +gitlink-cli repo +detail --owner Gitlink --repo forgeplus +gitlink-cli repo +settings --owner Gitlink --repo forgeplus +gitlink-cli repo +units --owner Gitlink --repo forgeplus +gitlink-cli repo +topics --keyword go + +# 更新导航和 Topics,写入前先 dry-run +gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki --dry-run +gitlink-cli repo +topic-add --project-id 17 --name go --dry-run +gitlink-cli repo +topic-delete --project-id 17 --id 8 --dry-run + +# 仓库迁移,写入前先 dry-run +gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus +gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --owner-name target-org --dry-run +gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run + # 删除仓库(⚠️ 危险操作) gitlink-cli repo +delete --owner myuser --repo old-project ``` @@ -76,4 +103,6 @@ gitlink-cli api GET /:owner/:repo/raw/main/README.md ## 注意事项 - `repo +delete` 是不可逆操作,执行前必须确认用户意图 +- `repo +units-update`、`repo +topic-add`、`repo +topic-delete`、`repo +transfer`、`repo +transfer-cancel` 支持 `--dry-run` +- `repo +topic-add` / `repo +topic-delete` 需要 `project_id`,可先用 `repo +detail` 获取 - 创建仓库默认为公开,使用 `--private true` 创建私有仓库