From 8bd3ceb7c83503877b59b1f993538b37b20399b6 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Tue, 26 May 2026 17:28:05 +0800 Subject: [PATCH] feat: add wiki shortcut group --- README.md | 27 ++- README.zh-CN.md | 27 ++- doc/changes/wiki-shortcut.md | 13 ++ internal/i18n/locales/en-US.json | 28 ++- internal/i18n/locales/zh-CN.json | 28 ++- shortcuts/register.go | 3 + shortcuts/register_test.go | 2 +- shortcuts/wiki/wiki.go | 297 +++++++++++++++++++++++++++++++ shortcuts/wiki/wiki_test.go | 263 +++++++++++++++++++++++++++ skills/gitlink-wiki/SKILL.md | 43 +++++ 10 files changed, 710 insertions(+), 21 deletions(-) create mode 100644 doc/changes/wiki-shortcut.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..d48d41a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/). +The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, wiki pages, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/). **[中文文档](./README.zh-CN.md)** @@ -83,7 +83,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans ## Why gitlink-cli? - **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup -- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands +- **Wide Coverage** — Repository, Wiki, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands - **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output - **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded - **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install` @@ -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 | List, view, create, update, 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,27 @@ gitlink-cli repo +create -n my-project -d "Project description" gitlink-cli repo +fork --owner Gitlink --repo forgeplus ``` +### Wiki Management + +```bash +# List wiki pages +gitlink-cli wiki +list --owner Gitlink --repo forgeplus + +# View a wiki page +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --page Home + +# Create a wiki page from inline content +gitlink-cli wiki +create --owner Gitlink --repo forgeplus \ + --page Home --title Home --content "Welcome to the project wiki" + +# Update a wiki page from a Markdown file +gitlink-cli wiki +update --owner Gitlink --repo forgeplus \ + --page Home --file docs/wiki-home.md --message "Update Home" + +# Delete a wiki page +gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --page Home +``` + ### Webhook Management ```bash @@ -638,6 +660,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 (list, view, create, update, 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..1412fc5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。 +[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Wiki、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。 **[English](./README.md)** @@ -83,7 +83,7 @@ ## 为什么选择 gitlink-cli? - **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink -- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令 +- **广泛覆盖** — 仓库、Wiki、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令 - **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出 - **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装 - **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用 @@ -96,6 +96,7 @@ | 分类 | 能力 | |------|------| | 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 | +| 📚 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 | | 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | @@ -257,6 +258,27 @@ gitlink-cli repo +create -n my-project -d "项目描述" gitlink-cli repo +fork --owner Gitlink --repo forgeplus ``` +### Wiki 管理 + +```bash +# 列出 Wiki 页面 +gitlink-cli wiki +list --owner Gitlink --repo forgeplus + +# 查看 Wiki 页面 +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --page Home + +# 使用命令行内容创建 Wiki 页面 +gitlink-cli wiki +create --owner Gitlink --repo forgeplus \ + --page Home --title Home --content "欢迎来到项目 Wiki" + +# 使用 Markdown 文件更新 Wiki 页面 +gitlink-cli wiki +update --owner Gitlink --repo forgeplus \ + --page Home --file docs/wiki-home.md --message "更新 Home" + +# 删除 Wiki 页面 +gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --page Home +``` + ### Webhook 管理 ```bash @@ -517,6 +539,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-shortcut.md b/doc/changes/wiki-shortcut.md new file mode 100644 index 0000000..f54c78c --- /dev/null +++ b/doc/changes/wiki-shortcut.md @@ -0,0 +1,13 @@ +# Wiki Shortcut + +Added a new `wiki` shortcut group for repository wiki management. + +Included commands: + +- `wiki +list` +- `wiki +view` +- `wiki +create` +- `wiki +update` +- `wiki +delete` + +The shortcut auto-resolves GitLink `projectId` from repository metadata when `--project-id` is omitted, supports both inline content and file-backed Markdown content, base64-encodes wiki content for the API, and validates conflicting or incomplete input before sending requests. diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index c8df7cf..79c20fa 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -83,6 +83,12 @@ "cmd.webhook.test.short": "Trigger a test delivery for a webhook", "cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available", "cmd.webhook.view.short": "View webhook details", + "cmd.wiki.create.short": "Create a wiki page", + "cmd.wiki.delete.short": "Delete a wiki page", + "cmd.wiki.list.short": "List wiki pages", + "cmd.wiki.short": "Wiki operations", + "cmd.wiki.update.short": "Update a wiki page", + "cmd.wiki.view.short": "View a wiki page", "error.auth.delete_token_failed": "failed to delete token: {message}", "error.auth.login_failed": "login failed: {message}", "error.auth.store_token_failed": "failed to store token: {message}", @@ -90,13 +96,13 @@ "error.config.save_failed": "failed to save config: {message}", "error.missing_required_flag": "required flag --{name} is missing", "error.unsupported_language": "unsupported language: {lang}", - "flag.api.body": "Request body (JSON string)", - "flag.api.body_file": "Read request body JSON from a file", - "flag.api.body_stdin": "Read request body JSON from stdin", "flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure", "flag.api.batch_dry_run": "Preview batch requests without sending remote requests", "flag.api.batch_file": "Read an API batch plan from a JSON file", "flag.api.batch_var": "Override a batch template variable (key=value, repeatable)", + "flag.api.body": "Request body (JSON string)", + "flag.api.body_file": "Read request body JSON from a file", + "flag.api.body_stdin": "Read request body JSON from stdin", "flag.api.header": "Additional headers (key:value)", "flag.api.query": "Query parameters (key=val&key2=val2)", "flag.auth.token": "Login by pasting an existing token", @@ -113,6 +119,8 @@ "flag.format": "Output format: json, table, yaml (default: table)", "flag.issue.add_label": "Label to add to each matching issue", "flag.issue.assignee": "Assignee login", + "flag.issue.assignee_id": "Assignee user ID", + "flag.issue.author_id": "Author user ID", "flag.issue.batch.reason": "Optional reason shown in the batch result", "flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.", "flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7", @@ -120,8 +128,6 @@ "flag.issue.batch_label.state": "Filter by issue state", "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", - "flag.issue.assignee_id": "Assignee user ID", - "flag.issue.author_id": "Author user ID", "flag.issue.body": "Issue description", "flag.issue.label": "Label ID", "flag.issue.label_filter": "Filter by existing label", @@ -149,14 +155,14 @@ "flag.pr.file": "Filter diff by file path", "flag.pr.head": "Source branch", "flag.pr.id": "PR number", - "flag.pr.milestone_id": "Milestone ID", "flag.pr.merge_method": "Merge method: merge, rebase, squash", + "flag.pr.milestone_id": "Milestone ID", "flag.pr.priority_id": "Priority ID", "flag.pr.review_commit": "Commit SHA to attach the review to", "flag.pr.review_content": "Review content", - "flag.pr.reviewer_id": "Reviewer user ID", "flag.pr.review_status": "Review status: common, approved, rejected", "flag.pr.review_status_filter": "Filter review status: common, approved, rejected", + "flag.pr.reviewer_id": "Reviewer user ID", "flag.pr.state": "Filter: open, merged, closed", "flag.pr.tag_id": "Issue tag ID", "flag.pr.title": "PR title", @@ -190,11 +196,16 @@ "flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.", "flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", "flag.webhook.url": "Webhook target URL", + "flag.wiki.content": "Wiki page content", + "flag.wiki.file": "Read wiki page content from file", + "flag.wiki.message": "Commit message", + "flag.wiki.page": "Wiki page name", + "flag.wiki.project_id": "GitLink project ID (auto-resolved when omitted)", + "flag.wiki.title": "Wiki page title (defaults to --page)", "output.auth.env_hint": " Or set {env} environment variable", "output.auth.login_hint": " Run: gitlink-cli auth login", "output.config.file": "Config file: {path}", "output.config.not_set": "(not set)", - "output.version": "gitlink-cli {version}", "output.doctor.api_auth.config_skipped": "API authentication check skipped because the configuration file is invalid.", "output.doctor.api_auth.failed": "Authenticated API request failed: {message}", "output.doctor.api_auth.no_login": "Authenticated API response did not include a login field.", @@ -217,6 +228,7 @@ "output.doctor.suggestion.check_token": "Check whether the stored token is valid, or run gitlink-cli auth login again.", "output.doctor.suggestion.fix_config_yaml": "Fix the YAML syntax in the gitlink-cli config file.", "output.doctor.suggestion.pass_owner_repo": "Run the command with --owner and --repo when not inside a GitLink repository.", + "output.version": "gitlink-cli {version}", "prompt.auth.password": "Password: ", "prompt.auth.token": "Paste your access token: ", "prompt.auth.username": "Username/Email/Phone: ", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 7fdc2b1..381ad2e 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -83,6 +83,12 @@ "cmd.webhook.test.short": "触发 Webhook 测试投递", "cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", "cmd.webhook.view.short": "查看 Webhook 详情", + "cmd.wiki.create.short": "创建 Wiki 页面", + "cmd.wiki.delete.short": "删除 Wiki 页面", + "cmd.wiki.list.short": "列出 Wiki 页面", + "cmd.wiki.short": "Wiki 操作", + "cmd.wiki.update.short": "更新 Wiki 页面", + "cmd.wiki.view.short": "查看 Wiki 页面", "error.auth.delete_token_failed": "删除 Token 失败:{message}", "error.auth.login_failed": "登录失败:{message}", "error.auth.store_token_failed": "保存 Token 失败:{message}", @@ -90,13 +96,13 @@ "error.config.save_failed": "保存配置失败:{message}", "error.missing_required_flag": "缺少必需参数 --{name}", "error.unsupported_language": "不支持的语言:{lang}", - "flag.api.body": "请求体(JSON 字符串)", - "flag.api.body_file": "从文件读取 JSON 请求体", - "flag.api.body_stdin": "从标准输入读取 JSON 请求体", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", "flag.api.batch_dry_run": "预览批处理请求,不发送远端请求", "flag.api.batch_file": "从 JSON 文件读取 API 批处理计划", "flag.api.batch_var": "覆盖批处理模板变量(key=value,可重复)", + "flag.api.body": "请求体(JSON 字符串)", + "flag.api.body_file": "从文件读取 JSON 请求体", + "flag.api.body_stdin": "从标准输入读取 JSON 请求体", "flag.api.header": "附加请求头(key:value)", "flag.api.query": "查询参数(key=val&key2=val2)", "flag.auth.token": "通过粘贴已有 Token 登录", @@ -113,6 +119,8 @@ "flag.format": "输出格式:json、table、yaml(默认:table)", "flag.issue.add_label": "要添加到每个匹配议题的标签", "flag.issue.assignee": "负责人登录名", + "flag.issue.assignee_id": "负责人用户 ID", + "flag.issue.author_id": "作者用户 ID", "flag.issue.batch.reason": "批量结果中显示的可选原因", "flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。", "flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7", @@ -120,8 +128,6 @@ "flag.issue.batch_label.state": "按议题状态筛选", "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", - "flag.issue.assignee_id": "负责人用户 ID", - "flag.issue.author_id": "作者用户 ID", "flag.issue.body": "议题描述", "flag.issue.label": "标签 ID", "flag.issue.label_filter": "按已有标签筛选", @@ -149,14 +155,14 @@ "flag.pr.file": "按文件路径筛选 diff", "flag.pr.head": "源分支", "flag.pr.id": "PR 编号", - "flag.pr.milestone_id": "里程碑 ID", "flag.pr.merge_method": "合并方式:merge、rebase、squash", + "flag.pr.milestone_id": "里程碑 ID", "flag.pr.priority_id": "优先级 ID", "flag.pr.review_commit": "关联评审的 Commit SHA", "flag.pr.review_content": "评审内容", - "flag.pr.reviewer_id": "评审人用户 ID", "flag.pr.review_status": "评审状态:common、approved、rejected", "flag.pr.review_status_filter": "按评审状态筛选:common、approved、rejected", + "flag.pr.reviewer_id": "评审人用户 ID", "flag.pr.state": "筛选:open、merged、closed", "flag.pr.tag_id": "议题标签 ID", "flag.pr.title": "PR 标题", @@ -190,11 +196,16 @@ "flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。", "flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", "flag.webhook.url": "Webhook 目标 URL", + "flag.wiki.content": "Wiki 页面内容", + "flag.wiki.file": "从文件读取 Wiki 页面内容", + "flag.wiki.message": "提交说明", + "flag.wiki.page": "Wiki 页面名称", + "flag.wiki.project_id": "GitLink 项目 ID(未传入时自动解析)", + "flag.wiki.title": "Wiki 页面标题(默认使用 --page)", "output.auth.env_hint": " 或设置 {env} 环境变量", "output.auth.login_hint": " 运行:gitlink-cli auth login", "output.config.file": "配置文件:{path}", "output.config.not_set": "(未设置)", - "output.version": "gitlink-cli {version}", "output.doctor.api_auth.config_skipped": "配置文件无效,已跳过 API 认证检查。", "output.doctor.api_auth.failed": "认证 API 请求失败:{message}", "output.doctor.api_auth.no_login": "认证 API 响应中缺少 login 字段。", @@ -217,6 +228,7 @@ "output.doctor.suggestion.check_token": "检查已保存的 Token 是否有效,或重新运行 gitlink-cli auth login。", "output.doctor.suggestion.fix_config_yaml": "修复 gitlink-cli 配置文件中的 YAML 语法。", "output.doctor.suggestion.pass_owner_repo": "不在 GitLink 仓库目录内时,请通过 --owner 和 --repo 指定仓库。", + "output.version": "gitlink-cli {version}", "prompt.auth.password": "密码:", "prompt.auth.token": "粘贴你的访问 Token:", "prompt.auth.username": "用户名/邮箱/手机号:", diff --git a/shortcuts/register.go b/shortcuts/register.go index 917bf85..57b578e 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" ) @@ -50,6 +51,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "webhook": webhook.Shortcuts(tr), "health": health.Shortcuts(tr), "workflow": workflow.Shortcuts(), + "wiki": wiki.Shortcuts(tr), } descriptions := map[string]string{ @@ -71,6 +73,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "webhook": tr.T("cmd.webhook.short"), "health": "Project health data collection", "workflow": "AI agent workflow analysis", + "wiki": tr.T("cmd.wiki.short"), } for name, shortcuts := range groups { 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..18b814c --- /dev/null +++ b/shortcuts/wiki/wiki.go @@ -0,0 +1,297 @@ +package wiki + +import ( + "encoding/base64" + "errors" + "fmt" + "net/url" + "os" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +const ( + wikiListPath = "/api/wiki/wikiPages" + wikiViewPath = "/api/wiki/getWiki" + wikiCreatePath = "/api/wiki/createWiki" + wikiUpdatePath = "/api/wiki/updateWiki" + wikiDeletePath = "/api/wiki/deleteWiki" +) + +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) + return []*common.Shortcut{ + { + Name: "list", + Description: tr.T("cmd.wiki.list.short"), + Flags: []common.Flag{ + {Name: "project-id", Usage: tr.T("flag.wiki.project_id")}, + }, + Run: runListWikiPages, + }, + { + Name: "view", + Description: tr.T("cmd.wiki.view.short"), + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: tr.T("flag.wiki.page"), Required: true}, + {Name: "project-id", Usage: tr.T("flag.wiki.project_id")}, + }, + Run: runViewWikiPage, + }, + { + Name: "create", + Description: tr.T("cmd.wiki.create.short"), + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: tr.T("flag.wiki.page"), Required: true}, + {Name: "title", Short: "t", Usage: tr.T("flag.wiki.title")}, + {Name: "content", Short: "c", Usage: tr.T("flag.wiki.content")}, + {Name: "file", Short: "f", Usage: tr.T("flag.wiki.file")}, + {Name: "message", Short: "m", Usage: tr.T("flag.wiki.message")}, + {Name: "project-id", Usage: tr.T("flag.wiki.project_id")}, + }, + Run: runCreateWikiPage, + }, + { + Name: "update", + Description: tr.T("cmd.wiki.update.short"), + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: tr.T("flag.wiki.page"), Required: true}, + {Name: "title", Short: "t", Usage: tr.T("flag.wiki.title")}, + {Name: "content", Short: "c", Usage: tr.T("flag.wiki.content")}, + {Name: "file", Short: "f", Usage: tr.T("flag.wiki.file")}, + {Name: "message", Short: "m", Usage: tr.T("flag.wiki.message")}, + {Name: "project-id", Usage: tr.T("flag.wiki.project_id")}, + }, + Run: runUpdateWikiPage, + }, + { + Name: "delete", + Description: tr.T("cmd.wiki.delete.short"), + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: tr.T("flag.wiki.page"), Required: true}, + {Name: "project-id", Usage: tr.T("flag.wiki.project_id")}, + }, + Run: runDeleteWikiPage, + }, + } +} + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + +func runListWikiPages(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + query, err := buildWikiQuery(ctx, "") + if err != nil { + return err + } + env, err := callWikiAPIWithQuery(ctx, "GET", wikiListPath, query) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runViewWikiPage(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + page, err := ctx.RequireArg("page") + if err != nil { + return err + } + query, err := buildWikiQuery(ctx, page) + if err != nil { + return err + } + env, err := callWikiAPIWithQuery(ctx, "GET", wikiViewPath, query) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runCreateWikiPage(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + payload, err := buildWikiWritePayload(ctx, true) + if err != nil { + return err + } + env, err := callWikiAPI(ctx, "POST", wikiCreatePath, payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runUpdateWikiPage(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + payload, err := buildWikiWritePayload(ctx, false) + if err != nil { + return err + } + env, err := callWikiAPI(ctx, "PUT", wikiUpdatePath, payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runDeleteWikiPage(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + page, err := ctx.RequireArg("page") + if err != nil { + return err + } + projectID, err := resolveProjectID(ctx) + if err != nil { + return err + } + payload := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + "pageName": page, + } + env, err := callWikiAPI(ctx, "DELETE", wikiDeletePath, payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func buildWikiQuery(ctx *common.RuntimeContext, page string) (url.Values, error) { + projectID, err := resolveProjectID(ctx) + if err != nil { + return nil, err + } + query := url.Values{} + query.Set("owner", ctx.Owner) + query.Set("repo", ctx.Repo) + query.Set("projectId", strconv.Itoa(projectID)) + if page != "" { + query.Set("pageName", page) + } + return query, nil +} + +func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) { + env, err := ctx.CallAPI(method, path, body) + if err != nil { + return nil, wrapWikiAPIError(path, err) + } + return env, nil +} + +func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) { + env, err := ctx.CallAPIWithQuery(method, path, query) + if err != nil { + return nil, wrapWikiAPIError(path, err) + } + return env, nil +} + +func wrapWikiAPIError(path string, err error) error { + var apiErr *client.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + return fmt.Errorf("GitLink Wiki API %s returned 404; confirm the repository has Wiki enabled and the Wiki OpenAPI is available: %w", path, err) + } + return err +} + +func buildWikiWritePayload(ctx *common.RuntimeContext, requireContent bool) (map[string]interface{}, error) { + page, err := ctx.RequireArg("page") + if err != nil { + return nil, err + } + projectID, err := resolveProjectID(ctx) + if err != nil { + return nil, err + } + title := ctx.Arg("title") + if title == "" { + title = page + } + + payload := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + "pageName": page, + "title": title, + } + if message := ctx.Arg("message"); message != "" { + payload["message"] = message + } + contentBase64, ok, err := readWikiContent(ctx) + if err != nil { + return nil, err + } + if ok { + payload["content_base64"] = contentBase64 + } else if requireContent { + return nil, fmt.Errorf("one of --content or --file is required") + } + return payload, nil +} + +func readWikiContent(ctx *common.RuntimeContext) (string, bool, error) { + content := ctx.Arg("content") + filePath := ctx.Arg("file") + if content != "" && filePath != "" { + return "", false, fmt.Errorf("use only one of --content or --file") + } + if filePath != "" { + data, err := os.ReadFile(filePath) + if err != nil { + return "", false, fmt.Errorf("read wiki file: %w", err) + } + return base64.StdEncoding.EncodeToString(data), true, nil + } + if content != "" { + return base64.StdEncoding.EncodeToString([]byte(content)), true, nil + } + return "", false, nil +} + +func resolveProjectID(ctx *common.RuntimeContext) (int, error) { + if value := strings.TrimSpace(ctx.Arg("project-id")); value != "" { + projectID, err := strconv.Atoi(value) + if err != nil || projectID <= 0 { + return 0, fmt.Errorf("invalid --project-id %q", value) + } + return projectID, nil + } + + env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) + if err != nil { + return 0, fmt.Errorf("resolve project ID: %w", err) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("unexpected repository response format") + } + for _, key := range []string{"project_id", "id"} { + if value, ok := data[key].(float64); ok && value > 0 { + return int(value), nil + } + } + return 0, fmt.Errorf("repository response missing project_id") +} diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go new file mode 100644 index 0000000..0db3cb4 --- /dev/null +++ b/shortcuts/wiki/wiki_test.go @@ -0,0 +1,263 @@ +package wiki + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestWikiListAutoResolvesProjectID(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + writeJSON(t, w, map[string]interface{}{"project_id": float64(1546652)}) + case r.Method == "GET" && r.URL.Path == "/api/wiki/wikiPages.json": + called = true + query := r.URL.Query() + assertEqual(t, query.Get("owner"), "owner") + assertEqual(t, query.Get("repo"), "repo") + assertEqual(t, query.Get("projectId"), "1546652") + writeJSON(t, w, map[string]interface{}{"message": "success", "data": []interface{}{}}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runWikiShortcut(t, server, "list", map[string]string{}) + if err != nil { + t.Fatalf("list shortcut failed: %v", err) + } + if !called { + t.Fatal("wiki list endpoint was not called") + } +} + +func TestWikiViewUsesExplicitProjectID(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/api/wiki/getWiki.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + called = true + query := r.URL.Query() + assertEqual(t, query.Get("owner"), "owner") + assertEqual(t, query.Get("repo"), "repo") + assertEqual(t, query.Get("projectId"), "42") + assertEqual(t, query.Get("pageName"), "Home") + writeJSON(t, w, map[string]interface{}{"message": "success", "data": map[string]interface{}{"title": "Home"}}) + })) + defer server.Close() + + err := runWikiShortcut(t, server, "view", map[string]string{ + "page": "Home", + "project-id": "42", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } + if !called { + t.Fatal("wiki view endpoint was not called") + } +} + +func TestWikiCreateBuildsPayloadFromContent(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + writeJSON(t, w, map[string]interface{}{"project_id": float64(1546652)}) + case r.Method == "POST" && r.URL.Path == "/api/wiki/createWiki.json": + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"message": "201"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runWikiShortcut(t, server, "create", map[string]string{ + "page": "Home", + "title": "Welcome", + "content": "hello wiki", + "message": "Add Home", + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + + assertEqual(t, payload["owner"], "owner") + assertEqual(t, payload["repo"], "repo") + assertEqual(t, payload["projectId"], float64(1546652)) + assertEqual(t, payload["pageName"], "Home") + assertEqual(t, payload["title"], "Welcome") + assertEqual(t, payload["message"], "Add Home") + assertEqual(t, payload["content_base64"], base64.StdEncoding.EncodeToString([]byte("hello wiki"))) +} + +func TestWikiUpdateBuildsPayloadFromFile(t *testing.T) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "wiki.md") + if err := os.WriteFile(filePath, []byte("# Updated\n"), 0o644); err != nil { + t.Fatalf("write wiki file: %v", err) + } + + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" || r.URL.Path != "/api/wiki/updateWiki.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"message": "success"}) + })) + defer server.Close() + + err := runWikiShortcut(t, server, "update", map[string]string{ + "page": "Home", + "file": filePath, + "project-id": "42", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + + assertEqual(t, payload["projectId"], float64(42)) + assertEqual(t, payload["pageName"], "Home") + assertEqual(t, payload["title"], "Home") + assertEqual(t, payload["content_base64"], base64.StdEncoding.EncodeToString([]byte("# Updated\n"))) +} + +func TestWikiDeleteSendsBody(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" || r.URL.Path != "/api/wiki/deleteWiki.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"message": "success"}) + })) + defer server.Close() + + err := runWikiShortcut(t, server, "delete", map[string]string{ + "page": "Home", + "project-id": "42", + }) + if err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } + + assertEqual(t, payload["owner"], "owner") + assertEqual(t, payload["repo"], "repo") + assertEqual(t, payload["projectId"], float64(42)) + assertEqual(t, payload["pageName"], "Home") +} + +func TestWikiOpenAPI404HasActionableError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/api/wiki/wikiPages.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"status": 404, "message": "not found"}) + })) + defer server.Close() + + err := runWikiShortcut(t, server, "list", map[string]string{ + "project-id": "42", + }) + if err == nil { + t.Fatal("expected wiki API 404 to fail") + } + if !strings.Contains(err.Error(), "GitLink Wiki API /api/wiki/wikiPages returned 404") { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(err.Error(), "Wiki OpenAPI is available") { + t.Fatalf("expected actionable wiki API hint, got: %v", err) + } +} + +func TestWikiRejectsInvalidInput(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + if err := runWikiShortcut(t, server, "view", map[string]string{ + "page": "Home", + "project-id": "abc", + }); err == nil { + t.Fatal("expected invalid project-id to fail") + } + if err := runWikiShortcut(t, server, "create", map[string]string{ + "page": "Home", + "project-id": "42", + }); err == nil { + t.Fatal("expected missing content to fail") + } + if err := runWikiShortcut(t, server, "create", map[string]string{ + "page": "Home", + "project-id": "42", + "content": "hello", + "file": "wiki.md", + }); err == nil { + t.Fatal("expected content/file conflict to fail") + } +} + +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 _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +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 fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} diff --git a/skills/gitlink-wiki/SKILL.md b/skills/gitlink-wiki/SKILL.md new file mode 100644 index 0000000..def9c6a --- /dev/null +++ b/skills/gitlink-wiki/SKILL.md @@ -0,0 +1,43 @@ +--- +name: gitlink-wiki +version: 1.0.0 +description: "Wiki management: list, view, create, update, and delete repository wiki pages." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli wiki --help" + shortcuts: + - "wiki +list" + - "wiki +view" + - "wiki +create" + - "wiki +update" + - "wiki +delete" +--- + +# gitlink-wiki + +## Shortcuts + +| Shortcut | Description | +|----------|-------------| +| `wiki +list` | List repository wiki pages | +| `wiki +view` | View a wiki page | +| `wiki +create` | Create a wiki page | +| `wiki +update` | Update a wiki page | +| `wiki +delete` | Delete a wiki page | + +## Examples + +```bash +gitlink-cli wiki +list --owner Gitlink --repo forgeplus +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --page Home +gitlink-cli wiki +create --owner Gitlink --repo forgeplus \ + --page Home --title Home --content "Welcome to the project wiki" +gitlink-cli wiki +update --owner Gitlink --repo forgeplus \ + --page Home --file docs/wiki-home.md --message "Update Home" +gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --page Home +``` + +## Notes + +`--project-id` is optional. When omitted, the CLI reads repository metadata and uses the returned `project_id`.