From 053875b0719a731dad8330b6db98c12bb2e1a4a2 Mon Sep 17 00:00:00 2001 From: wangyue789 Date: Wed, 27 May 2026 18:39:00 +0800 Subject: [PATCH] feat(wiki): add OpenAPI shortcuts --- README.md | 19 +++ README.zh-CN.md | 19 +++ doc/changes/wiki-openapi-shortcuts.md | 24 +++ shortcuts/register.go | 3 + shortcuts/register_test.go | 2 +- shortcuts/wiki/wiki.go | 232 ++++++++++++++++++++++++++ shortcuts/wiki/wiki_test.go | 218 ++++++++++++++++++++++++ skills/gitlink-wiki/SKILL.md | 64 +++++++ 8 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 doc/changes/wiki-openapi-shortcuts.md create mode 100644 shortcuts/wiki/wiki.go create mode 100644 shortcuts/wiki/wiki_test.go create mode 100644 skills/gitlink-wiki/SKILL.md diff --git a/README.md b/README.md index b43dcd1..d526660 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | Category | Capabilities | |----------|-------------| | 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions | +| Wiki | Create, update, view, list, and delete wiki pages | | 🐛 Issue | Create, update, close, batch close, comment on issues | | 🔖 Label | Create, list, update, delete issue labels | | 🔀 PR | Create, merge, review pull requests, view changed files | @@ -246,6 +247,23 @@ gitlink-cli repo +create -n my-project -d "Project description" gitlink-cli repo +fork --owner Gitlink --repo forgeplus ``` +### Wiki Management + +```bash +# List and view wiki pages +gitlink-cli wiki +pages --owner Gitlink --repo forgeplus --project-id 123 +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 123 --page Home + +# Create or update wiki pages +gitlink-cli wiki +create --owner Gitlink --repo forgeplus \ + --project-id 123 --page Home --title Home --content "Welcome to Wiki" +gitlink-cli wiki +update --owner Gitlink --repo forgeplus \ + --project-id 123 --page Home --title Home --content "Updated content" + +# Preview wiki page deletion +gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 123 --page Home --dry-run +``` + ### Webhook Management ```bash @@ -638,6 +656,7 @@ See [skills/README.md](skills/README.md) for details. |-------|-------------| | `gitlink-shared` | Authentication, global parameters, safety rules, API notes | | `gitlink-repo` | Repository operations (create, view, delete, fork, insights, etc.) | +| `gitlink-wiki` | Wiki operations (create, update, view, list, delete) | | `gitlink-issue` | Issue operations (create, update, close, comment, etc.) | | `gitlink-pr` | Pull request operations (create, merge, review, etc.) | | `gitlink-member` | Repository member and invite link management | diff --git a/README.zh-CN.md b/README.zh-CN.md index 265ac98..85fda2c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -96,6 +96,7 @@ | 分类 | 能力 | |------|------| | 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 | +| Wiki | 创建、更新、查看、列出和删除 Wiki 页面 | | 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | @@ -257,6 +258,23 @@ gitlink-cli repo +create -n my-project -d "项目描述" gitlink-cli repo +fork --owner Gitlink --repo forgeplus ``` +### Wiki 管理 + +```bash +# 列出和查看 Wiki 页面 +gitlink-cli wiki +pages --owner Gitlink --repo forgeplus --project-id 123 +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 123 --page Home + +# 创建或更新 Wiki 页面 +gitlink-cli wiki +create --owner Gitlink --repo forgeplus \ + --project-id 123 --page Home --title Home --content "Welcome to Wiki" +gitlink-cli wiki +update --owner Gitlink --repo forgeplus \ + --project-id 123 --page Home --title Home --content "Updated content" + +# 预览删除 Wiki 页面 +gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 123 --page Home --dry-run +``` + ### Webhook 管理 ```bash @@ -517,6 +535,7 @@ git push gitlink |-------|------| | `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 | | `gitlink-repo` | 仓库操作(创建、查看、删除、Fork、洞察数据等) | +| `gitlink-wiki` | Wiki 操作(创建、更新、查看、列表、删除) | | `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) | | `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) | | `gitlink-member` | 仓库成员与邀请链接管理 | diff --git a/doc/changes/wiki-openapi-shortcuts.md b/doc/changes/wiki-openapi-shortcuts.md new file mode 100644 index 0000000..9a7d272 --- /dev/null +++ b/doc/changes/wiki-openapi-shortcuts.md @@ -0,0 +1,24 @@ +# Wiki OpenAPI Shortcuts + +## Summary + +This change adds a dedicated `wiki` shortcut group for GitLink Wiki OpenAPI +coverage. It includes read, create, update, and delete workflows. + +## Added shortcuts + +| Shortcut | API | +|----------|-----| +| `wiki +pages` | `GET /api/wiki/wikiPages` | +| `wiki +view` | `GET /api/wiki/getWiki` | +| `wiki +create` | `POST /api/wiki/createWiki` | +| `wiki +update` | `PUT /api/wiki/updateWiki` | +| `wiki +delete` | `DELETE /api/wiki/deleteWiki` | + +`wiki +create` and `wiki +update` accept either raw `--content`, which the CLI +base64-encodes, or pre-encoded `--content-base64`. `wiki +delete` supports +`--dry-run` for safe previews. + +## Submitter + +Wang Yue diff --git a/shortcuts/register.go b/shortcuts/register.go index 917bf85..f32312a 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -22,6 +22,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" ) @@ -49,6 +50,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "compare": compare.Shortcuts(), "webhook": webhook.Shortcuts(tr), "health": health.Shortcuts(tr), + "wiki": wiki.Shortcuts(), "workflow": workflow.Shortcuts(), } @@ -70,6 +72,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "compare": "Compare branches, tags, or commits", "webhook": tr.T("cmd.webhook.short"), "health": "Project health data collection", + "wiki": "Wiki operations", "workflow": "AI agent workflow analysis", } diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index a8c8ce4..8ea2cc2 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", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "health", + "health", "wiki", } groupSet := map[string]bool{} diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go new file mode 100644 index 0000000..6915173 --- /dev/null +++ b/shortcuts/wiki/wiki.go @@ -0,0 +1,232 @@ +package wiki + +import ( + "encoding/base64" + "fmt" + "net/url" + "strconv" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "pages", + Description: "List repository wiki pages", + Flags: wikiProjectFlags(), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q, err := wikiProjectQuery(ctx) + if err != nil { + return err + } + env, err := ctx.CallAPIWithQuery("GET", "/wiki/wikiPages", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "view", + Description: "Show a wiki page", + Flags: append(wikiProjectFlags(), + common.Flag{Name: "page", Short: "p", Usage: "Wiki page name", Required: true}, + ), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q, err := wikiProjectQuery(ctx) + if err != nil { + return err + } + page, err := ctx.RequireArg("page") + if err != nil { + return err + } + q.Set("pageName", page) + env, err := ctx.CallAPIWithQuery("GET", "/wiki/getWiki", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a wiki page", + Flags: wikiWriteFlags(true), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + payload, err := wikiWritePayload(ctx, true) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", "/wiki/createWiki", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "update", + Description: "Update a wiki page", + Flags: wikiWriteFlags(false), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + payload, err := wikiWritePayload(ctx, false) + if err != nil { + return err + } + env, err := ctx.CallAPI("PUT", "/wiki/updateWiki", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a wiki page", + Flags: append(wikiProjectFlags(), + common.Flag{Name: "page", Short: "p", Usage: "Wiki page name", Required: true}, + common.Flag{Name: "dry-run", Usage: "Preview the delete request without changing wiki state", Bool: true, Default: "false"}, + ), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + payload, err := wikiBasePayload(ctx) + if err != nil { + return err + } + page, err := ctx.RequireArg("page") + if err != nil { + return err + } + payload["pageName"] = page + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "delete_wiki_page", + "method": "DELETE", + "path": "/wiki/deleteWiki", + "payload": payload, + }) + } + env, err := ctx.CallAPI("DELETE", "/wiki/deleteWiki", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func wikiProjectFlags() []common.Flag { + return []common.Flag{ + {Name: "project-id", Usage: "GitLink project ID", Required: true}, + } +} + +func wikiWriteFlags(contentRequired bool) []common.Flag { + return append(wikiProjectFlags(), + common.Flag{Name: "page", Short: "p", Usage: "Wiki page name", Required: true}, + common.Flag{Name: "title", Short: "t", Usage: "Wiki page title", Required: true}, + common.Flag{Name: "message", Short: "m", Usage: "Commit message"}, + common.Flag{Name: "content", Usage: "Wiki content; encoded to base64 before sending"}, + common.Flag{Name: "content-base64", Usage: "Pre-encoded wiki content"}, + ) +} + +func wikiProjectQuery(ctx *common.RuntimeContext) (url.Values, error) { + projectID, err := wikiProjectID(ctx) + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("projectId", strconv.Itoa(projectID)) + return q, nil +} + +func wikiWritePayload(ctx *common.RuntimeContext, contentRequired bool) (map[string]interface{}, error) { + payload, err := wikiBasePayload(ctx) + if err != nil { + return nil, err + } + page, err := ctx.RequireArg("page") + if err != nil { + return nil, err + } + title, err := ctx.RequireArg("title") + if err != nil { + return nil, err + } + content, ok, err := wikiContent(ctx, contentRequired) + if err != nil { + return nil, err + } + payload["pageName"] = page + payload["title"] = title + if message := ctx.Arg("message"); message != "" { + payload["message"] = message + } + if ok { + payload["content_base64"] = content + } + return payload, nil +} + +func wikiBasePayload(ctx *common.RuntimeContext) (map[string]interface{}, error) { + projectID, err := wikiProjectID(ctx) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + }, nil +} + +func wikiProjectID(ctx *common.RuntimeContext) (int, error) { + value, err := ctx.RequireArg("project-id") + if err != nil { + return 0, err + } + id, err := strconv.Atoi(value) + if err != nil || id <= 0 { + return 0, fmt.Errorf("--project-id must be a positive integer") + } + return id, nil +} + +func wikiContent(ctx *common.RuntimeContext, required bool) (string, bool, error) { + content := ctx.Arg("content") + encoded := ctx.Arg("content-base64") + if content != "" && encoded != "" { + return "", false, fmt.Errorf("--content cannot be used with --content-base64") + } + if content != "" { + return base64.StdEncoding.EncodeToString([]byte(content)), true, nil + } + if encoded != "" { + return encoded, true, nil + } + if required { + return "", false, fmt.Errorf("required flag --content is missing (or use --content-base64)") + } + return "", false, nil +} diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go new file mode 100644 index 0000000..a1d1b62 --- /dev/null +++ b/shortcuts/wiki/wiki_test.go @@ -0,0 +1,218 @@ +package wiki + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestWikiShortcutsRouteToOpenAPIEndpoints(t *testing.T) { + cases := []struct { + name string + args map[string]string + method string + path string + query map[string]string + payload map[string]interface{} + }{ + { + name: "pages", + args: map[string]string{"project-id": "123"}, + method: "GET", + path: "/wiki/wikiPages.json", + query: map[string]string{"owner": "owner", "repo": "repo", "projectId": "123"}, + }, + { + name: "view", + args: map[string]string{"project-id": "123", "page": "Home"}, + method: "GET", + path: "/wiki/getWiki.json", + query: map[string]string{"owner": "owner", "repo": "repo", "projectId": "123", "pageName": "Home"}, + }, + { + name: "create", + args: map[string]string{"project-id": "123", "page": "Home", "title": "Home", "message": "Add Home", "content": "hello"}, + method: "POST", + path: "/wiki/createWiki.json", + payload: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "projectId": float64(123), + "pageName": "Home", + "title": "Home", + "message": "Add Home", + "content_base64": "aGVsbG8=", + }, + }, + { + name: "update", + args: map[string]string{"project-id": "123", "page": "Home", "title": "Home", "content-base64": "dXBkYXRlZA=="}, + method: "PUT", + path: "/wiki/updateWiki.json", + payload: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "projectId": float64(123), + "pageName": "Home", + "title": "Home", + "content_base64": "dXBkYXRlZA==", + }, + }, + { + name: "delete", + args: map[string]string{"project-id": "123", "page": "Home"}, + method: "DELETE", + path: "/wiki/deleteWiki.json", + payload: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "projectId": float64(123), + "pageName": "Home", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + called := false + server := newWikiTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != tc.method || r.URL.Path != tc.path { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + assertWikiQuery(t, r, tc.query) + if tc.payload != nil { + assertWikiPayload(t, r, tc.payload) + } + called = true + writeWikiJSON(t, w, map[string]interface{}{"message": "success", "data": map[string]interface{}{}}) + }) + defer server.Close() + + err := runWikiShortcut(server, tc.name, tc.args) + if err != nil { + t.Fatalf("%s shortcut failed: %v", tc.name, err) + } + if !called { + t.Fatal("expected API request") + } + }) + } +} + +func TestWikiDeleteDryRunDoesNotCallAPI(t *testing.T) { + server := newWikiTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runWikiShortcut(server, "delete", map[string]string{ + "project-id": "123", + "page": "Home", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("delete dry-run failed: %v", err) + } +} + +func TestWikiShortcutsValidateRequiredArgs(t *testing.T) { + server := newWikiTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + cases := []struct { + name string + args map[string]string + want string + }{ + {name: "pages", args: map[string]string{}, want: "--project-id"}, + {name: "pages", args: map[string]string{"project-id": "abc"}, want: "--project-id must be a positive integer"}, + {name: "view", args: map[string]string{"project-id": "123"}, want: "--page"}, + {name: "create", args: map[string]string{"project-id": "123", "page": "Home", "title": "Home"}, want: "--content"}, + {name: "create", args: map[string]string{"project-id": "123", "page": "Home", "title": "Home", "content": "x", "content-base64": "eA=="}, want: "--content cannot be used with --content-base64"}, + {name: "update", args: map[string]string{"project-id": "123", "page": "Home"}, want: "--title"}, + {name: "delete", args: map[string]string{"project-id": "123"}, want: "--page"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := runWikiShortcut(server, tc.name, tc.args) + if err == nil { + t.Fatal("expected validation error") + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %q, want it to mention %s", err.Error(), tc.want) + } + }) + } +} + +func runWikiShortcut(server *httptest.Server, name string, args map[string]string) error { + for _, shortcut := range Shortcuts() { + if shortcut.Name != name { + continue + } + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + Tr: i18n.Default(), + } + return shortcut.Run(ctx) + } + return fmt.Errorf("shortcut %q not found", name) +} + +func newWikiTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func assertWikiQuery(t *testing.T, r *http.Request, want map[string]string) { + t.Helper() + query := r.URL.Query() + if len(query) != len(want) { + t.Fatalf("query = %v, want %v", query, want) + } + for key, value := range want { + if got := query.Get(key); got != value { + t.Fatalf("query %s = %q, want %q", key, got, value) + } + } +} + +func assertWikiPayload(t *testing.T, r *http.Request, want map[string]interface{}) { + t.Helper() + var got map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode request body: %v", err) + } + if len(got) != len(want) { + t.Fatalf("payload = %v, want %v", got, want) + } + for key, value := range want { + if got[key] != value { + t.Fatalf("payload %s = %v, want %v", key, got[key], value) + } + } +} + +func writeWikiJSON(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) + } +} diff --git a/skills/gitlink-wiki/SKILL.md b/skills/gitlink-wiki/SKILL.md new file mode 100644 index 0000000..444e129 --- /dev/null +++ b/skills/gitlink-wiki/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gitlink-wiki +version: 1.0.0 +description: "Wiki management: list, view, create, update, and delete GitLink wiki pages." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli wiki --help" +--- + +# gitlink-wiki + +Read `../gitlink-shared/SKILL.md` first for authentication, global flags, and safety rules. + +Write and delete operations must be confirmed by the user before execution. Use +`wiki +delete --dry-run` before deleting a page. + +## Shortcuts + +| Shortcut | Purpose | Auth | +|----------|---------|------| +| `wiki +pages` | List wiki pages | No for public repos | +| `wiki +view` | Show one wiki page | No for public repos | +| `wiki +create` | Create a wiki page | Yes | +| `wiki +update` | Update a wiki page | Yes | +| `wiki +delete` | Delete a wiki page | Yes | + +All shortcuts require `--project-id` because the Wiki OpenAPI requires the +GitLink project ID in addition to owner and repo. + +`wiki +create` and `wiki +update` accept either `--content`, which the CLI +base64-encodes, or `--content-base64` for callers that already have encoded +content. + +## Examples + +```bash +gitlink-cli wiki +pages --owner Gitlink --repo forgeplus --project-id 123 +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 123 --page Home + +gitlink-cli wiki +create --owner Gitlink --repo forgeplus \ + --project-id 123 \ + --page Home \ + --title Home \ + --content "Welcome to Wiki" + +gitlink-cli wiki +update --owner Gitlink --repo forgeplus \ + --project-id 123 \ + --page Home \ + --title Home \ + --content "Updated content" + +gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 123 --page Home --dry-run +``` + +## API Mapping + +| Shortcut | API | +|----------|-----| +| `wiki +pages` | `GET /api/wiki/wikiPages` | +| `wiki +view` | `GET /api/wiki/getWiki` | +| `wiki +create` | `POST /api/wiki/createWiki` | +| `wiki +update` | `PUT /api/wiki/updateWiki` | +| `wiki +delete` | `DELETE /api/wiki/deleteWiki` |