From 13fa90183f91b465bf71aaff7ef95d1220859088 Mon Sep 17 00:00:00 2001 From: luwanzhou Date: Sun, 14 Jun 2026 00:38:19 +0800 Subject: [PATCH 1/4] feat(wiki): add wiki management shortcuts Add a `wiki` shortcut group wrapping GitLink's /api/wiki and /api/wikiExport endpoints, which previously had no shortcut coverage (wiki management was a guide-listed capability): - wiki +list -> GET /wiki/wikiPages - wiki +view -> GET /wiki/getWiki - wiki +create -> POST /wiki/createWiki - wiki +update -> PUT /wiki/updateWiki - wiki +delete -> DELETE /wiki/deleteWiki - wiki +export -> GET /wikiExport/wikiExport-wrapper The numeric project ID is resolved from --owner/--repo (or --project-id). Page content accepts --content/--content-file (base64-encoded automatically) or --content-base64. create/update/delete support --dry-run; export supports --type markdown|pdf|html. Includes unit tests, bilingual (en-US/zh-CN) i18n help text, README updates, and a change note. Co-Authored-By: Claude Opus 4.8 --- README.md | 27 +++ README.zh-CN.md | 26 +++ doc/changes/wiki-shortcuts.md | 62 ++++++ internal/i18n/locales/en-US.json | 41 +++- internal/i18n/locales/zh-CN.json | 41 +++- shortcuts/register.go | 3 + shortcuts/register_test.go | 2 +- shortcuts/wiki/wiki.go | 333 +++++++++++++++++++++++++++++++ shortcuts/wiki/wiki_test.go | 295 +++++++++++++++++++++++++++ 9 files changed, 813 insertions(+), 17 deletions(-) create mode 100644 doc/changes/wiki-shortcuts.md create mode 100644 shortcuts/wiki/wiki.go create mode 100644 shortcuts/wiki/wiki_test.go diff --git a/README.md b/README.md index b43dcd1..009b8a1 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,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, delete, and export wiki pages | | 🔍 Search | Search repositories, users | | 👤 User | View user profiles and info | | 📋 PM | Sprint management, kanban boards, weekly reports | @@ -576,6 +577,32 @@ Safety: - `workflow +pr-summary` does not comment, approve, reject, or merge pull requests. - `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes. +### Wiki + +`wiki` manages a repository's wiki pages. The numeric GitLink project ID is +resolved from `--owner/--repo` automatically, or pass `--project-id`. + +```bash +# List and view wiki pages +gitlink-cli wiki +list --owner Gitlink --repo gitlink-cli +gitlink-cli wiki +view --owner Gitlink --repo gitlink-cli --page Home + +# Create a page (content is base64-encoded automatically) +gitlink-cli wiki +create --owner Gitlink --repo gitlink-cli --page Home --title Home --content "# Welcome" + +# Create from a file +gitlink-cli wiki +create --owner Gitlink --repo gitlink-cli --page Guide --content-file guide.md + +# Update (content optional) and preview with --dry-run +gitlink-cli wiki +update --owner Gitlink --repo gitlink-cli --page Home --title "Home Page" --dry-run + +# Delete a page +gitlink-cli wiki +delete --owner Gitlink --repo gitlink-cli --page Home + +# Export the wiki (markdown, pdf, or html) +gitlink-cli wiki +export --owner Gitlink --repo gitlink-cli --type markdown +``` + ### Raw API For endpoints not covered by shortcuts, use the Raw API directly: diff --git a/README.zh-CN.md b/README.zh-CN.md index 265ac98..b205c7d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -105,6 +105,7 @@ | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | | ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | +| 📖 Wiki | 列出、查看、创建、更新、删除、导出 Wiki 页面 | | 🔍 搜索 | 搜索仓库、用户 | | 👤 用户 | 查看用户资料和信息 | | 📋 项目管理 | Sprint 管理、看板、周报 | @@ -455,6 +456,31 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` +### Wiki 管理 + +`wiki` 管理仓库的 Wiki 页面。GitLink 项目 ID 会自动从 `--owner/--repo` 解析,也可用 `--project-id` 指定。 + +```bash +# 列出并查看 Wiki 页面 +gitlink-cli wiki +list --owner Gitlink --repo gitlink-cli +gitlink-cli wiki +view --owner Gitlink --repo gitlink-cli --page Home + +# 创建页面(内容自动 base64 编码) +gitlink-cli wiki +create --owner Gitlink --repo gitlink-cli --page Home --title Home --content "# 欢迎" + +# 从文件创建 +gitlink-cli wiki +create --owner Gitlink --repo gitlink-cli --page Guide --content-file guide.md + +# 更新(内容可选),并用 --dry-run 预览 +gitlink-cli wiki +update --owner Gitlink --repo gitlink-cli --page Home --title "首页" --dry-run + +# 删除页面 +gitlink-cli wiki +delete --owner Gitlink --repo gitlink-cli --page Home + +# 导出 Wiki(markdown、pdf 或 html) +gitlink-cli wiki +export --owner Gitlink --repo gitlink-cli --type markdown +``` + ### Raw API Shortcuts 未覆盖的接口可通过 Raw API 直接调用: diff --git a/doc/changes/wiki-shortcuts.md b/doc/changes/wiki-shortcuts.md new file mode 100644 index 0000000..4a32993 --- /dev/null +++ b/doc/changes/wiki-shortcuts.md @@ -0,0 +1,62 @@ +# Wiki Shortcuts + +## Summary + +Adds a new `wiki` shortcut group so maintainers and agents can manage a +repository's wiki without falling back to Raw API calls. Wiki was listed in the +competition guide as a desired capability and previously had no shortcut +coverage. The commands wrap GitLink's `/api/wiki/*` and `/api/wikiExport/*` +endpoints, which require the numeric GitLink project ID in addition to +owner/repo. + +## Commands + +| Command | Purpose | Endpoint | +|---------|---------|----------| +| `gitlink-cli wiki +list` | List wiki pages | `GET /wiki/wikiPages` | +| `gitlink-cli wiki +view` | View a wiki page | `GET /wiki/getWiki` | +| `gitlink-cli wiki +create` | Create a wiki page | `POST /wiki/createWiki` | +| `gitlink-cli wiki +update` | Update a wiki page | `PUT /wiki/updateWiki` | +| `gitlink-cli wiki +delete` | Delete a wiki page | `DELETE /wiki/deleteWiki` | +| `gitlink-cli wiki +export` | Export the wiki | `GET /wikiExport/wikiExport-wrapper` | + +## Behaviour + +- `--project-id` selects the GitLink project ID. When omitted, it is resolved + from `--owner/--repo` via the repository info endpoint, matching the + convention used by the `repo` interaction commands. +- Page content for `+create`/`+update` is provided with `--content` (plain text, + base64-encoded automatically), `--content-file` (read from a file, also + base64-encoded), or `--content-base64` (already encoded). Precedence is + `--content-base64` > `--content` > `--content-file`. +- `+create` requires content; `+update` treats content as optional so callers + can change only the title/message. +- `--title` defaults to the page name when omitted. +- `+create`, `+update`, and `+delete` support `--dry-run` to preview the request + body without changing remote state. +- `+export` accepts `--type` (`markdown` (default), `pdf`, or `html`) and an + optional `--project-name` (defaults to the repository name). + +## Tests + +Unit tests cover endpoint paths, project ID auto-resolution from repo info, +base64 encoding from `--content` and `--content-file`, the create +content-required guard, update without content, dry-run previews, export +defaults and invalid `--type`, and invalid `--project-id`. + +## 中文说明 + +### 变更内容 + +- 新增 `wiki` 命令组:`+list`、`+view`、`+create`、`+update`、`+delete`、`+export`。 +- `--project-id` 省略时自动从 `--owner/--repo` 解析(与 `repo` 互动命令一致)。 +- `+create`/`+update` 内容支持 `--content`(纯文本自动 base64)、`--content-file` + (读文件自动 base64)、`--content-base64`(已编码);`+create` 必填内容, + `+update` 内容可选。 +- `+create`/`+update`/`+delete` 支持 `--dry-run` 预览请求体。 +- `+export` 支持 `--type`(markdown/pdf/html)和 `--project-name`。 + +### 价值 + +Wiki 此前无任何 shortcut 封装,是参赛指南点名的能力方向。该命令组让人与 AI Agent +都能直接管理仓库 Wiki,为科研项目文档沉淀与导出等场景提供支撑。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index c8df7cf..0b66236 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -83,6 +83,19 @@ "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.long": "Create a wiki page. Content is provided via --content, --content-file, or --content-base64 and is base64-encoded automatically.", + "cmd.wiki.create.short": "Create a wiki page", + "cmd.wiki.delete.long": "Delete a wiki page. Supports --dry-run to preview the request.", + "cmd.wiki.delete.short": "Delete a wiki page", + "cmd.wiki.export.long": "Export the repository wiki as markdown, pdf, or html.", + "cmd.wiki.export.short": "Export the wiki", + "cmd.wiki.list.long": "List all wiki pages for a repository.", + "cmd.wiki.list.short": "List wiki pages", + "cmd.wiki.short": "Repository wiki operations", + "cmd.wiki.update.long": "Update an existing wiki page. Content is optional; when omitted only the title/message change.", + "cmd.wiki.update.short": "Update a wiki page", + "cmd.wiki.view.long": "View a single wiki page by its page name.", + "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 +103,15 @@ "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", + "error.wiki.content_required": "wiki content is required; pass --content, --content-file, or --content-base64", + "error.wiki.export_type": "invalid --type; use markdown, pdf, or html", "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 +128,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 +137,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 +164,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 +205,20 @@ "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 (plain text, base64-encoded automatically)", + "flag.wiki.content_base64": "Wiki page content already base64-encoded", + "flag.wiki.content_file": "Read wiki page content from a file (base64-encoded automatically)", + "flag.wiki.dry_run": "Preview the request without changing the wiki", + "flag.wiki.export_type": "Export format: markdown, pdf, or html", + "flag.wiki.message": "Commit message for the change", + "flag.wiki.page": "Wiki page name", + "flag.wiki.project_id": "GitLink project ID. If omitted, it is resolved from --owner/--repo.", + "flag.wiki.project_name": "Project name for export (defaults to the repository name)", + "flag.wiki.title": "Wiki page title (defaults to the page name)", "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 +241,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..074894b 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -83,6 +83,19 @@ "cmd.webhook.test.short": "触发 Webhook 测试投递", "cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", "cmd.webhook.view.short": "查看 Webhook 详情", + "cmd.wiki.create.long": "创建 Wiki 页面。内容通过 --content、--content-file 或 --content-base64 提供,会自动进行 base64 编码。", + "cmd.wiki.create.short": "创建 Wiki 页面", + "cmd.wiki.delete.long": "删除 Wiki 页面。支持 --dry-run 预览请求。", + "cmd.wiki.delete.short": "删除 Wiki 页面", + "cmd.wiki.export.long": "将仓库 Wiki 导出为 markdown、pdf 或 html。", + "cmd.wiki.export.short": "导出 Wiki", + "cmd.wiki.list.long": "列出仓库的所有 Wiki 页面。", + "cmd.wiki.list.short": "列出 Wiki 页面", + "cmd.wiki.short": "仓库 Wiki 操作", + "cmd.wiki.update.long": "更新已有的 Wiki 页面。内容可选;省略时仅修改标题/提交信息。", + "cmd.wiki.update.short": "更新 Wiki 页面", + "cmd.wiki.view.long": "按页面名称查看单个 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 +103,15 @@ "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 请求体", + "error.wiki.content_required": "需要提供 Wiki 内容;请使用 --content、--content-file 或 --content-base64", + "error.wiki.export_type": "无效的 --type;请使用 markdown、pdf 或 html", "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 +128,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 +137,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 +164,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 +205,20 @@ "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 页面内容(纯文本,自动 base64 编码)", + "flag.wiki.content_base64": "已经过 base64 编码的 Wiki 页面内容", + "flag.wiki.content_file": "从文件读取 Wiki 页面内容(自动 base64 编码)", + "flag.wiki.dry_run": "预览请求,不修改 Wiki", + "flag.wiki.export_type": "导出格式:markdown、pdf 或 html", + "flag.wiki.message": "本次变更的提交信息", + "flag.wiki.page": "Wiki 页面名称", + "flag.wiki.project_id": "GitLink 项目 ID。省略时从 --owner/--repo 解析。", + "flag.wiki.project_name": "导出用的项目名称(默认使用仓库名称)", + "flag.wiki.title": "Wiki 页面标题(默认使用页面名称)", "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 +241,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..1e44379 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" ) @@ -48,6 +49,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), "webhook": webhook.Shortcuts(tr), + "wiki": wiki.Shortcuts(tr), "health": health.Shortcuts(tr), "workflow": workflow.Shortcuts(), } @@ -69,6 +71,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", "webhook": tr.T("cmd.webhook.short"), + "wiki": tr.T("cmd.wiki.short"), "health": "Project health data collection", "workflow": "AI agent workflow analysis", } diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index a8c8ce4..d2a6ebc 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", + "wiki", "health", } groupSet := map[string]bool{} diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go new file mode 100644 index 0000000..b824338 --- /dev/null +++ b/shortcuts/wiki/wiki.go @@ -0,0 +1,333 @@ +// Package wiki implements shortcuts for managing a repository's wiki pages +// (list, view, create, update, delete) and exporting the wiki. These wrap +// GitLink's /api/wiki and /api/wikiExport endpoints, which require the numeric +// GitLink project ID in addition to owner/repo. +package wiki + +import ( + "encoding/base64" + "fmt" + "net/url" + "os" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns wiki management shortcuts. +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) + + projectIDFlag := common.Flag{Name: "project-id", Usage: tr.T("flag.wiki.project_id")} + contentFlags := []common.Flag{ + {Name: "content", Short: "c", Usage: tr.T("flag.wiki.content")}, + {Name: "content-file", Usage: tr.T("flag.wiki.content_file")}, + {Name: "content-base64", Usage: tr.T("flag.wiki.content_base64")}, + } + dryRunFlag := common.Flag{Name: "dry-run", Usage: tr.T("flag.wiki.dry_run"), Bool: true, Default: "false"} + + writeFlags := func() []common.Flag { + flags := []common.Flag{ + projectIDFlag, + {Name: "page", Short: "p", Usage: tr.T("flag.wiki.page"), Required: true}, + {Name: "title", Short: "t", Usage: tr.T("flag.wiki.title")}, + {Name: "message", Short: "m", Usage: tr.T("flag.wiki.message")}, + } + flags = append(flags, contentFlags...) + flags = append(flags, dryRunFlag) + return flags + } + + return []*common.Shortcut{ + { + Name: "list", + Description: tr.T("cmd.wiki.list.short"), + Long: tr.T("cmd.wiki.list.long"), + Flags: []common.Flag{projectIDFlag}, + Run: func(ctx *common.RuntimeContext) error { + projectID, err := prepare(ctx) + if err != nil { + return err + } + q := baseQuery(ctx, projectID) + env, err := ctx.CallAPIWithQuery("GET", "/wiki/wikiPages", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "view", + Description: tr.T("cmd.wiki.view.short"), + Long: tr.T("cmd.wiki.view.long"), + Flags: []common.Flag{ + projectIDFlag, + {Name: "page", Short: "p", Usage: tr.T("flag.wiki.page"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + projectID, err := prepare(ctx) + if err != nil { + return err + } + page, err := ctx.RequireArg("page") + if err != nil { + return err + } + q := baseQuery(ctx, projectID) + q.Set("pageName", page) + env, err := ctx.CallAPIWithQuery("GET", "/wiki/getWiki", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: tr.T("cmd.wiki.create.short"), + Long: tr.T("cmd.wiki.create.long"), + Flags: writeFlags(), + Run: runWrite("POST", "/wiki/createWiki", true), + }, + { + Name: "update", + Description: tr.T("cmd.wiki.update.short"), + Long: tr.T("cmd.wiki.update.long"), + Flags: writeFlags(), + Run: runWrite("PUT", "/wiki/updateWiki", false), + }, + { + Name: "delete", + Description: tr.T("cmd.wiki.delete.short"), + Long: tr.T("cmd.wiki.delete.long"), + Flags: []common.Flag{ + projectIDFlag, + {Name: "page", Short: "p", Usage: tr.T("flag.wiki.page"), Required: true}, + dryRunFlag, + }, + Run: func(ctx *common.RuntimeContext) error { + projectID, err := prepare(ctx) + if err != nil { + return err + } + page, err := ctx.RequireArg("page") + if err != nil { + return err + } + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + "pageName": page, + } + if ctx.Arg("dry-run") == "true" { + return dryRun(ctx, "DELETE", "/wiki/deleteWiki", body) + } + env, err := ctx.CallAPI("DELETE", "/wiki/deleteWiki", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "export", + Description: tr.T("cmd.wiki.export.short"), + Long: tr.T("cmd.wiki.export.long"), + Flags: []common.Flag{ + projectIDFlag, + {Name: "type", Short: "t", Usage: tr.T("flag.wiki.export_type"), Default: "markdown"}, + {Name: "project-name", Usage: tr.T("flag.wiki.project_name")}, + }, + Run: func(ctx *common.RuntimeContext) error { + projectID, err := prepare(ctx) + if err != nil { + return err + } + exportType := ctx.Arg("type") + if exportType == "" { + exportType = "markdown" + } + if !validExportType(exportType) { + return fmt.Errorf("%s", ctx.Tr.T("error.wiki.export_type")) + } + projectName := ctx.Arg("project-name") + if projectName == "" { + projectName = ctx.Repo + } + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repoName", ctx.Repo) + q.Set("projectId", strconv.FormatInt(projectID, 10)) + q.Set("projectName", projectName) + q.Set("type", exportType) + env, err := ctx.CallAPIWithQuery("GET", "/wikiExport/wikiExport-wrapper", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +// runWrite builds the create/update handlers, which share the same request body. +// contentRequired distinguishes create (content mandatory) from update (optional). +func runWrite(method, path string, contentRequired bool) func(ctx *common.RuntimeContext) error { + return func(ctx *common.RuntimeContext) error { + projectID, err := prepare(ctx) + if err != nil { + return err + } + page, err := ctx.RequireArg("page") + if err != nil { + return err + } + title := ctx.Arg("title") + if title == "" { + title = page + } + content, hasContent, err := wikiContent(ctx) + if err != nil { + return err + } + if contentRequired && !hasContent { + return fmt.Errorf("%s", ctx.Tr.T("error.wiki.content_required")) + } + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + "pageName": page, + "title": title, + "message": ctx.Arg("message"), + } + if hasContent { + body["content_base64"] = content + } + if ctx.Arg("dry-run") == "true" { + return dryRun(ctx, method, path, body) + } + env, err := ctx.CallAPI(method, path, body) + if err != nil { + return err + } + return ctx.Output(env) + } +} + +// prepare resolves owner/repo and the numeric project ID for wiki requests. +func prepare(ctx *common.RuntimeContext) (int64, error) { + if err := ctx.ResolveOwnerRepo(); err != nil { + return 0, err + } + return resolveProjectID(ctx) +} + +// baseQuery returns the owner/repo/projectId query shared by read endpoints. +func baseQuery(ctx *common.RuntimeContext, projectID int64) url.Values { + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("projectId", strconv.FormatInt(projectID, 10)) + return q +} + +// wikiContent returns the base64-encoded wiki content from --content-base64, +// --content, or --content-file (in that order of precedence). +func wikiContent(ctx *common.RuntimeContext) (string, bool, error) { + if raw := ctx.Arg("content-base64"); raw != "" { + return raw, true, nil + } + if text := ctx.Arg("content"); text != "" { + return base64.StdEncoding.EncodeToString([]byte(text)), true, nil + } + if file := ctx.Arg("content-file"); file != "" { + data, err := os.ReadFile(file) + if err != nil { + return "", false, fmt.Errorf("read --content-file: %w", err) + } + return base64.StdEncoding.EncodeToString(data), true, nil + } + return "", false, nil +} + +func dryRun(ctx *common.RuntimeContext, method, path string, body map[string]interface{}) error { + preview := map[string]interface{}{ + "dry_run": true, + "method": method, + "path": path, + "body": body, + } + return ctx.OutputData(preview) +} + +func validExportType(t string) bool { + switch t { + case "pdf", "markdown", "html": + return true + } + return false +} + +// resolveProjectID returns the numeric GitLink project ID from --project-id, +// falling back to the repository's id resolved via owner/repo. +func resolveProjectID(ctx *common.RuntimeContext) (int64, error) { + if raw := strings.TrimSpace(ctx.Arg("project-id")); raw != "" { + return normalizeProjectID(raw) + } + 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("resolve project id: unexpected repository response") + } + for _, key := range []string{"id", "project_id"} { + if id, ok := projectIDValue(data[key]); ok { + return id, nil + } + } + return 0, fmt.Errorf("resolve project id: repository response did not include id") +} + +func normalizeProjectID(value string) (int64, error) { + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("invalid --project-id %q: use a positive numeric project ID", value) + } + return parsed, nil +} + +func projectIDValue(value interface{}) (int64, bool) { + switch v := value.(type) { + case float64: + if v > 0 { + return int64(v), true + } + case int: + if v > 0 { + return int64(v), true + } + case int64: + if v > 0 { + return v, true + } + case string: + if id, err := normalizeProjectID(v); err == nil { + return id, true + } + } + return 0, false +} + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go new file mode 100644 index 0000000..856b2c4 --- /dev/null +++ b/shortcuts/wiki/wiki_test.go @@ -0,0 +1,295 @@ +package wiki + +import ( + "encoding/base64" + "encoding/json" + "io" + "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 runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "alice", + Repo: "demo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) +} + +func findShortcut(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(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +// decodeBody reads the request body into a map. +func decodeBody(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + data, _ := io.ReadAll(r.Body) + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("decode body: %v (raw: %s)", err, string(data)) + } + return m +} + +// --- list / view resolve project id from repo info --- + +func TestWikiListResolvesProjectID(t *testing.T) { + var sawRepo, sawList bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/alice/demo.json": + sawRepo = true + writeJSON(w, map[string]interface{}{"id": float64(4242)}) + case "/wiki/wikiPages.json": + sawList = true + if got := r.URL.Query().Get("projectId"); got != "4242" { + t.Fatalf("projectId = %q, want 4242", got) + } + if got := r.URL.Query().Get("owner"); got != "alice" { + t.Fatalf("owner = %q, want alice", got) + } + writeJSON(w, map[string]interface{}{"data": map[string]interface{}{}}) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + if err := runShortcut(t, server, "list", map[string]string{}); err != nil { + t.Fatalf("list failed: %v", err) + } + if !sawRepo || !sawList { + t.Fatalf("expected repo+list calls, got repo=%v list=%v", sawRepo, sawList) + } +} + +func TestWikiViewExplicitProjectID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/wiki/getWiki.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("pageName"); got != "Home" { + t.Fatalf("pageName = %q, want Home", got) + } + if got := r.URL.Query().Get("projectId"); got != "10" { + t.Fatalf("projectId = %q, want 10", got) + } + writeJSON(w, map[string]interface{}{"data": map[string]interface{}{}}) + })) + defer server.Close() + + args := map[string]string{"project-id": "10", "page": "Home"} + if err := runShortcut(t, server, "view", args); err != nil { + t.Fatalf("view failed: %v", err) + } +} + +func TestWikiViewMissingPage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]interface{}{"data": map[string]interface{}{}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "view", map[string]string{"project-id": "10"}); err == nil { + t.Fatal("expected error for missing --page") + } +} + +// --- create encodes content as base64 --- + +func TestWikiCreateEncodesContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/wiki/createWiki.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + body := decodeBody(t, r) + if body["pageName"] != "Home" { + t.Fatalf("pageName = %v", body["pageName"]) + } + if body["projectId"] != float64(10) { + t.Fatalf("projectId = %v, want 10", body["projectId"]) + } + want := base64.StdEncoding.EncodeToString([]byte("hello")) + if body["content_base64"] != want { + t.Fatalf("content_base64 = %v, want %v", body["content_base64"], want) + } + writeJSON(w, map[string]interface{}{"message": "201"}) + })) + defer server.Close() + + args := map[string]string{"project-id": "10", "page": "Home", "content": "hello"} + if err := runShortcut(t, server, "create", args); err != nil { + t.Fatalf("create failed: %v", err) + } +} + +func TestWikiCreateRequiresContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected when content is missing") + })) + defer server.Close() + + args := map[string]string{"project-id": "10", "page": "Home"} + if err := runShortcut(t, server, "create", args); err == nil { + t.Fatal("expected error for missing content") + } +} + +func TestWikiCreateContentFile(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "page.md") + if err := os.WriteFile(file, []byte("# Title"), 0o600); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := decodeBody(t, r) + want := base64.StdEncoding.EncodeToString([]byte("# Title")) + if body["content_base64"] != want { + t.Fatalf("content_base64 = %v, want %v", body["content_base64"], want) + } + writeJSON(w, map[string]interface{}{"message": "201"}) + })) + defer server.Close() + + args := map[string]string{"project-id": "10", "page": "Home", "content-file": file} + if err := runShortcut(t, server, "create", args); err != nil { + t.Fatalf("create from file failed: %v", err) + } +} + +// --- update allows omitting content --- + +func TestWikiUpdateWithoutContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/wiki/updateWiki.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + body := decodeBody(t, r) + if _, ok := body["content_base64"]; ok { + t.Fatalf("content_base64 should be absent when not provided") + } + if body["title"] != "Renamed" { + t.Fatalf("title = %v, want Renamed", body["title"]) + } + writeJSON(w, map[string]interface{}{"message": "ok"}) + })) + defer server.Close() + + args := map[string]string{"project-id": "10", "page": "Home", "title": "Renamed"} + if err := runShortcut(t, server, "update", args); err != nil { + t.Fatalf("update failed: %v", err) + } +} + +// --- delete dry-run does not call the API --- + +func TestWikiDeleteDryRun(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected in dry-run") + })) + defer server.Close() + + args := map[string]string{"project-id": "10", "page": "Home", "dry-run": "true"} + if err := runShortcut(t, server, "delete", args); err != nil { + t.Fatalf("delete dry-run failed: %v", err) + } +} + +func TestWikiDelete(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/wiki/deleteWiki.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodDelete { + t.Fatalf("method = %s, want DELETE", r.Method) + } + body := decodeBody(t, r) + if body["pageName"] != "Home" { + t.Fatalf("pageName = %v", body["pageName"]) + } + writeJSON(w, map[string]interface{}{"message": "ok"}) + })) + defer server.Close() + + args := map[string]string{"project-id": "10", "page": "Home"} + if err := runShortcut(t, server, "delete", args); err != nil { + t.Fatalf("delete failed: %v", err) + } +} + +// --- export --- + +func TestWikiExportDefaults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/wikiExport/wikiExport-wrapper.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + q := r.URL.Query() + if q.Get("type") != "markdown" { + t.Fatalf("type = %q, want markdown", q.Get("type")) + } + if q.Get("repoName") != "demo" { + t.Fatalf("repoName = %q, want demo", q.Get("repoName")) + } + if q.Get("projectName") != "demo" { + t.Fatalf("projectName = %q, want demo (default to repo)", q.Get("projectName")) + } + writeJSON(w, map[string]interface{}{"data": map[string]interface{}{}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "export", map[string]string{"project-id": "10"}); err != nil { + t.Fatalf("export failed: %v", err) + } +} + +func TestWikiExportInvalidType(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected for invalid type") + })) + defer server.Close() + + args := map[string]string{"project-id": "10", "type": "docx"} + err := runShortcut(t, server, "export", args) + if err == nil || !strings.Contains(err.Error(), "type") { + t.Fatalf("expected invalid type error, got %v", err) + } +} + +// --- invalid project id --- + +func TestWikiInvalidProjectID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected for invalid project id") + })) + defer server.Close() + + args := map[string]string{"project-id": "abc"} + if err := runShortcut(t, server, "list", args); err == nil { + t.Fatal("expected error for invalid --project-id") + } +} From 3ca9005ca22680bcc82a1ae9b139b6228a2b9d63 Mon Sep 17 00:00:00 2001 From: luwanzhou Date: Sun, 14 Jun 2026 03:15:26 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(wiki):=20=E4=BF=AE=E6=AD=A3=20API=20?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=EF=BC=8C=E6=B7=BB=E5=8A=A0=20/open/=20?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E5=8C=B9=E9=85=8D=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文档指定 wiki API 路径为 /wiki/open/xxx,但代码中遗漏了 /open/ 前缀。 修正所有 wiki 命令的 API 路径: - /wiki/wikiPages → /wiki/open/wikiPages - /wiki/getWiki → /wiki/open/getWiki - /wiki/createWiki → /wiki/open/createWiki - /wiki/updateWiki → /wiki/open/updateWiki - /wiki/deleteWiki → /wiki/open/deleteWiki 同步更新测试文件和变更文档中的路径引用。 --- doc/changes/wiki-shortcuts.md | 12 ++++++------ shortcuts/wiki/wiki.go | 12 ++++++------ shortcuts/wiki/wiki_test.go | 10 +++++----- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/doc/changes/wiki-shortcuts.md b/doc/changes/wiki-shortcuts.md index 4a32993..c33820f 100644 --- a/doc/changes/wiki-shortcuts.md +++ b/doc/changes/wiki-shortcuts.md @@ -5,7 +5,7 @@ Adds a new `wiki` shortcut group so maintainers and agents can manage a repository's wiki without falling back to Raw API calls. Wiki was listed in the competition guide as a desired capability and previously had no shortcut -coverage. The commands wrap GitLink's `/api/wiki/*` and `/api/wikiExport/*` +coverage. The commands wrap GitLink's `/api/wiki/open/*` and `/api/wikiExport/*` endpoints, which require the numeric GitLink project ID in addition to owner/repo. @@ -13,11 +13,11 @@ owner/repo. | Command | Purpose | Endpoint | |---------|---------|----------| -| `gitlink-cli wiki +list` | List wiki pages | `GET /wiki/wikiPages` | -| `gitlink-cli wiki +view` | View a wiki page | `GET /wiki/getWiki` | -| `gitlink-cli wiki +create` | Create a wiki page | `POST /wiki/createWiki` | -| `gitlink-cli wiki +update` | Update a wiki page | `PUT /wiki/updateWiki` | -| `gitlink-cli wiki +delete` | Delete a wiki page | `DELETE /wiki/deleteWiki` | +| `gitlink-cli wiki +list` | List wiki pages | `GET /wiki/open/wikiPages` | +| `gitlink-cli wiki +view` | View a wiki page | `GET /wiki/open/getWiki` | +| `gitlink-cli wiki +create` | Create a wiki page | `POST /wiki/open/createWiki` | +| `gitlink-cli wiki +update` | Update a wiki page | `PUT /wiki/open/updateWiki` | +| `gitlink-cli wiki +delete` | Delete a wiki page | `DELETE /wiki/open/deleteWiki` | | `gitlink-cli wiki +export` | Export the wiki | `GET /wikiExport/wikiExport-wrapper` | ## Behaviour diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index b824338..a65594d 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -52,7 +52,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return err } q := baseQuery(ctx, projectID) - env, err := ctx.CallAPIWithQuery("GET", "/wiki/wikiPages", q) + env, err := ctx.CallAPIWithQuery("GET", "/wiki/open/wikiPages", q) if err != nil { return err } @@ -78,7 +78,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } q := baseQuery(ctx, projectID) q.Set("pageName", page) - env, err := ctx.CallAPIWithQuery("GET", "/wiki/getWiki", q) + env, err := ctx.CallAPIWithQuery("GET", "/wiki/open/getWiki", q) if err != nil { return err } @@ -90,14 +90,14 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: tr.T("cmd.wiki.create.short"), Long: tr.T("cmd.wiki.create.long"), Flags: writeFlags(), - Run: runWrite("POST", "/wiki/createWiki", true), + Run: runWrite("POST", "/wiki/open/createWiki", true), }, { Name: "update", Description: tr.T("cmd.wiki.update.short"), Long: tr.T("cmd.wiki.update.long"), Flags: writeFlags(), - Run: runWrite("PUT", "/wiki/updateWiki", false), + Run: runWrite("PUT", "/wiki/open/updateWiki", false), }, { Name: "delete", @@ -124,9 +124,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { "pageName": page, } if ctx.Arg("dry-run") == "true" { - return dryRun(ctx, "DELETE", "/wiki/deleteWiki", body) + return dryRun(ctx, "DELETE", "/wiki/open/deleteWiki", body) } - env, err := ctx.CallAPI("DELETE", "/wiki/deleteWiki", body) + env, err := ctx.CallAPI("DELETE", "/wiki/open/deleteWiki", body) if err != nil { return err } diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go index 856b2c4..2451b7b 100644 --- a/shortcuts/wiki/wiki_test.go +++ b/shortcuts/wiki/wiki_test.go @@ -64,7 +64,7 @@ func TestWikiListResolvesProjectID(t *testing.T) { case "/alice/demo.json": sawRepo = true writeJSON(w, map[string]interface{}{"id": float64(4242)}) - case "/wiki/wikiPages.json": + case "/wiki/open/wikiPages.json": sawList = true if got := r.URL.Query().Get("projectId"); got != "4242" { t.Fatalf("projectId = %q, want 4242", got) @@ -89,7 +89,7 @@ func TestWikiListResolvesProjectID(t *testing.T) { func TestWikiViewExplicitProjectID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/wiki/getWiki.json" { + if r.URL.Path != "/wiki/open/getWiki.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } if got := r.URL.Query().Get("pageName"); got != "Home" { @@ -123,7 +123,7 @@ func TestWikiViewMissingPage(t *testing.T) { func TestWikiCreateEncodesContent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/wiki/createWiki.json" { + if r.URL.Path != "/wiki/open/createWiki.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } body := decodeBody(t, r) @@ -185,7 +185,7 @@ func TestWikiCreateContentFile(t *testing.T) { func TestWikiUpdateWithoutContent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/wiki/updateWiki.json" { + if r.URL.Path != "/wiki/open/updateWiki.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } body := decodeBody(t, r) @@ -221,7 +221,7 @@ func TestWikiDeleteDryRun(t *testing.T) { func TestWikiDelete(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/wiki/deleteWiki.json" { + if r.URL.Path != "/wiki/open/deleteWiki.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } if r.Method != http.MethodDelete { From f9f96cfedc5df3259c94ce633dbd8ac41b884678 Mon Sep 17 00:00:00 2001 From: luwanzhou Date: Sun, 14 Jun 2026 03:33:08 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(wiki):=20gateway=E5=9F=9F=E5=90=8D?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20&=20=E5=8E=BB=E9=99=A4.json=E5=90=8E?= =?UTF-8?q?=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wiki API 仅在 gateway.gitlink.org.cn 上可用,prepare() 中自动切换域名 - gateway 不接受 .json 后缀,shouldAppendJSONSuffix 排除 /wiki/open/ 路径 - 测试路径同步更新(去除 .json 后缀) - 已验证: +list, +view, +create(dry-run), +update(dry-run), +delete(dry-run) - TODO: wiki +export 的 /wikiExport API 返回 500,待平台修复后跟进 --- internal/client/client.go | 5 +++++ shortcuts/wiki/wiki.go | 18 ++++++++++++++++-- shortcuts/wiki/wiki_test.go | 10 +++++----- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index 1d20aed..6f04287 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -164,6 +164,11 @@ func shouldAppendJSONSuffix(path string) bool { if strings.HasSuffix(path, ".json") { return false } + // Wiki open endpoints are served by gateway.gitlink.org.cn which does not + // accept the .json suffix used by the www.gitlink.org.cn API convention. + if strings.Contains(path, "/wiki/open/") { + return false + } parts := strings.Split(strings.Trim(path, "/"), "/") for i, part := range parts { if part == "raw" && i >= 2 && i+2 < len(parts) { diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index a65594d..c432001 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -219,12 +219,26 @@ func runWrite(method, path string, contentRequired bool) func(ctx *common.Runtim } } -// prepare resolves owner/repo and the numeric project ID for wiki requests. +// prepare resolves owner/repo and the numeric project ID for wiki requests, +// and switches the API base URL to the gateway endpoint that hosts wiki APIs. func prepare(ctx *common.RuntimeContext) (int64, error) { if err := ctx.ResolveOwnerRepo(); err != nil { return 0, err } - return resolveProjectID(ctx) + // Resolve project ID first (requires www base URL for /owner/repo endpoint). + projectID, err := resolveProjectID(ctx) + if err != nil { + return 0, err + } + // Switch to gateway for wiki API calls (/wiki/open/* only available there). + switchToGateway(ctx) + return projectID, nil +} + +// switchToGateway replaces the www subdomain with gateway in the API base URL. +// Wiki endpoints (/wiki/open/*) are only available on gateway.gitlink.org.cn. +func switchToGateway(ctx *common.RuntimeContext) { + ctx.Client.BaseURL = strings.Replace(ctx.Client.BaseURL, "www.gitlink.org.cn", "gateway.gitlink.org.cn", 1) } // baseQuery returns the owner/repo/projectId query shared by read endpoints. diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go index 2451b7b..99013d6 100644 --- a/shortcuts/wiki/wiki_test.go +++ b/shortcuts/wiki/wiki_test.go @@ -64,7 +64,7 @@ func TestWikiListResolvesProjectID(t *testing.T) { case "/alice/demo.json": sawRepo = true writeJSON(w, map[string]interface{}{"id": float64(4242)}) - case "/wiki/open/wikiPages.json": + case "/wiki/open/wikiPages": sawList = true if got := r.URL.Query().Get("projectId"); got != "4242" { t.Fatalf("projectId = %q, want 4242", got) @@ -89,7 +89,7 @@ func TestWikiListResolvesProjectID(t *testing.T) { func TestWikiViewExplicitProjectID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/wiki/open/getWiki.json" { + if r.URL.Path != "/wiki/open/getWiki" { t.Fatalf("unexpected path: %s", r.URL.Path) } if got := r.URL.Query().Get("pageName"); got != "Home" { @@ -123,7 +123,7 @@ func TestWikiViewMissingPage(t *testing.T) { func TestWikiCreateEncodesContent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/wiki/open/createWiki.json" { + if r.URL.Path != "/wiki/open/createWiki" { t.Fatalf("unexpected path: %s", r.URL.Path) } body := decodeBody(t, r) @@ -185,7 +185,7 @@ func TestWikiCreateContentFile(t *testing.T) { func TestWikiUpdateWithoutContent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/wiki/open/updateWiki.json" { + if r.URL.Path != "/wiki/open/updateWiki" { t.Fatalf("unexpected path: %s", r.URL.Path) } body := decodeBody(t, r) @@ -221,7 +221,7 @@ func TestWikiDeleteDryRun(t *testing.T) { func TestWikiDelete(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/wiki/open/deleteWiki.json" { + if r.URL.Path != "/wiki/open/deleteWiki" { t.Fatalf("unexpected path: %s", r.URL.Path) } if r.Method != http.MethodDelete { From 339900785f72f48cc93cfa1cb5529ef740466ac6 Mon Sep 17 00:00:00 2001 From: co63oc <4617245+co63oc@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:07:33 +0800 Subject: [PATCH 4/4] fix(client): support gateway error format and wiki/open paths Cherry-picked from co63oc's PR #193: - Handle gateway {"code":N, "msg":"..."} error format - Extend success status codes (201, 204) - Exclude wiki/open paths from .json suffix auto-append Co-Authored-By: co63oc <4617245+co63oc@users.noreply.github.com> --- internal/client/client.go | 39 +++++++++++++++++------- internal/client/client_test.go | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index 6f04287..c517dc6 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, } } @@ -175,6 +188,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) + } + } +}