feat(shortcuts): add wiki/commit/file/star/watch modules + batch operations
新增 5 个 Shortcut 模块(23 个命令)和 3 个批量操作: 新模块: - wiki: 5 命令 (list/view/create/update/delete),含 Sidebar 自动清理 - commit: 4 命令 (list/view/diff/blame) - file: 5 命令 (list/tree/get/create/delete),自动 base64 编码 - star: 3 命令 (star/unstar/stars) - watch: 3 命令 (watch/unwatch/watchers) 批量操作: - member batch-add: 批量添加成员 - org batch-invite: 批量邀请(用户名自动解析为 ID) - repo batch-create/batch-fork/batch-delete: 批量仓库管理 - 所有批量操作支持 --dry-run 和 --from CSV 测试: 每个模块包含 httptest mock 单元测试 文档: doc/changes/new-shortcut-modules.md Note: 代码基于旧版 API 签名,需适配 i18n 翻译器接口
This commit is contained in:
parent
7947d4dfd6
commit
e2fe4c02f0
|
|
@ -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 的相关变更。
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package commit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List commits in a repository",
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA"},
|
||||
{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 fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
if sha := ctx.Arg("sha"); sha != "" {
|
||||
q.Set("sha", sha)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo), q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取提交列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View files changed in a commit",
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
|
||||
{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 fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查看提交详情失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "diff",
|
||||
Description: "Show diff for a commit",
|
||||
Flags: []common.Flag{
|
||||
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
sha, err := ctx.RequireArg("sha")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取提交 Diff 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "blame",
|
||||
Description: "Show blame for a file",
|
||||
Flags: []common.Flag{
|
||||
{Name: "path", Short: "p", Usage: "File path", Required: true},
|
||||
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
filePath, err := ctx.RequireArg("path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("filepath", filePath)
|
||||
q.Set("sha", ctx.Arg("sha"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo), q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 Blame 信息失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
package commit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestCommitList(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/commits.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": 1,
|
||||
"commits": []map[string]interface{}{
|
||||
{"sha": "abc123", "commit_message": "initial commit"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runCommitShortcut(t, server, "list", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("list shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitListWithSHA(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/commits.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("sha") != "develop" {
|
||||
t.Fatalf("expected sha=develop, got %s", r.URL.Query().Get("sha"))
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": 0,
|
||||
"commits": []map[string]interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runCommitShortcut(t, server, "list", map[string]string{"sha": "develop"})
|
||||
if err != nil {
|
||||
t.Fatalf("list with sha failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitView(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/commits/abc123/files.json" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"file_nums": 1,
|
||||
"files": []map[string]interface{}{
|
||||
{"filename": "main.go", "additions": 10, "deletions": 2},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runCommitShortcut(t, server, "view", map[string]string{"sha": "abc123"})
|
||||
if err != nil {
|
||||
t.Fatalf("view shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitDiff(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/commits/abc123/diff.json" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"file_nums": 1,
|
||||
"total_addition": 10,
|
||||
"total_deletion": 2,
|
||||
"files": []map[string]interface{}{{"name": "main.go"}},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runCommitShortcut(t, server, "diff", map[string]string{"sha": "abc123"})
|
||||
if err != nil {
|
||||
t.Fatalf("diff shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitBlame(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/blame.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("filepath") != "main.go" {
|
||||
t.Fatalf("expected filepath=main.go, got %s", r.URL.Query().Get("filepath"))
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"file_name": "main.go",
|
||||
"num_lines": 20,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runCommitShortcut(t, server, "blame", map[string]string{"path": "main.go", "sha": "master"})
|
||||
if err != nil {
|
||||
t.Fatalf("blame shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// === helpers ===
|
||||
|
||||
func runCommitShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findCommitShortcut(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 findCommitShortcut(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")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, got, want interface{}) {
|
||||
t.Helper()
|
||||
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
|
||||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
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
|
||||
}
|
||||
q := url.Values{}
|
||||
if ref := ctx.Arg("ref"); ref != "" {
|
||||
q.Set("ref", ref)
|
||||
}
|
||||
if search := ctx.Arg("search"); search != "" {
|
||||
q.Set("search", search)
|
||||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
package file
|
||||
|
||||
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 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)
|
||||
}
|
||||
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, "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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "create", map[string]string{
|
||||
"path": "test.txt", "content": "hello world", "message": "add test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDelete(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runFileShortcut(t, server, "delete", map[string]string{
|
||||
"path": "old.txt", "sha": "def456", "message": "remove old",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDeleteRequiresPath(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, "delete", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --path")
|
||||
}
|
||||
}
|
||||
|
||||
// === 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,
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findFileShortcut(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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package watch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "watch",
|
||||
Description: "Watch 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
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("target_type", "project")
|
||||
q.Set("id", fmt.Sprintf("%d", projectID))
|
||||
env, err := ctx.CallAPIWithQuery("POST", "/watchers/follow", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unwatch",
|
||||
Description: "Unwatch 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
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("target_type", "project")
|
||||
q.Set("id", fmt.Sprintf("%d", projectID))
|
||||
env, err := ctx.CallAPIWithQuery("DELETE", "/watchers/unfollow", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "watchers",
|
||||
Description: "List watchers 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")
|
||||
ctx.Owner = owner
|
||||
ctx.Repo = repo
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/watchers", 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")
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
package watch
|
||||
|
||||
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 TestWatch(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), "name": "repo"})
|
||||
case r.Method == "POST" && r.URL.Path == "/watchers/follow.json":
|
||||
if r.URL.Query().Get("target_type") != "project" {
|
||||
t.Fatal("expected target_type=project")
|
||||
}
|
||||
if r.URL.Query().Get("id") != "100" {
|
||||
t.Fatalf("expected id=100, got %s", r.URL.Query().Get("id"))
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"watched": true})
|
||||
default:
|
||||
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runWatchShortcut(t, server, "watch", map[string]string{}); err != nil {
|
||||
t.Fatalf("watch failed: %v", err)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Fatalf("expected 2 calls (GET project + POST watch), got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwatch(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 == "/watchers/unfollow.json":
|
||||
writeJSON(t, w, map[string]interface{}{"watched": false})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runWatchShortcut(t, server, "unwatch", map[string]string{}); err != nil {
|
||||
t.Fatalf("unwatch failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchers(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/owner/repo/watchers.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", "is_watch": true}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runWatchShortcut(t, server, "watchers", map[string]string{
|
||||
"owner": "owner", "repo": "repo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("watchers failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// === helpers ===
|
||||
|
||||
func runWatchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
shortcut := findWatchShortcut(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 findWatchShortcut(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)
|
||||
}
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const wikiBaseURL = "https://gateway.gitlink.org.cn/api"
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List wiki pages",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); 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", fmt.Sprintf("%d", projectID))
|
||||
return callWikiAPI(ctx, "GET", "/wiki/open/wikiPages", nil, q)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View a wiki page",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := fetchProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("owner", ctx.Owner)
|
||||
q.Set("repo", ctx.Repo)
|
||||
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 wiki page",
|
||||
Flags: []common.Flag{
|
||||
{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"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := ctx.RequireArg("content")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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, "POST", "/wiki/open/createWiki", body, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a wiki page",
|
||||
Flags: []common.Flag{
|
||||
{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"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := ctx.RequireArg("content")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 and remove it from sidebar",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := fetchProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Delete the wiki page
|
||||
body := map[string]interface{}{
|
||||
"owner": ctx.Owner,
|
||||
"repo": ctx.Repo,
|
||||
"projectId": projectID,
|
||||
"pageName": name,
|
||||
}
|
||||
if err := callWikiAPISilent(ctx, "DELETE", "/wiki/open/deleteWiki", body, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
|
@ -0,0 +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) {
|
||||
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()
|
||||
|
||||
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) {
|
||||
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{"name": "Home"})
|
||||
if err != nil {
|
||||
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) {
|
||||
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(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{
|
||||
"name": "NewPage", "content": "Hello Wiki!", "message": "create page",
|
||||
})
|
||||
if err != nil {
|
||||
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) {
|
||||
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(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{
|
||||
"name": "Home", "content": "Updated content", "message": "update page",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiUpdateRequiresName(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, "update", map[string]string{"content": "test"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --name")
|
||||
}
|
||||
}
|
||||
|
||||
// --- delete ---
|
||||
|
||||
func TestWikiDelete(t *testing.T) {
|
||||
callCount := 0
|
||||
sidebarContent := "[[Home]]\n[[OldPage]]\n[[Guide]]"
|
||||
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(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 request #%d: %s %s", callCount, r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runWikiShortcut(t, server, "delete", map[string]string{"name": "OldPage"})
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiDeleteRequiresName(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, "delete", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --name")
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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,
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
}
|
||||
|
||||
func findWikiShortcut(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)
|
||||
}
|
||||
Loading…
Reference in New Issue