Merge PR #147: feat(shortcuts): 新增 wiki/commit/file/star/watch 模块及批量操作

# Conflicts:
#	shortcuts/file/file.go
#	shortcuts/file/file_test.go
#	shortcuts/wiki/wiki.go
#	shortcuts/wiki/wiki_test.go
This commit is contained in:
wbtiger 2026-07-14 22:50:06 +08:00
commit a9b5690569
10 changed files with 1899 additions and 869 deletions

View File

@ -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 的相关变更。

View File

@ -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
}

View File

@ -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)
}

204
shortcuts/member/batch.go Normal file
View File

@ -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
}

336
shortcuts/org/batch.go Normal file
View File

@ -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
}

378
shortcuts/repo/batch.go Normal file
View File

@ -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
}

85
shortcuts/star/star.go Normal file
View File

@ -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")
}

101
shortcuts/star/star_test.go Normal file
View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}