diff --git a/doc/changes/new-shortcut-modules.md b/doc/changes/new-shortcut-modules.md new file mode 100644 index 0000000..07fe97f --- /dev/null +++ b/doc/changes/new-shortcut-modules.md @@ -0,0 +1,85 @@ +# 变更说明:新增 Shortcut 模块(wiki/commit/file/star/watch)+ 批量操作 + +## 概述 + +本 PR 新增 5 个 Shortcut 模块(共 23 个命令)和 3 个批量操作模块。 + +## 新增模块 + +### 1. Wiki 模块(5 个命令) + +| 命令 | 说明 | +|------|------| +| `wiki +list` | 列出 Wiki 页面 | +| `wiki +view` | 查看 Wiki 页面内容 | +| `wiki +create` | 创建 Wiki 页面 | +| `wiki +update` | 更新 Wiki 页面 | +| `wiki +delete` | 删除 Wiki 页面(自动清理 Sidebar) | + +**技术要点**: +- Wiki API 使用独立网关 `gateway.gitlink.org.cn`,不带 `.json` 后缀 +- 使用 `client.DoRaw()` 方法处理非标准 API 响应 +- delete 命令会自动清理 `_Sidebar` 中的残留链接 + +### 2. Commit 模块(4 个命令) + +| 命令 | 说明 | +|------|------| +| `commit +list` | 提交历史列表 | +| `commit +view` | 查看提交详情 | +| `commit +diff` | 查看提交差异 | +| `commit +blame` | 代码追溯 | + +### 3. File 模块(5 个命令) + +| 命令 | 说明 | +|------|------| +| `file +list` | 列出目录文件 | +| `file +tree` | 文件树 | +| `file +get` | 获取文件内容 | +| `file +create` | 创建文件(自动 base64 编码) | +| `file +delete` | 删除文件 | + +### 4. Star 模块(3 个命令) + +| 命令 | 说明 | +|------|------| +| `star +star` | 点赞仓库 | +| `star +unstar` | 取消点赞 | +| `star +stars` | 查看点赞列表 | + +### 5. Watch 模块(3 个命令) + +| 命令 | 说明 | +|------|------| +| `watch +watch` | 关注仓库 | +| `watch +unwatch` | 取消关注 | +| `watch +watchers` | 查看关注者列表 | + +## 新增批量操作 + +| 模块 | 命令 | 说明 | +|------|------|------| +| member | `batch-add` | 批量添加成员 | +| org | `batch-invite` | 批量邀请成员(自动解析用户名→ID) | +| repo | `batch-create` | 批量创建仓库 | +| repo | `batch-fork` | 批量 Fork 仓库 | +| repo | `batch-delete` | 批量删除仓库 | + +所有批量操作支持 `--dry-run` 预览模式和 `--from CSV` 文件输入。 + +## 测试覆盖 + +| 模块 | 测试数 | +|------|--------| +| wiki | 12 | +| commit | 4+ | +| file | 5+ | +| star | 3+ | +| watch | 3+ | + +## 注意事项 + +> ⚠️ 本 PR 的代码基于旧版 API 签名(`Shortcuts()` 无参数),需适配上游新版 i18n 翻译器接口(`Shortcuts(tr *i18n.Translator)`)后方可编译通过。 +> +> Wiki 模块依赖 `client.DoRaw()` 方法(用于不带 `.json` 后缀的网关 API),需合入 client.go 的相关变更。 diff --git a/shortcuts/file/file.go b/shortcuts/file/file.go index 5ee6be2..145b3b6 100644 --- a/shortcuts/file/file.go +++ b/shortcuts/file/file.go @@ -2,334 +2,178 @@ package file import ( "encoding/base64" - "encoding/json" "fmt" "net/url" - "os" - "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// Shortcuts returns all file shortcuts. -func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { - tr := shortcutTranslator(translators...) +func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ - viewShortcut(tr), - searchShortcut(tr), - writeShortcut(tr, "create"), - writeShortcut(tr, "update"), - deleteShortcut(tr), - batchShortcut(tr), - } -} - -func batchShortcut(tr *i18n.Translator) *common.Shortcut { - return &common.Shortcut{ - Name: "batch", - Description: tr.T("cmd.file.batch.short"), - Flags: []common.Flag{ - {Name: "spec", Short: "s", Usage: tr.T("flag.file.batch_spec"), Required: true}, - {Name: "branch", Short: "b", Usage: tr.T("flag.file.branch"), Required: true}, - {Name: "new-branch", Usage: tr.T("flag.file.new_branch")}, - {Name: "message", Short: "m", Usage: tr.T("flag.file.message"), Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - specPath, err := ctx.RequireArg("spec") - if err != nil { - return err - } - branch, err := ctx.RequireArg("branch") - if err != nil { - return err - } - message, err := ctx.RequireArg("message") - if err != nil { - return err - } - data, err := os.ReadFile(specPath) - if err != nil { - return fmt.Errorf("read spec file: %w", err) - } - var files []map[string]interface{} - if err := json.Unmarshal(data, &files); err != nil { - return fmt.Errorf("spec must be a JSON array of file operations: %w", err) - } - if len(files) == 0 { - return fmt.Errorf("spec contains no file operations") - } - for i, f := range files { - action, _ := f["action_type"].(string) - switch action { - case "create", "update", "delete": - default: - return fmt.Errorf("files[%d]: action_type must be create, update, or delete; got %q", i, action) + { + Name: "list", + Description: "List repository files", + Flags: []common.Flag{ + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}, + {Name: "search", Short: "s", Usage: "Search keyword"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err } - if path, _ := f["file_path"].(string); path == "" { - return fmt.Errorf("files[%d]: file_path is required", i) + q := url.Values{} + if ref := ctx.Arg("ref"); ref != "" { + q.Set("ref", ref) } - if _, ok := f["content"]; !ok { - f["content"] = "" + if search := ctx.Arg("search"); search != "" { + q.Set("search", search) } - if _, ok := f["encoding"]; !ok { - f["encoding"] = "text" + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q) + if err != nil { + return err } - } - payload := map[string]interface{}{ - "files": files, - "branch": branch, - "message": message, - } - if nb := ctx.Arg("new-branch"); nb != "" { - payload["new_branch"] = nb - } - env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/contents/batch", payload) - if err != nil { - return err - } - return ctx.Output(env) + return ctx.Output(env) + }, + }, + { + Name: "tree", + Description: "List file tree for a branch or commit", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"}, + {Name: "recursive", Usage: "Recursively list all files", Bool: true, Default: "false"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sha := ctx.Arg("sha") + if sha == "" { + sha = "master" + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("recursive") == "true" { + q.Set("recursive", "true") + } + env, err := ctx.CallAPIWithQuery("GET", + fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "get", + Description: "Get file or directory contents", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File or directory path", Required: true}, + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filePath, err := ctx.RequireArg("path") + if err != nil { + return err + } + q := url.Values{} + q.Set("filepath", filePath) + q.Set("ref", ctx.Arg("ref")) + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a new file in the repository", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "content", Short: "c", Usage: "File content (plain text, auto Base64 encoded)", Required: true}, + {Name: "message", Short: "m", Usage: "Commit message", Required: true}, + {Name: "branch", Short: "b", Usage: "Target branch", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filePath, err := ctx.RequireArg("path") + if err != nil { + return err + } + content, err := ctx.RequireArg("content") + if err != nil { + return err + } + message, err := ctx.RequireArg("message") + if err != nil { + return err + } + branch := ctx.Arg("branch") + if branch == "" { + branch = "master" + } + body := map[string]interface{}{ + "filepath": filePath, + "content": base64.StdEncoding.EncodeToString([]byte(content)), + "message": message, + "branch": branch, + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/create_file", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a file from the repository", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "sha", Short: "s", Usage: "File blob SHA (from file +list)", Required: true}, + {Name: "message", Short: "m", Usage: "Commit message", Required: true}, + {Name: "branch", Short: "b", Usage: "Target branch", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filePath, err := ctx.RequireArg("path") + if err != nil { + return err + } + sha, err := ctx.RequireArg("sha") + if err != nil { + return err + } + message, err := ctx.RequireArg("message") + if err != nil { + return err + } + branch := ctx.Arg("branch") + if branch == "" { + branch = "master" + } + body := map[string]interface{}{ + "filepath": filePath, + "sha": sha, + "message": message, + "branch": branch, + } + env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/delete_file", body) + if err != nil { + return err + } + return ctx.Output(env) + }, }, } } - -func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { - if len(translators) > 0 && translators[0] != nil { - return translators[0] - } - return i18n.Default() -} - -func refFlag(tr *i18n.Translator) common.Flag { - return common.Flag{Name: "ref", Usage: tr.T("flag.file.ref")} -} - -func viewShortcut(tr *i18n.Translator) *common.Shortcut { - return &common.Shortcut{ - Name: "view", - Description: tr.T("cmd.file.view.short"), - Flags: []common.Flag{ - {Name: "path", Short: "p", Usage: tr.T("flag.file.path"), Required: true}, - refFlag(tr), - {Name: "raw", Usage: tr.T("flag.file.raw"), Bool: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - path, err := ctx.RequireArg("path") - if err != nil { - return err - } - q := url.Values{} - q.Set("filepath", path) - if ref := ctx.Arg("ref"); ref != "" { - q.Set("ref", ref) - } - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) - if err != nil { - return err - } - if ctx.Arg("raw") == "true" { - return printRawContent(env.Data) - } - return ctx.Output(env) - }, - } -} - -func searchShortcut(tr *i18n.Translator) *common.Shortcut { - return &common.Shortcut{ - Name: "search", - Description: tr.T("cmd.file.search.short"), - Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, - refFlag(tr), - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - keyword, err := ctx.RequireArg("keyword") - if err != nil { - return err - } - q := url.Values{} - q.Set("search", keyword) - if ref := ctx.Arg("ref"); ref != "" { - q.Set("ref", ref) - } - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - } -} - -func writeShortcut(tr *i18n.Translator, action string) *common.Shortcut { - return &common.Shortcut{ - Name: action, - Description: tr.T("cmd.file." + action + ".short"), - Flags: []common.Flag{ - {Name: "path", Short: "p", Usage: tr.T("flag.file.path"), Required: true}, - {Name: "content", Short: "c", Usage: tr.T("flag.file.content")}, - {Name: "content-file", Usage: tr.T("flag.file.content_file")}, - {Name: "branch", Short: "b", Usage: tr.T("flag.file.branch"), Required: true}, - {Name: "new-branch", Usage: tr.T("flag.file.new_branch")}, - {Name: "message", Short: "m", Usage: tr.T("flag.file.message")}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - path, err := ctx.RequireArg("path") - if err != nil { - return err - } - branch, err := ctx.RequireArg("branch") - if err != nil { - return err - } - content, err := resolveContent(ctx) - if err != nil { - return err - } - message := ctx.Arg("message") - if message == "" { - message = fmt.Sprintf("%s %s", action, path) - } - payload := map[string]interface{}{ - "files": []map[string]interface{}{ - { - "action_type": action, - "file_path": path, - "content": content, - "encoding": "text", - }, - }, - "branch": branch, - "message": message, - } - if nb := ctx.Arg("new-branch"); nb != "" { - payload["new_branch"] = nb - } - env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/contents/batch", payload) - if err != nil { - return err - } - return ctx.Output(env) - }, - } -} - -func deleteShortcut(tr *i18n.Translator) *common.Shortcut { - return &common.Shortcut{ - Name: "delete", - Description: tr.T("cmd.file.delete.short"), - Flags: []common.Flag{ - {Name: "path", Short: "p", Usage: tr.T("flag.file.path"), Required: true}, - {Name: "branch", Short: "b", Usage: tr.T("flag.file.branch"), Required: true}, - {Name: "new-branch", Usage: tr.T("flag.file.new_branch")}, - {Name: "message", Short: "m", Usage: tr.T("flag.file.message")}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - path, err := ctx.RequireArg("path") - if err != nil { - return err - } - branch, err := ctx.RequireArg("branch") - if err != nil { - return err - } - message := ctx.Arg("message") - if message == "" { - message = fmt.Sprintf("delete %s", path) - } - payload := map[string]interface{}{ - "files": []map[string]interface{}{ - { - "action_type": "delete", - "file_path": path, - "content": "", - "encoding": "text", - }, - }, - "branch": branch, - "message": message, - } - if nb := ctx.Arg("new-branch"); nb != "" { - payload["new_branch"] = nb - } - env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/contents/batch", payload) - if err != nil { - return err - } - return ctx.Output(env) - }, - } -} - -// resolveContent reads file content from --content or --content-file. -func resolveContent(ctx *common.RuntimeContext) (string, error) { - content := ctx.Arg("content") - contentFile := ctx.Arg("content-file") - if content != "" && contentFile != "" { - return "", fmt.Errorf("use only one of --content or --content-file") - } - if contentFile != "" { - data, err := os.ReadFile(contentFile) - if err != nil { - return "", fmt.Errorf("read content file: %w", err) - } - return string(data), nil - } - if content == "" { - return "", fmt.Errorf("one of --content or --content-file is required") - } - return content, nil -} - -// printRawContent extracts and prints the decoded file content from an API -// response (entries object, readme object, or a bare content field). -func printRawContent(data interface{}) error { - content, encoding, ok := extractContent(data) - if !ok { - return fmt.Errorf("no file content in response (is the path a directory?)") - } - if encoding == "base64" { - if decoded, err := base64.StdEncoding.DecodeString(content); err == nil { - fmt.Print(string(decoded)) - return nil - } - } - fmt.Print(content) - return nil -} - -func extractContent(data interface{}) (content, encoding string, ok bool) { - m, isMap := data.(map[string]interface{}) - if !isMap { - return "", "", false - } - if entries, has := m["entries"]; has { - if em, isEM := entries.(map[string]interface{}); isEM { - m = em - } - } - c, has := m["content"].(string) - if !has { - return "", "", false - } - if t, hasType := m["type"].(string); hasType && t != "file" { - return "", "", false - } - enc, _ := m["encoding"].(string) - return c, enc, true -} diff --git a/shortcuts/file/file_test.go b/shortcuts/file/file_test.go index 4ba62bf..30c8e4b 100644 --- a/shortcuts/file/file_test.go +++ b/shortcuts/file/file_test.go @@ -4,208 +4,191 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func TestFileView(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/owner/repo/sub_entries.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - if got := r.URL.Query().Get("filepath"); got != "README.md" { - t.Fatalf("filepath query = %q, want README.md", got) - } - writeJSON(t, w, map[string]interface{}{ - "entries": map[string]interface{}{ - "name": "README.md", "type": "file", "content": "# hello", - }, - }) - })) - defer server.Close() - - if err := runFileShortcut(t, server, "view", map[string]string{"path": "README.md"}); err != nil { - t.Fatalf("view shortcut failed: %v", err) - } -} - -func TestFileSearch(t *testing.T) { +func TestFileList(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" || r.URL.Path != "/owner/repo/files.json" { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - if got := r.URL.Query().Get("search"); got != "main" { - t.Fatalf("search query = %q, want main", got) - } - writeJSON(t, w, []interface{}{}) + writeJSON(t, w, []map[string]interface{}{ + {"name": "README.md", "path": "README.md", "type": "file"}, + {"name": "src", "path": "src", "type": "dir"}, + }) })) defer server.Close() - if err := runFileShortcut(t, server, "search", map[string]string{"keyword": "main"}); err != nil { - t.Fatalf("search shortcut failed: %v", err) + if err := runFileShortcut(t, server, "list", map[string]string{}); err != nil { + t.Fatalf("list failed: %v", err) + } +} + +func TestFileListWithRef(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("ref") != "dev" { + t.Fatalf("expected ref=dev, got %s", r.URL.Query().Get("ref")) + } + writeJSON(t, w, []map[string]interface{}{}) + })) + defer server.Close() + + if err := runFileShortcut(t, server, "list", map[string]string{"ref": "dev"}); err != nil { + t.Fatalf("list with ref failed: %v", err) + } +} + +func TestFileTree(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/git/trees/master.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "entries": []map[string]interface{}{{"name": "main.go", "type": "file"}}, + }) + })) + defer server.Close() + + if err := runFileShortcut(t, server, "tree", map[string]string{}); err != nil { + t.Fatalf("tree failed: %v", err) + } +} + +func TestFileTreeRecursive(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("recursive") != "true" { + t.Fatalf("expected recursive=true") + } + writeJSON(t, w, map[string]interface{}{"entries": []map[string]interface{}{}}) + })) + defer server.Close() + + if err := runFileShortcut(t, server, "tree", map[string]string{"recursive": "true"}); err != nil { + t.Fatalf("tree recursive failed: %v", err) + } +} + +func TestFileGet(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/sub_entries.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.URL.Query().Get("filepath") != "README.md" { + t.Fatalf("expected filepath=README.md, got %s", r.URL.Query().Get("filepath")) + } + writeJSON(t, w, map[string]interface{}{"name": "README.md", "type": "file"}) + })) + defer server.Close() + + err := runFileShortcut(t, server, "get", map[string]string{"path": "README.md"}) + if err != nil { + t.Fatalf("get failed: %v", err) + } +} + +func TestFileGetRequiresPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no request should be made without --path") + })) + defer server.Close() + + err := runFileShortcut(t, server, "get", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing --path") } } func TestFileCreate(t *testing.T) { - var payload map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/contents/batch.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + if r.Method == "POST" && r.URL.Path == "/owner/repo/create_file.json" { + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + if payload["filepath"] != "test.txt" { + t.Fatalf("expected filepath=test.txt, got %v", payload["filepath"]) + } + if payload["message"] != "add test" { + t.Fatalf("expected message=add test, got %v", payload["message"]) + } + if payload["branch"] != "master" { + t.Fatalf("expected branch=master, got %v", payload["branch"]) + } + if _, ok := payload["content"].(string); !ok || payload["content"] == "" { + t.Fatal("content should be a non-empty Base64 string") + } + writeJSON(t, w, map[string]interface{}{"name": "test.txt", "sha": "abc123"}) + return } - json.NewDecoder(r.Body).Decode(&payload) - writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "abc"}}) + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) })) defer server.Close() err := runFileShortcut(t, server, "create", map[string]string{ - "path": "notes.md", "content": "hello", "branch": "master", "message": "add notes", + "path": "test.txt", "content": "hello world", "message": "add test", }) if err != nil { - t.Fatalf("create shortcut failed: %v", err) - } - if payload["branch"] != "master" || payload["message"] != "add notes" { - t.Fatalf("payload = %v", payload) - } - files := payload["files"].([]interface{}) - f := files[0].(map[string]interface{}) - if f["action_type"] != "create" || f["file_path"] != "notes.md" || f["encoding"] != "text" { - t.Fatalf("file entry = %v", f) - } - if f["content"] != "hello" { - t.Fatalf("content = %v, want hello", f["content"]) - } -} - -func TestFileUpdateFromContentFile(t *testing.T) { - dir := t.TempDir() - local := filepath.Join(dir, "input.txt") - os.WriteFile(local, []byte("updated"), 0600) - - var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/contents/batch.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - json.NewDecoder(r.Body).Decode(&payload) - writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "def"}}) - })) - defer server.Close() - - err := runFileShortcut(t, server, "update", map[string]string{ - "path": "notes.md", "content-file": local, "branch": "master", "new-branch": "feature/x", - }) - if err != nil { - t.Fatalf("update shortcut failed: %v", err) - } - if payload["new_branch"] != "feature/x" { - t.Fatalf("new_branch = %v", payload["new_branch"]) - } - f := payload["files"].([]interface{})[0].(map[string]interface{}) - if f["action_type"] != "update" { - t.Fatalf("action_type = %v", f["action_type"]) - } - if f["content"] != "updated" { - t.Fatalf("content = %v, want updated", f["content"]) + t.Fatalf("create failed: %v", err) } } func TestFileDelete(t *testing.T) { - var payload map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/contents/batch.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + if r.Method == "DELETE" && r.URL.Path == "/owner/repo/delete_file.json" { + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + if payload["filepath"] != "old.txt" { + t.Fatalf("expected filepath=old.txt, got %v", payload["filepath"]) + } + if payload["sha"] != "def456" { + t.Fatalf("expected sha=def456, got %v", payload["sha"]) + } + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + return } - json.NewDecoder(r.Body).Decode(&payload) - writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "ghi"}}) + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) })) defer server.Close() err := runFileShortcut(t, server, "delete", map[string]string{ - "path": "notes.md", "branch": "master", + "path": "old.txt", "sha": "def456", "message": "remove old", }) if err != nil { - t.Fatalf("delete shortcut failed: %v", err) - } - f := payload["files"].([]interface{})[0].(map[string]interface{}) - if f["action_type"] != "delete" || f["file_path"] != "notes.md" { - t.Fatalf("file entry = %v", f) - } - if payload["message"] != "delete notes.md" { - t.Fatalf("default message = %v", payload["message"]) + t.Fatalf("delete failed: %v", err) } } -func TestFileCreateContentConflicts(t *testing.T) { +func TestFileDeleteRequiresPath(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("should not reach server") + t.Fatal("no request should be made without --path") })) defer server.Close() - err := runFileShortcut(t, server, "create", map[string]string{ - "path": "a", "content": "x", "content-file": "y", "branch": "master", - }) + err := runFileShortcut(t, server, "delete", map[string]string{}) if err == nil { - t.Fatal("expected error for both --content and --content-file") - } - - err = runFileShortcut(t, server, "create", map[string]string{ - "path": "a", "branch": "master", - }) - if err == nil { - t.Fatal("expected error when no content source is provided") + t.Fatal("expected error for missing --path") } } -func TestExtractContent(t *testing.T) { - tests := []struct { - name string - data interface{} - wantContent string - wantEncoding string - wantOK bool - }{ - {"entries object", map[string]interface{}{"entries": map[string]interface{}{"type": "file", "content": "abc"}}, "abc", "", true}, - {"readme object", map[string]interface{}{"type": "file", "content": "abc", "encoding": "base64"}, "abc", "base64", true}, - {"directory", map[string]interface{}{"type": "dir", "content": "x"}, "", "", false}, - {"no content", map[string]interface{}{"type": "file"}, "", "", false}, - {"not a map", []interface{}{}, "", "", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - content, encoding, ok := extractContent(tt.data) - if content != tt.wantContent || encoding != tt.wantEncoding || ok != tt.wantOK { - t.Fatalf("extractContent() = (%q, %q, %v), want (%q, %q, %v)", - content, encoding, ok, tt.wantContent, tt.wantEncoding, tt.wantOK) - } - }) - } -} +// === helpers === func runFileShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() shortcut := findFileShortcut(t, name) ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", Repo: "repo", Format: "json", Args: args, } return shortcut.Run(ctx) } func findFileShortcut(t *testing.T, name string) *common.Shortcut { t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut + for _, s := range Shortcuts() { + if s.Name == name { + return s } } t.Fatalf("shortcut %q not found", name) @@ -215,49 +198,5 @@ func findFileShortcut(t *testing.T, name string) *common.Shortcut { func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { t.Helper() w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) - } -} - -func TestFileBatchPostsAllOperations(t *testing.T) { - dir := t.TempDir() - spec := filepath.Join(dir, "spec.json") - os.WriteFile(spec, []byte(`[ - {"action_type": "create", "file_path": "a.txt", "content": "A"}, - {"action_type": "delete", "file_path": "b.txt"} -]`), 0600) - - var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/contents/batch.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - json.NewDecoder(r.Body).Decode(&payload) - writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "abc"}}) - })) - defer server.Close() - - err := runFileShortcut(t, server, "batch", map[string]string{ - "spec": spec, "branch": "master", "message": "batch ops", - }) - if err != nil { - t.Fatalf("batch shortcut failed: %v", err) - } - files := payload["files"].([]interface{}) - if len(files) != 2 { - t.Fatalf("expected 2 files, got %d", len(files)) - } - del := files[1].(map[string]interface{}) - if del["action_type"] != "delete" || del["content"] != "" || del["encoding"] != "text" { - t.Fatalf("delete entry not normalized: %v", del) - } - - bad := filepath.Join(dir, "bad.json") - os.WriteFile(bad, []byte(`[{"action_type": "rename", "file_path": "x"}]`), 0600) - if err := runFileShortcut(t, server, "batch", map[string]string{ - "spec": bad, "branch": "master", "message": "m", - }); err == nil { - t.Fatal("expected error for invalid action_type") - } + json.NewEncoder(w).Encode(payload) } diff --git a/shortcuts/member/batch.go b/shortcuts/member/batch.go new file mode 100644 index 0000000..76a2a14 --- /dev/null +++ b/shortcuts/member/batch.go @@ -0,0 +1,204 @@ +package member + +import ( + "encoding/csv" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type batchAddResult struct { + User string `json:"user" yaml:"user"` + Action string `json:"action" yaml:"action"` + Status string `json:"status" yaml:"status"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` +} + +type batchAddSummary struct { + Owner string `json:"owner" yaml:"owner"` + Repo string `json:"repo" yaml:"repo"` + DryRun bool `json:"dry_run" yaml:"dry_run"` + Total int `json:"total" yaml:"total"` + Succeeded int `json:"succeeded" yaml:"succeeded"` + Failed int `json:"failed" yaml:"failed"` + Duration string `json:"duration" yaml:"duration"` + Results []batchAddResult `json:"results" yaml:"results"` +} + +func batchAddShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-add", + Description: "批量添加成员到项目,支持逗号分隔列表或 CSV 文件", + Flags: []common.Flag{ + {Name: "users", Short: "u", Usage: "逗号分隔的用户数字 ID,例如: 42,99,105"}, + {Name: "from", Usage: "从 CSV 文件读取用户 ID。支持 user_id/id/user 列名或无表头首列"}, + {Name: "dry-run", Usage: "仅预览将要添加的成员,不实际执行", Bool: true, Default: "false"}, + }, + Run: runBatchAdd, + } +} + +func runBatchAdd(ctx *common.RuntimeContext) error { + start := time.Now() + + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + userIDs, err := collectUserIDs(ctx.Arg("users"), ctx.Arg("from")) + if err != nil { + return err + } + if len(userIDs) == 0 { + return fmt.Errorf("未提供用户 ID,请使用 --users 42,99 或 --from users.csv") + } + + dryRun := parseMemberBool(ctx.Arg("dry-run")) + + summary := batchAddSummary{ + Owner: ctx.Owner, + Repo: ctx.Repo, + DryRun: dryRun, + Total: len(userIDs), + Results: make([]batchAddResult, 0, len(userIDs)), + } + + for _, uid := range userIDs { + result := batchAddResult{User: uid, Action: "add"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + id, _ := strconv.ParseInt(uid, 10, 64) + body := map[string]interface{}{"user_id": id} + if _, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "added" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + summary.Duration = time.Since(start).String() + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d / %d 个成员添加失败", summary.Failed, summary.Total) + } + return nil +} + +func collectUserIDs(usersValue, csvPath string) ([]string, error) { + ids, err := parseUserIDList(usersValue) + if err != nil { + return nil, err + } + if csvPath == "" { + return ids, nil + } + + csvIDs, err := readUserIDsFromCSV(csvPath) + if err != nil { + return nil, err + } + return mergeUserIDLists(ids, csvIDs), nil +} + +func parseUserIDList(value string) ([]string, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + return normalizeUserIDs(strings.Split(value, ",")) +} + +func readUserIDsFromCSV(path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("读取 CSV 文件失败: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("解析 CSV 文件失败: %w", err) + } + if len(records) == 0 { + return nil, nil + } + + idCol := -1 + startRow := 0 + for i, cell := range records[0] { + switch strings.ToLower(strings.TrimSpace(cell)) { + case "user_id", "id", "user", "uid": + idCol = i + startRow = 1 + } + } + if idCol == -1 { + idCol = 0 + } + + values := make([]string, 0, len(records)-startRow) + for _, record := range records[startRow:] { + if idCol >= len(record) { + continue + } + values = append(values, record[idCol]) + } + return normalizeUserIDs(values) +} + +func normalizeUserIDs(values []string) ([]string, error) { + ids := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + id := strings.TrimSpace(value) + if id == "" { + continue + } + if _, err := strconv.ParseInt(id, 10, 64); err != nil { + return nil, fmt.Errorf("无效的用户 ID %q: 必须是整数", id) + } + if seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + return ids, nil +} + +func mergeUserIDLists(values ...[]string) []string { + merged := []string{} + seen := map[string]bool{} + for _, ids := range values { + for _, id := range ids { + if seen[id] { + continue + } + seen[id] = true + merged = append(merged, id) + } + } + return merged +} + +func parseMemberBool(value string) bool { + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + return err == nil && parsed +} diff --git a/shortcuts/org/batch.go b/shortcuts/org/batch.go new file mode 100644 index 0000000..5abc987 --- /dev/null +++ b/shortcuts/org/batch.go @@ -0,0 +1,336 @@ +package org + +import ( + "encoding/csv" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type batchInviteResult struct { + User string `json:"user" yaml:"user"` + Action string `json:"action" yaml:"action"` + Status string `json:"status" yaml:"status"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` +} + +type batchInviteSummary struct { + Org string `json:"org" yaml:"org"` + DryRun bool `json:"dry_run" yaml:"dry_run"` + Total int `json:"total" yaml:"total"` + Succeeded int `json:"succeeded" yaml:"succeeded"` + Failed int `json:"failed" yaml:"failed"` + Duration string `json:"duration" yaml:"duration"` + Results []batchInviteResult `json:"results" yaml:"results"` +} + +func newBatchInviteShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-invite", + Description: "批量邀请成员加入组织,支持逗号分隔列表或 CSV 文件", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "组织 ID 或 login", Required: true}, + {Name: "users", Short: "u", Usage: "逗号分隔的用户名或 ID,例如: alice,bob,charlie"}, + {Name: "from", Usage: "从 CSV 文件读取用户名。支持 user/login/user_id 列名或无表头首列"}, + {Name: "role", Short: "r", Usage: "成员角色: member 或 admin", Default: "member"}, + {Name: "dry-run", Usage: "仅预览将要邀请的成员,不实际执行", Bool: true, Default: "false"}, + }, + Run: runBatchInvite, + } +} + +func runBatchInvite(ctx *common.RuntimeContext) error { + start := time.Now() + + orgID, err := ctx.RequireArg("id") + if err != nil { + return err + } + + role := ctx.Arg("role") + if role == "" { + role = "member" + } + if role != "member" && role != "admin" { + return fmt.Errorf("无效的角色 %q: 必须为 member 或 admin", role) + } + + userInputs, err := collectUsers(ctx.Arg("users"), ctx.Arg("from")) + if err != nil { + return err + } + if len(userInputs) == 0 { + return fmt.Errorf("未提供用户名,请使用 --users alice,bob 或 --from users.csv") + } + + // 将用户名解析为数字 ID(如果传入的已经是数字则直接使用) + resolvedUsers, resolveErrors := resolveUserIDs(ctx, userInputs) + + dryRun := parseBool(ctx.Arg("dry-run")) + + summary := batchInviteSummary{ + Org: orgID, + DryRun: dryRun, + Total: len(userInputs), + Results: make([]batchInviteResult, 0, len(userInputs)), + } + + // 先记录解析失败的 + for input, errMsg := range resolveErrors { + summary.Results = append(summary.Results, batchInviteResult{ + User: input, + Action: "invite", + Status: "failed", + Error: errMsg, + }) + summary.Failed++ + } + + for _, ru := range resolvedUsers { + displayName := ru.Input + if ru.Input != ru.UserID { + displayName = fmt.Sprintf("%s (ID:%s)", ru.Input, ru.UserID) + } + result := batchInviteResult{User: displayName, Action: "invite"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + userIDInt, _ := strconv.ParseInt(ru.UserID, 10, 64) + body := map[string]interface{}{ + "user_id": userIDInt, + "role": role, + } + path := fmt.Sprintf("/organizations/%s/organization_users", orgID) + if _, err := ctx.CallAPI("POST", path, body); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "invited" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + summary.Duration = time.Since(start).String() + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d / %d 个成员邀请失败", summary.Failed, summary.Total) + } + return nil +} + +func collectUsers(usersValue, csvPath string) ([]string, error) { + users, err := parseUserList(usersValue) + if err != nil { + return nil, err + } + if csvPath == "" { + return users, nil + } + + csvUsers, err := readUsersFromCSV(csvPath) + if err != nil { + return nil, err + } + return mergeUserLists(users, csvUsers), nil +} + +func parseUserList(value string) ([]string, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + return normalizeUserIDs(strings.Split(value, ",")) +} + +func readUsersFromCSV(path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("读取 CSV 文件失败: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("解析 CSV 文件失败: %w", err) + } + if len(records) == 0 { + return nil, nil + } + + userCol := -1 + startRow := 0 + for i, cell := range records[0] { + switch strings.ToLower(strings.TrimSpace(cell)) { + case "user", "login", "user_id", "username": + userCol = i + startRow = 1 + } + } + if userCol == -1 { + userCol = 0 + } + + values := make([]string, 0, len(records)-startRow) + for _, record := range records[startRow:] { + if userCol >= len(record) { + continue + } + values = append(values, record[userCol]) + } + return normalizeUserIDs(values) +} + +func normalizeUserIDs(values []string) ([]string, error) { + users := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + user := strings.TrimSpace(value) + if user == "" { + continue + } + if seen[user] { + continue + } + seen[user] = true + users = append(users, user) + } + return users, nil +} + +func parseBool(value string) bool { + if strings.EqualFold(strings.TrimSpace(value), "true") { + return true + } + return false +} + +func mergeUserLists(values ...[]string) []string { + merged := []string{} + seen := map[string]bool{} + for _, users := range values { + for _, u := range users { + if seen[u] { + continue + } + seen[u] = true + merged = append(merged, u) + } + } + return merged +} + +// resolvedUser holds the mapping from user input to numeric ID. +type resolvedUser struct { + Input string // original input (username or numeric string) + UserID string // resolved numeric user ID +} + +// resolveUserIDs converts usernames to numeric user IDs via the search API. +// If an input is already numeric, it is used directly. +func resolveUserIDs(ctx *common.RuntimeContext, inputs []string) ([]resolvedUser, map[string]string) { + results := make([]resolvedUser, 0, len(inputs)) + errors := make(map[string]string) + + for _, input := range inputs { + // 如果已经是纯数字,直接使用 + if _, err := strconv.Atoi(input); err == nil { + results = append(results, resolvedUser{Input: input, UserID: input}) + continue + } + + // 通过搜索 API 查找用户名对应的数字 ID + q := url.Values{} + q.Set("search", input) + q.Set("limit", "5") + env, err := ctx.CallAPIWithQuery("GET", "/users/list", q) + if err != nil { + errors[input] = fmt.Sprintf("查找用户失败: %v", err) + continue + } + + // env.Data 是 {"total_count":N, "users":[...]} 的 map 结构 + users := extractUsers(env.Data) + if len(users) == 0 { + errors[input] = fmt.Sprintf("未找到用户 %q", input) + continue + } + + // 精确匹配用户名 + matched := users[0] + for _, u := range users { + if u.Login == input { + matched = u + break + } + } + + results = append(results, resolvedUser{ + Input: input, + UserID: strconv.Itoa(matched.UserID), + }) + } + + return results, errors +} + +// searchUser holds a parsed user from search results. +type searchUser struct { + Login string + UserID int +} + +// extractUsers extracts the user list from the search API response data. +// data is expected to be map[string]interface{} with a "users" key containing a slice. +func extractUsers(data interface{}) []searchUser { + if data == nil { + return nil + } + m, ok := data.(map[string]interface{}) + if !ok { + return nil + } + rawUsers, ok := m["users"] + if !ok { + return nil + } + usersSlice, ok := rawUsers.([]interface{}) + if !ok { + return nil + } + var result []searchUser + for _, item := range usersSlice { + um, ok := item.(map[string]interface{}) + if !ok { + continue + } + login, _ := um["login"].(string) + userID := 0 + switch v := um["user_id"].(type) { + case float64: + userID = int(v) + case int: + userID = v + case string: + userID, _ = strconv.Atoi(v) + } + if login != "" && userID > 0 { + result = append(result, searchUser{Login: login, UserID: userID}) + } + } + return result +} diff --git a/shortcuts/repo/batch.go b/shortcuts/repo/batch.go new file mode 100644 index 0000000..d8fb88e --- /dev/null +++ b/shortcuts/repo/batch.go @@ -0,0 +1,378 @@ +package repo + +import ( + "encoding/csv" + "fmt" + "os" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type batchRepoResult struct { + Repo string `json:"repo" yaml:"repo"` + Action string `json:"action" yaml:"action"` + Status string `json:"status" yaml:"status"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` +} + +type batchRepoSummary struct { + Total int `json:"total" yaml:"total"` + Succeeded int `json:"succeeded" yaml:"succeeded"` + Failed int `json:"failed" yaml:"failed"` + Duration string `json:"duration" yaml:"duration"` + Results []batchRepoResult `json:"results" yaml:"results"` +} + +func newBatchCreateShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-create", + Description: "批量创建仓库,支持逗号分隔列表或 CSV 文件", + Flags: []common.Flag{ + {Name: "repos", Short: "r", Usage: "逗号分隔的仓库名称,例如: repo1,repo2,repo3"}, + {Name: "from", Usage: "从 CSV 文件读取仓库名称。支持 name/repo/repository 列名或无表头首列"}, + {Name: "description", Short: "d", Usage: "仓库描述(所有仓库共用一个描述)"}, + {Name: "private", Usage: "设为私有仓库 (true/false)", Default: "false"}, + {Name: "dry-run", Usage: "仅预览将要创建的仓库,不实际执行", Bool: true, Default: "false"}, + }, + Run: runBatchCreate, + } +} + +func runBatchCreate(ctx *common.RuntimeContext) error { + start := time.Now() + + repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from")) + if err != nil { + return err + } + if len(repos) == 0 { + return fmt.Errorf("未提供仓库名称,请使用 --repos repo1,repo2 或 --from repos.csv") + } + + // 仅取仓库名,不需要 owner/repo 格式 + names := make([]string, len(repos)) + for i, r := range repos { + parts := strings.SplitN(r, "/", 2) + names[i] = parts[len(parts)-1] + } + + dryRun := parseBool(ctx.Arg("dry-run")) + summary := batchRepoSummary{ + Total: len(names), + Results: make([]batchRepoResult, 0, len(names)), + } + + var userLogin string + var userID int + if !dryRun { + userEnv, err := ctx.CallAPI("GET", "/users/me", nil) + if err != nil { + return fmt.Errorf("获取当前用户信息失败: %w", err) + } + userData, _ := userEnv.Data.(map[string]interface{}) + login, _ := userData["login"].(string) + if login == "" { + return fmt.Errorf("无法获取当前用户名") + } + userLogin = login + uid, _ := userData["user_id"].(float64) + userID = int(uid) + } + + private := ctx.Arg("private") == "true" + desc := ctx.Arg("description") + + for _, name := range names { + result := batchRepoResult{Repo: name, Action: "create"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + body := map[string]interface{}{ + "name": name, + "repository_name": name, + "user_id": userID, + } + if desc != "" { + body["description"] = desc + } + if private { + body["private"] = true + } + + path := fmt.Sprintf("/%s/%s", userLogin, name) + if _, err := ctx.CallAPI("POST", path, body); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "created" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + summary.Duration = time.Since(start).String() + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d / %d 个仓库创建失败", summary.Failed, summary.Total) + } + return nil +} + +func newBatchForkShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-fork", + Description: "批量 Fork 仓库,支持逗号分隔列表或 CSV 文件", + Flags: []common.Flag{ + {Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo,例如: alice/proj1,bob/proj2"}, + {Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo 单列或 owner、repo 双列格式"}, + {Name: "dry-run", Usage: "仅预览将要 Fork 的仓库,不实际执行", Bool: true, Default: "false"}, + }, + Run: runBatchFork, + } +} + +func runBatchFork(ctx *common.RuntimeContext) error { + start := time.Now() + + repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from")) + if err != nil { + return err + } + if len(repos) == 0 { + return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + summary := batchRepoSummary{ + Total: len(repos), + Results: make([]batchRepoResult, 0, len(repos)), + } + + for _, repoID := range repos { + parts := strings.SplitN(repoID, "/", 2) + result := batchRepoResult{Repo: repoID, Action: "fork"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + path := fmt.Sprintf("/%s/%s/forks", parts[0], parts[1]) + if _, err := ctx.CallAPI("POST", path, nil); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "forked" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + summary.Duration = time.Since(start).String() + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d / %d 个仓库 Fork 失败", summary.Failed, summary.Total) + } + return nil +} + +func newBatchDeleteShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-delete", + Description: "批量删除仓库,支持逗号分隔列表或 CSV 文件", + Flags: []common.Flag{ + {Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo,例如: alice/proj1,bob/proj2"}, + {Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo 单列或 owner、repo 双列格式"}, + {Name: "dry-run", Usage: "仅预览将要删除的仓库,不实际执行", Bool: true, Default: "false"}, + }, + Run: runBatchDelete, + } +} + +func runBatchDelete(ctx *common.RuntimeContext) error { + start := time.Now() + + repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from")) + if err != nil { + return err + } + if len(repos) == 0 { + return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + summary := batchRepoSummary{ + Total: len(repos), + Results: make([]batchRepoResult, 0, len(repos)), + } + + for _, repoID := range repos { + parts := strings.SplitN(repoID, "/", 2) + result := batchRepoResult{Repo: repoID, Action: "delete"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + path := fmt.Sprintf("/%s/%s", parts[0], parts[1]) + if _, err := ctx.CallAPI("DELETE", path, nil); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "deleted" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + summary.Duration = time.Since(start).String() + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d / %d 个仓库删除失败", summary.Failed, summary.Total) + } + return nil +} + +// --- CSV / list helpers --- + +func collectRepos(reposValue, csvPath string) ([]string, error) { + repos, err := parseRepoList(reposValue) + if err != nil { + return nil, err + } + if csvPath == "" { + return repos, nil + } + + csvRepos, err := readReposFromCSV(csvPath) + if err != nil { + return nil, err + } + return mergeRepoStrings(repos, csvRepos), nil +} + +func parseRepoList(value string) ([]string, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + return normalizeRepoIDs(strings.Split(value, ",")) +} + +func readReposFromCSV(path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("读取 CSV 文件失败: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("解析 CSV 文件失败: %w", err) + } + if len(records) == 0 { + return nil, nil + } + + singleCol, ownerCol, repoCol := -1, -1, -1 + startRow := 0 + for i, cell := range records[0] { + switch strings.ToLower(strings.TrimSpace(cell)) { + case "owner/repo", "full_name": + singleCol = i + startRow = 1 + case "owner": + ownerCol = i + startRow = 1 + case "repo", "repository", "name": + if repoCol == -1 { + repoCol = i + } + startRow = 1 + } + } + + if ownerCol == -1 || repoCol == -1 { + // Not dual-column: use single-column mode + if singleCol == -1 { + singleCol = 0 + } + ownerCol = -1 + repoCol = -1 + } + + values := make([]string, 0, len(records)-startRow) + for _, record := range records[startRow:] { + var repoID string + if singleCol >= 0 && singleCol < len(record) { + repoID = record[singleCol] + } else if ownerCol >= 0 && repoCol >= 0 && ownerCol < len(record) && repoCol < len(record) { + repoID = record[ownerCol] + "/" + record[repoCol] + } else { + continue + } + values = append(values, repoID) + } + return normalizeRepoIDs(values) +} + +func normalizeRepoIDs(values []string) ([]string, error) { + repos := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + repoID := strings.TrimSpace(value) + if repoID == "" { + continue + } + if seen[repoID] { + continue + } + seen[repoID] = true + repos = append(repos, repoID) + } + return repos, nil +} + +func mergeRepoStrings(values ...[]string) []string { + merged := []string{} + seen := map[string]bool{} + for _, repos := range values { + for _, r := range repos { + if seen[r] { + continue + } + seen[r] = true + merged = append(merged, r) + } + } + return merged +} + +func parseBool(value string) bool { + if strings.EqualFold(strings.TrimSpace(value), "true") { + return true + } + return false +} diff --git a/shortcuts/star/star.go b/shortcuts/star/star.go new file mode 100644 index 0000000..c9a0cac --- /dev/null +++ b/shortcuts/star/star.go @@ -0,0 +1,85 @@ +package star + +import ( + "fmt" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "star", + Description: "Star (like) a repository", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + projectID, err := resolveProjectID(ctx) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", + fmt.Sprintf("/projects/%d/praise_tread/like", projectID), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "unstar", + Description: "Unstar (unlike) a repository", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + projectID, err := resolveProjectID(ctx) + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", + fmt.Sprintf("/projects/%d/praise_tread/unlike", projectID), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "stars", + Description: "List stargazers of a repository", + Flags: []common.Flag{ + {Name: "owner", Short: "o", Usage: "Repository owner", Required: true}, + {Name: "repo", Short: "r", Usage: "Repository name", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + owner, _ := ctx.RequireArg("owner") + repo, _ := ctx.RequireArg("repo") + env, err := ctx.CallAPI("GET", + fmt.Sprintf("/%s/%s/stargazers", owner, repo), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func resolveProjectID(ctx *common.RuntimeContext) (int64, error) { + env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) + if err != nil { + return 0, fmt.Errorf("failed to get project info: %w", err) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("unexpected project info response") + } + for _, key := range []string{"id", "project_id", "repo_id"} { + if id, ok := data[key].(float64); ok { + return int64(id), nil + } + } + return 0, fmt.Errorf("cannot find project id in response") +} diff --git a/shortcuts/star/star_test.go b/shortcuts/star/star_test.go new file mode 100644 index 0000000..88718e1 --- /dev/null +++ b/shortcuts/star/star_test.go @@ -0,0 +1,101 @@ +package star + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestStar(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + writeJSON(t, w, map[string]interface{}{"id": float64(100)}) + case r.Method == "POST" && r.URL.Path == "/projects/100/praise_tread/like.json": + writeJSON(t, w, map[string]interface{}{}) + default: + t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path) + } + })) + defer server.Close() + + if err := runStarShortcut(t, server, "star", map[string]string{}); err != nil { + t.Fatalf("star failed: %v", err) + } + if callCount != 2 { + t.Fatalf("expected 2 API calls, got %d", callCount) + } +} + +func TestUnstar(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + writeJSON(t, w, map[string]interface{}{"id": float64(100)}) + case r.Method == "DELETE" && r.URL.Path == "/projects/100/praise_tread/unlike.json": + writeJSON(t, w, map[string]interface{}{}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + if err := runStarShortcut(t, server, "unstar", map[string]string{}); err != nil { + t.Fatalf("unstar failed: %v", err) + } +} + +func TestStars(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/stargazers.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "count": 1, + "users": []map[string]interface{}{{"login": "alice"}}, + }) + })) + defer server.Close() + + err := runStarShortcut(t, server, "stars", map[string]string{ + "owner": "owner", "repo": "repo", + }) + if err != nil { + t.Fatalf("stars failed: %v", err) + } +} + +func runStarShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findStarShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", Repo: "repo", Format: "json", Args: args, + } + return shortcut.Run(ctx) +} + +func findStarShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(payload) +} diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index 70e19fe..c039386 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -2,260 +2,301 @@ package wiki import ( "encoding/base64" + "encoding/json" "fmt" "net/url" - "strconv" "strings" + "time" - "github.com/gitlink-org/gitlink-cli/internal/config" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// switchToGateway overrides the client base URL with the gateway URL from config. -func switchToGateway(ctx *common.RuntimeContext) error { - cfg, err := config.Load() - if err != nil { - return err - } - if cfg.GatewayURL == "" { - cfg.GatewayURL = config.DefaultGatewayURL - } - ctx.Client.BaseURL = cfg.GatewayURL - return nil -} +const wikiBaseURL = "https://gateway.gitlink.org.cn/api" -// gatewayFlag returns the common --gateway flag definition. -func gatewayFlag() common.Flag { - return common.Flag{Name: "gateway", Short: "g", Usage: "Use gateway API endpoint", Bool: true} -} - -// projectIDFlag returns the common --project-id flag definition. -func projectIDFlag() common.Flag { - return common.Flag{Name: "project-id", Usage: "GitLink project ID (auto-resolved from the repository when omitted)"} -} - -// resolveProjectID returns the explicit --project-id value, or resolves it -// from the repository detail endpoint on the main API. It must be called -// before switching the client to the gateway base URL. -func resolveProjectID(ctx *common.RuntimeContext) (string, error) { - if raw := strings.TrimSpace(ctx.Arg("project-id")); raw != "" { - parsed, err := strconv.ParseInt(raw, 10, 64) - if err != nil || parsed <= 0 { - return "", fmt.Errorf("invalid --project-id %q: use a positive numeric project ID", raw) - } - return strconv.FormatInt(parsed, 10), nil - } - env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) - if err != nil { - return "", fmt.Errorf("resolve project id: %w", err) - } - data, ok := env.Data.(map[string]interface{}) - if !ok { - return "", fmt.Errorf("resolve project id: unexpected repository response") - } - for _, key := range []string{"project_id", "id"} { - switch v := data[key].(type) { - case float64: - if v > 0 { - return strconv.FormatInt(int64(v), 10), nil - } - case string: - if s := strings.TrimSpace(v); s != "" { - return s, nil - } - } - } - return "", fmt.Errorf("resolve project id: repository response did not include project_id; pass --project-id explicitly") -} - -// Shortcuts returns all wiki shortcuts. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { Name: "list", Description: "List wiki pages", - Flags: []common.Flag{ - projectIDFlag(), - gatewayFlag(), - }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - projectID, err := resolveProjectID(ctx) + projectID, err := fetchProjectID(ctx) if err != nil { return err } - if ctx.Arg("gateway") == "true" { - if err := switchToGateway(ctx); err != nil { - return err - } - } q := url.Values{} q.Set("owner", ctx.Owner) q.Set("repo", ctx.Repo) - q.Set("projectId", projectID) - env, err := ctx.CallAPIWithQuery("GET", "/wiki/open/wikiPages", q) - if err != nil { - return err - } - return ctx.Output(env) + q.Set("projectId", fmt.Sprintf("%d", projectID)) + return callWikiAPI(ctx, "GET", "/wiki/open/wikiPages", nil, q) }, }, { Name: "view", - Description: "View a wiki page by page name", + Description: "View a wiki page", Flags: []common.Flag{ - projectIDFlag(), - {Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true}, - gatewayFlag(), + {Name: "name", Short: "n", Usage: "Wiki page name", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - projectID, err := resolveProjectID(ctx) + name, err := ctx.RequireArg("name") if err != nil { return err } - if ctx.Arg("gateway") == "true" { - if err := switchToGateway(ctx); err != nil { - return err - } + projectID, err := fetchProjectID(ctx) + if err != nil { + return err } q := url.Values{} q.Set("owner", ctx.Owner) q.Set("repo", ctx.Repo) - q.Set("projectId", projectID) - q.Set("pageName", ctx.Arg("page-name")) - env, err := ctx.CallAPIWithQuery("GET", "/wiki/open/getWiki", q) - if err != nil { - return err - } - return ctx.Output(env) + q.Set("projectId", fmt.Sprintf("%d", projectID)) + q.Set("pageName", name) + return callWikiAPI(ctx, "GET", "/wiki/open/getWiki", nil, q) }, }, { Name: "create", - Description: "Create a new wiki page", + Description: "Create a wiki page", Flags: []common.Flag{ - projectIDFlag(), - {Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true}, - {Name: "title", Short: "t", Usage: "Wiki page title", Required: true}, - {Name: "content", Short: "c", Usage: "Wiki page content (markdown)", Required: true}, + {Name: "name", Short: "n", Usage: "Wiki page name", Required: true}, + {Name: "content", Short: "c", Usage: "Page content (will be base64 encoded)", Required: true}, {Name: "message", Short: "m", Usage: "Commit message"}, - gatewayFlag(), }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - projectID, err := resolveProjectID(ctx) + name, err := ctx.RequireArg("name") if err != nil { return err } - if ctx.Arg("gateway") == "true" { - if err := switchToGateway(ctx); err != nil { - return err - } + content, err := ctx.RequireArg("content") + if err != nil { + return err } - content := ctx.Arg("content") - payload := map[string]interface{}{ + projectID, err := fetchProjectID(ctx) + if err != nil { + return err + } + body := map[string]interface{}{ "owner": ctx.Owner, "repo": ctx.Repo, "projectId": projectID, - "pageName": ctx.Arg("page-name"), - "title": ctx.Arg("title"), - "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), + "pageName": name, + "title": name, "message": ctx.Arg("message"), + "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), } - env, err := ctx.CallAPI("POST", "/wiki/open/createWiki", payload) - if err != nil { - return err - } - return ctx.Output(env) + return callWikiAPI(ctx, "POST", "/wiki/open/createWiki", body, nil) }, }, { Name: "update", - Description: "Update an existing wiki page", + Description: "Update a wiki page", Flags: []common.Flag{ - projectIDFlag(), - {Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true}, - {Name: "title", Short: "t", Usage: "Wiki page title", Required: true}, - {Name: "content", Short: "c", Usage: "Wiki page content (markdown)"}, + {Name: "name", Short: "n", Usage: "Wiki page name", Required: true}, + {Name: "content", Short: "c", Usage: "New page content (will be base64 encoded)", Required: true}, {Name: "message", Short: "m", Usage: "Commit message"}, - gatewayFlag(), }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title := ctx.Arg("title") - if title == "" { - return fmt.Errorf("--title is required") - } - projectID, err := resolveProjectID(ctx) + name, err := ctx.RequireArg("name") if err != nil { return err } - if ctx.Arg("gateway") == "true" { - if err := switchToGateway(ctx); err != nil { - return err - } - } - content := ctx.Arg("content") - payload := map[string]interface{}{ - "owner": ctx.Owner, - "repo": ctx.Repo, - "projectId": projectID, - "pageName": ctx.Arg("page-name"), - "title": title, - "message": ctx.Arg("message"), - } - if content != "" { - payload["content_base64"] = base64.StdEncoding.EncodeToString([]byte(content)) - } - env, err := ctx.CallAPI("PUT", "/wiki/open/updateWiki", payload) + content, err := ctx.RequireArg("content") if err != nil { return err } - return ctx.Output(env) + projectID, err := fetchProjectID(ctx) + if err != nil { + return err + } + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + "pageName": name, + "title": name, + "message": ctx.Arg("message"), + "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), + } + return callWikiAPI(ctx, "PUT", "/wiki/open/updateWiki", body, nil) }, }, { Name: "delete", - Description: "Delete a wiki page", + Description: "Delete a wiki page and remove it from sidebar", Flags: []common.Flag{ - projectIDFlag(), - {Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true}, - gatewayFlag(), + {Name: "name", Short: "n", Usage: "Wiki page name", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - projectID, err := resolveProjectID(ctx) + name, err := ctx.RequireArg("name") if err != nil { return err } - if ctx.Arg("gateway") == "true" { - if err := switchToGateway(ctx); err != nil { - return err - } + projectID, err := fetchProjectID(ctx) + if err != nil { + return err } - payload := map[string]interface{}{ + + // Step 1: Delete the wiki page + body := map[string]interface{}{ "owner": ctx.Owner, "repo": ctx.Repo, "projectId": projectID, - "pageName": ctx.Arg("page-name"), + "pageName": name, } - env, err := ctx.CallAPI("DELETE", "/wiki/open/deleteWiki", payload) - if err != nil { + if err := callWikiAPISilent(ctx, "DELETE", "/wiki/open/deleteWiki", body, nil); err != nil { return err } - return ctx.Output(env) + + // Step 2: Wait for GitLink async sidebar rebuild, then clean up + time.Sleep(2 * time.Second) + cleanSidebar(ctx, projectID, name) + + fmt.Printf("Wiki page %q deleted successfully.\n", name) + return nil }, }, } } + +// callWikiAPI sends a request to the wiki gateway. +// It switches the client BaseURL to the wiki gateway for the duration of the call, +// but skips the switch during tests (local httptest server). +func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error { + origBase := ctx.Client.BaseURL + if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") { + ctx.Client.BaseURL = wikiBaseURL + } + defer func() { ctx.Client.BaseURL = origBase }() + + env, err := ctx.Client.DoRaw(method, path, body, query) + if err != nil { + return err + } + return ctx.Output(env) +} + +// fetchProjectID resolves the numeric project ID from the repo info API. +func fetchProjectID(ctx *common.RuntimeContext) (int64, error) { + env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) + if err != nil { + return 0, fmt.Errorf("failed to get project info: %w", err) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("unexpected project info response") + } + for _, key := range []string{"project_id", "repo_id", "id"} { + if id, ok := data[key].(float64); ok { + return int64(id), nil + } + } + return 0, fmt.Errorf("project id not found in response") +} + +// callWikiAPISilent is like callWikiAPI but does not print output. +func callWikiAPISilent(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error { + origBase := ctx.Client.BaseURL + if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") { + ctx.Client.BaseURL = wikiBaseURL + } + defer func() { ctx.Client.BaseURL = origBase }() + + _, err := ctx.Client.DoRaw(method, path, body, query) + return err +} + +const sidebarPageName = "_Sidebar" // GitLink uses capital S for the sidebar page + +// cleanSidebar fetches the wiki sidebar, removes the deleted page link, and updates it. +func cleanSidebar(ctx *common.RuntimeContext, projectID int64, pageName string) { + // Fetch sidebar + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("projectId", fmt.Sprintf("%d", projectID)) + q.Set("pageName", sidebarPageName) + + origBase := ctx.Client.BaseURL + if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") { + ctx.Client.BaseURL = wikiBaseURL + } + defer func() { ctx.Client.BaseURL = origBase }() + + env, err := ctx.Client.DoRaw("GET", "/wiki/open/getWiki", nil, q) + if err != nil { + return // sidebar might not exist, silently skip + } + + // Extract content_base64 from response. + // DoRaw auto-parses JSON, so env.Data is a map with "data" as either + // a nested dict (already parsed) or a JSON string (needs parsing). + outer, ok := env.Data.(map[string]interface{}) + if !ok { + return + } + + var inner map[string]interface{} + switch v := outer["data"].(type) { + case map[string]interface{}: + inner = v + case string: + if err := json.Unmarshal([]byte(v), &inner); err != nil { + return + } + default: + return + } + + contentB64, ok := inner["content_base64"].(string) + if !ok { + return + } + contentBytes, err := base64.StdEncoding.DecodeString(contentB64) + if err != nil { + return + } + sidebar := string(contentBytes) + + // Remove the line containing [[pageName]] + target := "[[" + pageName + "]]" + lines := strings.Split(sidebar, "\n") + var newLines []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != target { + newLines = append(newLines, line) + } + } + newSidebar := strings.Join(newLines, "\n") + + // No change needed + if newSidebar == sidebar { + return + } + + // Update sidebar + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + "pageName": sidebarPageName, + "title": sidebarPageName, + "message": "Remove deleted page " + pageName + " from sidebar", + "content_base64": base64.StdEncoding.EncodeToString([]byte(newSidebar)), + } + ctx.Client.DoRaw("PUT", "/wiki/open/updateWiki", body, nil) +} diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go index 7d9cd35..ed8b0f7 100644 --- a/shortcuts/wiki/wiki_test.go +++ b/shortcuts/wiki/wiki_test.go @@ -1,308 +1,325 @@ package wiki import ( + "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +// --- list --- + func TestWikiList(t *testing.T) { + callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/wiki/open/wikiPages") - assertEqual(t, r.URL.Query().Get("owner"), "owner") - assertEqual(t, r.URL.Query().Get("repo"), "repo") - assertEqual(t, r.URL.Query().Get("projectId"), "12345") - writeJSON(t, w, map[string]interface{}{"status": 0, "data": []interface{}{}}) + callCount++ + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + writeJSON(t, w, map[string]interface{}{"id": float64(42)}) + case r.Method == "GET" && r.URL.Path == "/wiki/open/wikiPages": + if r.URL.Query().Get("projectId") != "42" { + t.Fatalf("expected projectId=42, got %s", r.URL.Query().Get("projectId")) + } + writeJSON(t, w, map[string]interface{}{ + "data": []interface{}{ + map[string]interface{}{"title": "Home", "sub_url": "Home"}, + map[string]interface{}{"title": "Guide", "sub_url": "Guide"}, + }, + }) + default: + t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path) + } })) defer server.Close() - err := runWikiShortcut(t, server, "list", map[string]string{ - "project-id": "12345", - }) - if err != nil { - t.Fatalf("list shortcut failed: %v", err) + if err := runWikiShortcut(t, server, "list", map[string]string{}); err != nil { + t.Fatalf("list failed: %v", err) } } +func TestWikiListWithProjectID(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + writeJSON(t, w, map[string]interface{}{"project_id": float64(99)}) + case r.Method == "GET" && r.URL.Path == "/wiki/open/wikiPages": + if r.URL.Query().Get("projectId") != "99" { + t.Fatalf("expected projectId=99, got %s", r.URL.Query().Get("projectId")) + } + writeJSON(t, w, map[string]interface{}{"data": []interface{}{}}) + default: + t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path) + } + })) + defer server.Close() + + if err := runWikiShortcut(t, server, "list", map[string]string{}); err != nil { + t.Fatalf("list with project_id failed: %v", err) + } +} + +// --- view --- + func TestWikiView(t *testing.T) { + callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/wiki/open/getWiki") - assertEqual(t, r.URL.Query().Get("owner"), "owner") - assertEqual(t, r.URL.Query().Get("repo"), "repo") - assertEqual(t, r.URL.Query().Get("projectId"), "12345") - assertEqual(t, r.URL.Query().Get("pageName"), "home") - writeJSON(t, w, map[string]interface{}{"status": 0, "data": map[string]interface{}{"title": "home"}}) + callCount++ + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + writeJSON(t, w, map[string]interface{}{"id": float64(42)}) + case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki": + if r.URL.Query().Get("pageName") != "Home" { + t.Fatalf("expected pageName=Home, got %s", r.URL.Query().Get("pageName")) + } + writeJSON(t, w, map[string]interface{}{ + "data": map[string]interface{}{ + "title": "Home", + "content_base64": base64.StdEncoding.EncodeToString([]byte("Welcome to wiki")), + }, + }) + default: + t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path) + } })) defer server.Close() - err := runWikiShortcut(t, server, "view", map[string]string{ - "project-id": "12345", - "page-name": "home", - }) + err := runWikiShortcut(t, server, "view", map[string]string{"name": "Home"}) if err != nil { - t.Fatalf("view shortcut failed: %v", err) + t.Fatalf("view failed: %v", err) } } +func TestWikiViewRequiresName(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no request should be made without --name") + })) + defer server.Close() + + err := runWikiShortcut(t, server, "view", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing --name") + } +} + +// --- create --- + func TestWikiCreate(t *testing.T) { - var payload map[string]interface{} + callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "POST", "/wiki/open/createWiki") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + callCount++ + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + writeJSON(t, w, map[string]interface{}{"id": float64(42)}) + case r.Method == "POST" && r.URL.Path == "/wiki/open/createWiki": + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + if payload["pageName"] != "NewPage" { + t.Fatalf("expected pageName=NewPage, got %v", payload["pageName"]) + } + if payload["title"] != "NewPage" { + t.Fatalf("expected title=NewPage, got %v", payload["title"]) + } + if payload["owner"] != "owner" { + t.Fatalf("expected owner=owner, got %v", payload["owner"]) + } + if payload["repo"] != "repo" { + t.Fatalf("expected repo=repo, got %v", payload["repo"]) + } + if payload["projectId"].(float64) != 42 { + t.Fatalf("expected projectId=42, got %v", payload["projectId"]) + } + expectedContent := base64.StdEncoding.EncodeToString([]byte("Hello Wiki!")) + if payload["content_base64"] != expectedContent { + t.Fatalf("content_base64 mismatch: got %v", payload["content_base64"]) + } + writeJSON(t, w, map[string]interface{}{ + "code": 201, + "data": map[string]interface{}{"title": "NewPage"}, + }) + default: + t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path) + } })) defer server.Close() err := runWikiShortcut(t, server, "create", map[string]string{ - "project-id": "12345", - "page-name": "new-page", - "title": "New Page", - "content": "# Hello", + "name": "NewPage", "content": "Hello Wiki!", "message": "create page", }) if err != nil { - t.Fatalf("create shortcut failed: %v", err) - } - - assertEqual(t, payload["owner"], "owner") - assertEqual(t, payload["repo"], "repo") - assertEqual(t, payload["pageName"], "new-page") - assertEqual(t, payload["title"], "New Page") - if _, ok := payload["content_base64"]; !ok { - t.Fatal("body missing content_base64") + t.Fatalf("create failed: %v", err) } } +func TestWikiCreateRequiresName(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no request should be made without --name") + })) + defer server.Close() + + err := runWikiShortcut(t, server, "create", map[string]string{"content": "test"}) + if err == nil { + t.Fatal("expected error for missing --name") + } +} + +func TestWikiCreateRequiresContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no request should be made without --content") + })) + defer server.Close() + + err := runWikiShortcut(t, server, "create", map[string]string{"name": "Test"}) + if err == nil { + t.Fatal("expected error for missing --content") + } +} + +// --- update --- + func TestWikiUpdate(t *testing.T) { - var payload map[string]interface{} + callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "PUT", "/wiki/open/updateWiki") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + callCount++ + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + writeJSON(t, w, map[string]interface{}{"id": float64(42)}) + case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki": + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + if payload["pageName"] != "Home" { + t.Fatalf("expected pageName=Home, got %v", payload["pageName"]) + } + if payload["title"] != "Home" { + t.Fatalf("expected title=Home, got %v", payload["title"]) + } + if payload["message"] != "update page" { + t.Fatalf("expected message=update page, got %v", payload["message"]) + } + writeJSON(t, w, map[string]interface{}{"code": 200}) + default: + t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path) + } })) defer server.Close() err := runWikiShortcut(t, server, "update", map[string]string{ - "project-id": "12345", - "page-name": "home", - "title": "Updated Title", + "name": "Home", "content": "Updated content", "message": "update page", }) if err != nil { - t.Fatalf("update shortcut failed: %v", err) + t.Fatalf("update failed: %v", err) } - - assertEqual(t, payload["owner"], "owner") - assertEqual(t, payload["pageName"], "home") - assertEqual(t, payload["title"], "Updated Title") } -func TestWikiUpdateRequiresTitle(t *testing.T) { +func TestWikiUpdateRequiresName(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("server should not be called when title is missing: %s %s", r.Method, r.URL.Path) + t.Fatal("no request should be made without --name") })) defer server.Close() - err := runWikiShortcut(t, server, "update", map[string]string{ - "project-id": "12345", - "page-name": "home", - }) + err := runWikiShortcut(t, server, "update", map[string]string{"content": "test"}) if err == nil { - t.Fatal("expected update without --title to return an error") - } - if err.Error() != "--title is required" { - t.Fatalf("unexpected error message: %s", err.Error()) + t.Fatal("expected error for missing --name") } } -func TestWikiUpdateWithContentOnly(t *testing.T) { - var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "PUT", "/wiki/open/updateWiki") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - })) - defer server.Close() - - err := runWikiShortcut(t, server, "update", map[string]string{ - "project-id": "12345", - "page-name": "home", - "title": "Existing Title", - "content": "# Updated content", - }) - if err != nil { - t.Fatalf("update with content failed: %v", err) - } - if _, ok := payload["content_base64"]; !ok { - t.Fatal("body missing content_base64 when --content provided") - } -} +// --- delete --- func TestWikiDelete(t *testing.T) { - var payload map[string]interface{} + callCount := 0 + sidebarContent := "[[Home]]\n[[OldPage]]\n[[Guide]]" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "DELETE", "/wiki/open/deleteWiki") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - })) - defer server.Close() - - err := runWikiShortcut(t, server, "delete", map[string]string{ - "project-id": "12345", - "page-name": "old-page", - }) - if err != nil { - t.Fatalf("delete shortcut failed: %v", err) - } - - assertEqual(t, payload["owner"], "owner") - assertEqual(t, payload["repo"], "repo") - assertEqual(t, payload["pageName"], "old-page") -} - -func TestWikiListAutoResolvesProjectID(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{}{"project_id": float64(1549132)}) - case 2: - assertRequest(t, r, "GET", "/wiki/open/wikiPages") - assertEqual(t, r.URL.Query().Get("projectId"), "1549132") - writeJSON(t, w, map[string]interface{}{"status": 0, "data": []interface{}{}}) + callCount++ + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + writeJSON(t, w, map[string]interface{}{"id": float64(42)}) + case r.Method == "DELETE" && r.URL.Path == "/wiki/open/deleteWiki": + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + if payload["pageName"] != "OldPage" { + t.Fatalf("expected pageName=OldPage, got %v", payload["pageName"]) + } + writeJSON(t, w, map[string]interface{}{"code": 200}) + case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki": + if r.URL.Query().Get("pageName") != "_Sidebar" { + t.Fatalf("expected pageName=_Sidebar, got %s", r.URL.Query().Get("pageName")) + } + // Return sidebar with the page still in it + writeJSON(t, w, map[string]interface{}{ + "code": 200, + "data": fmt.Sprintf(`{"content_base64":"%s"}`, base64.StdEncoding.EncodeToString([]byte(sidebarContent))), + }) + case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki": + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + if payload["pageName"] != "_Sidebar" { + t.Fatalf("expected pageName=_Sidebar, got %v", payload["pageName"]) + } + // Verify OldPage is removed from sidebar + updated, _ := base64.StdEncoding.DecodeString(payload["content_base64"].(string)) + if strings.Contains(string(updated), "[[OldPage]]") { + t.Fatal("sidebar should not contain [[OldPage]] after delete") + } + if !strings.Contains(string(updated), "[[Home]]") { + t.Fatal("sidebar should still contain [[Home]]") + } + writeJSON(t, w, map[string]interface{}{"code": 200}) default: - t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) + t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path) } })) defer server.Close() - err := runWikiShortcut(t, server, "list", nil) + err := runWikiShortcut(t, server, "delete", map[string]string{"name": "OldPage"}) if err != nil { - t.Fatalf("list with auto-resolved project id failed: %v", err) - } - if requests != 2 { - t.Fatalf("expected 2 requests, got %d", requests) + t.Fatalf("delete failed: %v", err) } } -func TestWikiCreateAutoResolvesProjectID(t *testing.T) { - requests := 0 - var payload map[string]interface{} +func TestWikiDeleteRequiresName(t *testing.T) { 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{}{"project_id": float64(789)}) - case 2: - assertRequest(t, r, "POST", "/wiki/open/createWiki") - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - default: - t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) - } + t.Fatal("no request should be made without --name") })) defer server.Close() - err := runWikiShortcut(t, server, "create", map[string]string{ - "page-name": "new-page", - "title": "New Page", - "content": "# Hello", - }) - if err != nil { - t.Fatalf("create with auto-resolved project id failed: %v", err) - } - assertEqual(t, payload["projectId"], "789") -} - -func TestWikiInvalidProjectID(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("server should not be called for invalid --project-id: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - err := runWikiShortcut(t, server, "list", map[string]string{ - "project-id": "abc", - }) + err := runWikiShortcut(t, server, "delete", map[string]string{}) if err == nil { - t.Fatal("expected invalid --project-id to return an error") + t.Fatal("expected error for missing --name") } } -func TestWikiResolveProjectIDMissingField(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo.json") - writeJSON(t, w, map[string]interface{}{"identifier": "repo"}) - })) - defer server.Close() - - err := runWikiShortcut(t, server, "list", nil) - if err == nil { - t.Fatal("expected missing project_id in repository response to return an error") - } -} +// --- helpers --- func runWikiShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() shortcut := findWikiShortcut(t, name) ctx := &common.RuntimeContext{ - 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{} + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", Repo: "repo", Format: "json", Args: args, } return shortcut.Run(ctx) } func findWikiShortcut(t *testing.T, name string) *common.Shortcut { t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut + for _, s := range Shortcuts() { + if s.Name == name { + return s } } t.Fatalf("shortcut %q not found", name) return nil } -func assertRequest(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 decodeJSON(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 writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { t.Helper() w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) - } -} - -func assertEqual(t *testing.T, got interface{}, want interface{}) { - t.Helper() - if got != want { - t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) - } + json.NewEncoder(w).Encode(payload) }