diff --git a/README.md b/README.md index 71837ae..e5e4318 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 🔧 CI | View builds, logs, CI/CD operations | | ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs | | 🔔 Webhook | Manage repo webhooks and test deliveries | +| 📖 Wiki | List, view, create, update, and delete wiki pages | | 🔍 Search | Search repositories, users | | 📊 Dataset | Query research datasets by project | | 👤 User | View user profiles and info | @@ -273,6 +274,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68 gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68 ``` +### Wiki Management + +```bash +# List wiki pages (table of contents) +gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345 + +# View a wiki page by page name +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home + +# Create a wiki page +gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \ + -n getting-started -t "Getting Started" -c "# Getting Started Guide" + +# Update a wiki page title and/or content +gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title" +gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# Updated content" +gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title" -c "New content" + +# Delete a wiki page +gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page +``` + ### Member Management ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 1661a1e..6a8879d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -113,6 +113,7 @@ | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | | ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | +| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 | | 🔍 搜索 | 搜索仓库、用户 | | 📊 数据集 | 按项目查询科研数据集 | | 👤 用户 | 查看用户资料和信息 | @@ -284,6 +285,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68 gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68 ``` +### Wiki 管理 + +```bash +# 列出 Wiki 页面(目录结构) +gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345 + +# 查看 Wiki 页面 +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home + +# 创建 Wiki 页面 +gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \ + -n getting-started -t "快速开始" -c "# 快速开始指南" + +# 更新 Wiki 页面标题和/或内容 +gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题" +gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# 更新后的内容" +gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题" -c "新内容" + +# 删除 Wiki 页面 +gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page +``` + ### 成员管理 ```bash diff --git a/doc/changes/wiki-shortcut.md b/doc/changes/wiki-shortcut.md new file mode 100644 index 0000000..b48af22 --- /dev/null +++ b/doc/changes/wiki-shortcut.md @@ -0,0 +1,23 @@ +# Wiki Shortcut + +新增 `wiki` Shortcut 组,支持 Wiki 页面管理: + +- `wiki +list` - 列出 Wiki 页面(目录结构) +- `wiki +view` - 按页面名称查看 Wiki 页面详情 +- `wiki +create` - 创建新的 Wiki 页面 +- `wiki +update` - 更新 Wiki 页面标题和/或内容 +- `wiki +delete` - 删除 Wiki 页面 + +## 实现要点 + +- **API 端点**:基于 `/api/wiki/open/{action}` 扁平路径结构,覆盖 5 个 Wiki 管理接口: + - `GET /api/wiki/open/wikiPages` — 目录列表 + - `GET /api/wiki/open/getWiki` — 查看页面 + - `POST /api/wiki/open/createWiki` — 创建页面 + - `PUT /api/wiki/open/updateWiki` — 更新页面 + - `DELETE /api/wiki/open/deleteWiki` — 删除页面 +- **标识方式**:Wiki 页面通过 `pageName`(slug)标识,所有操作需要 `projectId`(GitLink 项目数字 ID) +- **内容编码**:创建和更新时,内容自动进行 base64 编码后以 `content_base64` 字段发送 +- **更新保护**:`+update` 要求必须提供 `--title` 和 `--page-name`;`--content` 为可选 +- **Shortcut 模式**:使用 `common.Shortcut` + `RuntimeContext` 框架,与其他模块保持一致 + diff --git a/internal/client/client.go b/internal/client/client.go index 1d20aed..1fb9d80 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -115,22 +115,35 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o } // Check GitLink error-in-body pattern + // Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."} + var bodyCode float64 + var bodyMsg string if status, ok := raw["status"]; ok { - var statusCode float64 switch v := status.(type) { case float64: - statusCode = v + bodyCode = v case int: - statusCode = float64(v) + bodyCode = float64(v) } - if statusCode != 0 && statusCode != 200 && statusCode != 1 { - msg, _ := raw["message"].(string) - suggestion := suggestFix(int(statusCode)) - return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{ - StatusCode: int(statusCode), - Code: int(statusCode), - Message: msg, - } + bodyMsg, _ = raw["message"].(string) + } else if code, ok := raw["code"]; ok { + switch v := code.(type) { + case float64: + bodyCode = v + case int: + bodyCode = float64(v) + } + bodyMsg, _ = raw["msg"].(string) + if bodyMsg == "" { + bodyMsg, _ = raw["message"].(string) + } + } + if bodyCode != 0 && bodyCode != 200 && bodyCode != 201 && bodyCode != 204 && bodyCode != 1 { + suggestion := suggestFix(int(bodyCode)) + return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{ + StatusCode: int(bodyCode), + Code: int(bodyCode), + Message: bodyMsg, } } @@ -170,6 +183,10 @@ func shouldAppendJSONSuffix(path string) bool { return false } } + // Wiki open API endpoints do not use .json suffix + if len(parts) >= 3 && parts[0] == "wiki" && parts[1] == "open" { + return false + } return true } diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 9a55bcb..c46226b 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -169,6 +169,45 @@ func TestClientDoStatusError(t *testing.T) { } } +func TestClientDoGatewayCodeError(t *testing.T) { + // Gateway returns {"code":N, "msg":"..."} instead of {"status":N, "message":"..."} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"code":400,"msg":"Bad Request"}`)) + })) + defer server.Close() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + env, err := c.Do("GET", "/api/test", nil, nil) + if err == nil { + t.Fatal("expected error for code=400") + } + if env == nil { + t.Fatal("expected envelope for code error") + } + if env.OK { + t.Fatal("expected OK=false for code=400") + } +} + +func TestClientDoGatewayCode201Success(t *testing.T) { + // Gateway returns code=201 with JSON string data — should be treated as success + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"code":201,"msg":"","data":"{\"title\":\"test\"}"}`)) + })) + defer server.Close() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + env, err := c.Do("POST", "/api/test", map[string]string{"title": "test"}, nil) + if err != nil { + t.Fatalf("unexpected error for code=201: %v", err) + } + if !env.OK { + t.Fatal("expected OK=true for code=201") + } +} + func TestClientDoStatusZero(t *testing.T) { // status=0, 200, 1 are treated as success server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -525,3 +564,18 @@ func TestShouldAppendJSONSuffixSkipsExistingJSONPath(t *testing.T) { t.Fatal("existing .json path should not get another suffix") } } + +func TestShouldAppendJSONSuffixSkipsWikiOpenPaths(t *testing.T) { + paths := []string{ + "/wiki/open/createWiki", + "/wiki/open/getWiki", + "/wiki/open/updateWiki", + "/wiki/open/deleteWiki", + "/wiki/open/wikiPages", + } + for _, p := range paths { + if shouldAppendJSONSuffix(p) { + t.Errorf("wiki/open path %q should not get .json suffix", p) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 0b3f269..de17f5b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,22 +8,25 @@ import ( ) const ( - DefaultBaseURL = "https://www.gitlink.org.cn/api" - DefaultFormat = "table" + DefaultBaseURL = "https://www.gitlink.org.cn/api" + DefaultGatewayURL = "https://gateway.gitlink.org.cn/api" + DefaultFormat = "table" ) type Config struct { - BaseURL string `yaml:"base_url"` - Format string `yaml:"default_format"` - Editor string `yaml:"editor,omitempty"` - Pager string `yaml:"pager,omitempty"` - Lang string `yaml:"lang,omitempty"` + BaseURL string `yaml:"base_url"` + GatewayURL string `yaml:"gateway_url"` + Format string `yaml:"default_format"` + Editor string `yaml:"editor,omitempty"` + Pager string `yaml:"pager,omitempty"` + Lang string `yaml:"lang,omitempty"` } func DefaultConfig() *Config { return &Config{ - BaseURL: DefaultBaseURL, - Format: DefaultFormat, + BaseURL: DefaultBaseURL, + GatewayURL: DefaultGatewayURL, + Format: DefaultFormat, } } @@ -54,6 +57,9 @@ func Load() (*Config, error) { if cfg.BaseURL == "" { cfg.BaseURL = DefaultBaseURL } + if cfg.GatewayURL == "" { + cfg.GatewayURL = DefaultGatewayURL + } if cfg.Format == "" { cfg.Format = DefaultFormat } @@ -80,6 +86,8 @@ func Get(key string) (string, error) { switch key { case "base_url": return cfg.BaseURL, nil + case "gateway_url": + return cfg.GatewayURL, nil case "default_format": return cfg.Format, nil case "editor": @@ -101,6 +109,8 @@ func Set(key, value string) error { switch key { case "base_url": cfg.BaseURL = value + case "gateway_url": + cfg.GatewayURL = value case "default_format": cfg.Format = value case "editor": diff --git a/shortcuts/register.go b/shortcuts/register.go index 00e2426..1fedc7e 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -25,6 +25,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/search" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" + "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" ) @@ -53,6 +54,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "compare": compare.Shortcuts(), "dataset": dataset.Shortcuts(tr), "webhook": webhook.Shortcuts(tr), + "wiki": wiki.Shortcuts(), "health": health.Shortcuts(tr), "ignore": ignore.Shortcuts(), "workflow": workflow.Shortcuts(), @@ -77,6 +79,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "compare": "Compare branches, tags, or commits", "dataset": tr.T("cmd.dataset.short"), "webhook": tr.T("cmd.webhook.short"), + "wiki": "Wiki page management", "health": "Project health data collection", "ignore": tr.T("cmd.ignore.short"), "workflow": "AI agent workflow analysis", diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 1c28241..00f4c57 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) { "repo", "issue", "label", "license", "pr", "profile", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "dataset", "health", "ignore", + "dataset", "health", "ignore", "wiki", } groupSet := map[string]bool{} diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go new file mode 100644 index 0000000..539b7a1 --- /dev/null +++ b/shortcuts/wiki/wiki.go @@ -0,0 +1,200 @@ +package wiki + +import ( + "encoding/base64" + "fmt" + "net/url" + + "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 +} + +// 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} +} + +// Shortcuts returns all wiki shortcuts. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List wiki pages", + Flags: []common.Flag{ + {Name: "project-id", Usage: "GitLink project ID", Required: true}, + gatewayFlag(), + }, + Run: func(ctx *common.RuntimeContext) error { + if ctx.Arg("gateway") == "true" { + if err := switchToGateway(ctx); err != nil { + return err + } + } + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("projectId", ctx.Arg("project-id")) + env, err := ctx.CallAPIWithQuery("GET", "/wiki/open/wikiPages", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "view", + Description: "View a wiki page by page name", + Flags: []common.Flag{ + {Name: "project-id", Usage: "GitLink project ID", Required: true}, + {Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true}, + gatewayFlag(), + }, + Run: func(ctx *common.RuntimeContext) error { + if ctx.Arg("gateway") == "true" { + if err := switchToGateway(ctx); err != nil { + return err + } + } + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("projectId", ctx.Arg("project-id")) + 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) + }, + }, + { + Name: "create", + Description: "Create a new wiki page", + Flags: []common.Flag{ + {Name: "project-id", Usage: "GitLink project ID", Required: true}, + {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: "message", Short: "m", Usage: "Commit message"}, + gatewayFlag(), + }, + Run: func(ctx *common.RuntimeContext) error { + if ctx.Arg("gateway") == "true" { + if err := switchToGateway(ctx); err != nil { + return err + } + } + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + content := ctx.Arg("content") + payload := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": ctx.Arg("project-id"), + "pageName": ctx.Arg("page-name"), + "title": ctx.Arg("title"), + "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), + "message": ctx.Arg("message"), + } + env, err := ctx.CallAPI("POST", "/wiki/open/createWiki", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "update", + Description: "Update an existing wiki page", + Flags: []common.Flag{ + {Name: "project-id", Usage: "GitLink project ID", Required: true}, + {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: "message", Short: "m", Usage: "Commit message"}, + gatewayFlag(), + }, + Run: func(ctx *common.RuntimeContext) error { + if ctx.Arg("gateway") == "true" { + if err := switchToGateway(ctx); err != nil { + return err + } + } + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + title := ctx.Arg("title") + if title == "" { + return fmt.Errorf("--title is required") + } + content := ctx.Arg("content") + payload := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": ctx.Arg("project-id"), + "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) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a wiki page", + Flags: []common.Flag{ + {Name: "project-id", Usage: "GitLink project ID", Required: true}, + {Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true}, + gatewayFlag(), + }, + Run: func(ctx *common.RuntimeContext) error { + if ctx.Arg("gateway") == "true" { + if err := switchToGateway(ctx); err != nil { + return err + } + } + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + payload := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": ctx.Arg("project-id"), + "pageName": ctx.Arg("page-name"), + } + env, err := ctx.CallAPI("DELETE", "/wiki/open/deleteWiki", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go new file mode 100644 index 0000000..f0d95d5 --- /dev/null +++ b/shortcuts/wiki/wiki_test.go @@ -0,0 +1,224 @@ +package wiki + +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 TestWikiList(t *testing.T) { + 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{}{}}) + })) + defer server.Close() + + err := runWikiShortcut(t, server, "list", map[string]string{ + "project-id": "12345", + }) + if err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestWikiView(t *testing.T) { + 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"}}) + })) + defer server.Close() + + err := runWikiShortcut(t, server, "view", map[string]string{ + "project-id": "12345", + "page-name": "home", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } +} + +func TestWikiCreate(t *testing.T) { + var payload map[string]interface{} + 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"}) + })) + defer server.Close() + + err := runWikiShortcut(t, server, "create", map[string]string{ + "project-id": "12345", + "page-name": "new-page", + "title": "New Page", + "content": "# Hello", + }) + 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") + } +} + +func TestWikiUpdate(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": "Updated Title", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + + assertEqual(t, payload["owner"], "owner") + assertEqual(t, payload["pageName"], "home") + assertEqual(t, payload["title"], "Updated Title") +} + +func TestWikiUpdateRequiresTitle(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) + })) + defer server.Close() + + err := runWikiShortcut(t, server, "update", map[string]string{ + "project-id": "12345", + "page-name": "home", + }) + 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()) + } +} + +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") + } +} + +func TestWikiDelete(t *testing.T) { + var payload map[string]interface{} + 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 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{} + } + 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 + } + } + 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) + } +} diff --git a/skills/README.md b/skills/README.md index d4df553..95233ec 100644 --- a/skills/README.md +++ b/skills/README.md @@ -110,6 +110,8 @@ skills/ │ └── ci-workflow.md # CI 工作流 ├── gitlink-pipeline/ # 流水线工作流 │ └── SKILL.md # Pipeline 操作指南 +├── gitlink-wiki/ # Wiki 页面管理 +│ └── SKILL.md # Wiki 操作指南 ├── gitlink-pm/ # 项目管理 │ └── SKILL.md # PM 操作指南 ├── gitlink-health/ # 项目健康度分析 @@ -150,6 +152,7 @@ skills/ | **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` | | **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` | | **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` | +| **gitlink-wiki** | Wiki 页面管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` | | **gitlink-pm** | 项目管理 | 通过 Raw API 访问 | | **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes | | **gitlink-health** | 开源项目健康度 | 详情见SKILL.md | diff --git a/skills/gitlink-wiki/SKILL.md b/skills/gitlink-wiki/SKILL.md new file mode 100644 index 0000000..b64d5be --- /dev/null +++ b/skills/gitlink-wiki/SKILL.md @@ -0,0 +1,79 @@ +--- +name: gitlink-wiki +version: 2.0.0 +description: "Wiki 页面管理:查看目录、查看、创建、更新和删除 GitLink Wiki 页面。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli wiki --help" +--- + +# gitlink-wiki + +**重要**: 开始操作前请先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中涵盖认证、权限、全局参数和 GitLink API 行为说明。 +**重要**: 执行写入或破坏性操作(如 `+create`、`+update` 或 `+delete`)前,请先确认用户意图。 +**重要**: 操作 GitLink 资源请使用 `gitlink-cli`,不要使用 `gh` 等 GitHub 专用工具。 + +## 快捷命令 + +| 快捷命令 | 说明 | 操作类型 | +|----------|------|----------| +| `wiki +list` | 列出 Wiki 页面(目录结构) | 只读 | +| `wiki +view` | 按页面名称查看 Wiki 页面详情 | 只读 | +| `wiki +create` | 创建新的 Wiki 页面 | 写入 | +| `wiki +update` | 更新 Wiki 页面标题和/或内容 | 写入 | +| `wiki +delete` | 删除 Wiki 页面 | 破坏性 | + +## 使用示例 + +```bash +# 列出 Wiki 目录 +gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345 + +# 查看 Wiki 页面 +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home + +# 创建 Wiki 页面 +gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \ + -n getting-started -t "快速开始" -c "# 快速开始指南\n\n这是入门文档。" + +# 创建时附带提交信息 +gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \ + -n api-guide -t "API 指南" -c "# API 指南" -m "Add API guide" + +# 仅更新页面标题 +gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题" + +# 仅更新页面内容 +gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# 更新后的内容" + +# 同时更新标题和内容 +gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 \ + -n home -t "新标题" -c "新内容" + +# 删除 Wiki 页面 +gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page +``` + +## 参数说明 + +| 命令 | 关键参数 | +|------|----------| +| `+list` | `--project-id` | +| `+view` | `--project-id`、`--page-name` (`-n`) | +| `+create` | `--project-id`、`--page-name` (`-n`)、`--title` (`-t`)、`--content` (`-c`)、`--message` (`-m`) | +| `+update` | `--project-id`、`--page-name` (`-n`)、`--title` (`-t`),可选 `--content` (`-c`)、`--message` (`-m`) | +| `+delete` | `--project-id`、`--page-name` (`-n`) | + +## API 说明 + +- 所有 Wiki 端点使用 `/api/wiki/{action}` 扁平路径结构(非 REST 嵌套路径)。 +- 目录列表: `GET /api/wiki/wikiPages`(查询参数:owner, repo, projectId) +- 查看详情: `GET /api/wiki/getWiki`(查询参数:owner, repo, projectId, pageName) +- 创建页面: `POST /api/wiki/createWiki`(JSON body,content 需 base64 编码) +- 更新页面: `PUT /api/wiki/updateWiki`(JSON body,content 需 base64 编码) +- 删除页面: `DELETE /api/wiki/deleteWiki`(JSON body:owner, repo, projectId, pageName) +- Wiki 页面通过 `pageName`(slug)标识,而非数字 ID。 +- 所有操作都需要 `--project-id`(GitLink 项目数字 ID)。 +- 创建和更新时,内容自动进行 base64 编码后以 `content_base64` 字段发送。 +- `+update` 要求必须提供 `--title` 和 `--page-name`;`--content` 为可选。