diff --git a/README.md b/README.md index c8bea48..2412eec 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,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 | @@ -595,6 +596,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 276e590..0fce1ff 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -106,6 +106,7 @@ | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | | ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | +| 📖 Wiki | 列出、查看、创建、更新、删除、导出 Wiki 页面 | | 🔍 搜索 | 搜索仓库、用户 | | 👤 用户 | 查看用户资料和信息 | | 📋 项目管理 | Sprint 管理、看板、周报 | @@ -474,6 +475,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..c33820f --- /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/open/*` 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/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 + +- `--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/client/client.go b/internal/client/client.go index ac5c3ff..7cb19a5 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, } } @@ -164,12 +177,21 @@ 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) { 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/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 9c7bc35..0b66236 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -54,13 +54,6 @@ "cmd.pr.version_diff.short": "Show diff for a pull request patchset version", "cmd.pr.versions.short": "List pull request patchset versions", "cmd.pr.view.short": "View pull request details", - "cmd.reaction.follow.short": "Follow a repository", - "cmd.reaction.like.short": "Like a repository", - "cmd.reaction.short": "Repository reaction operations", - "cmd.reaction.stargazers.short": "List users who liked the repository", - "cmd.reaction.unfollow.short": "Unfollow a repository", - "cmd.reaction.unlike.short": "Unlike a repository", - "cmd.reaction.watchers.short": "List repository watchers", "cmd.release.create.short": "Create a release", "cmd.release.delete.short": "Delete a release", "cmd.release.list.short": "List releases", @@ -90,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}", @@ -97,6 +103,8 @@ "error.config.save_failed": "failed to save config: {message}", "error.missing_required_flag": "required flag --{name} is missing", "error.unsupported_language": "unsupported language: {lang}", + "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", @@ -168,9 +176,6 @@ "flag.pr.tag_id": "Issue tag ID", "flag.pr.title": "PR title", "flag.pr.version_id": "Patchset version ID", - "flag.reaction.end_at": "End Unix timestamp", - "flag.reaction.project_id": "GitLink project ID. If omitted, it is resolved from --owner/--repo.", - "flag.reaction.start_at": "Start Unix timestamp", "flag.release.body": "Release notes", "flag.release.id": "Release ID", "flag.release.id_or_tag": "Release ID or tag", @@ -200,6 +205,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 (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}", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 62614b2..074894b 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -54,13 +54,6 @@ "cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff", "cmd.pr.versions.short": "列出拉取请求补丁集版本", "cmd.pr.view.short": "查看拉取请求详情", - "cmd.reaction.follow.short": "关注仓库", - "cmd.reaction.like.short": "点赞仓库", - "cmd.reaction.short": "仓库互动操作", - "cmd.reaction.stargazers.short": "列出仓库点赞用户", - "cmd.reaction.unfollow.short": "取消关注仓库", - "cmd.reaction.unlike.short": "取消点赞仓库", - "cmd.reaction.watchers.short": "列出仓库关注用户", "cmd.release.create.short": "创建发布", "cmd.release.delete.short": "删除发布", "cmd.release.list.short": "列出发布", @@ -90,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}", @@ -97,6 +103,8 @@ "error.config.save_failed": "保存配置失败:{message}", "error.missing_required_flag": "缺少必需参数 --{name}", "error.unsupported_language": "不支持的语言:{lang}", + "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 批处理计划", @@ -168,9 +176,6 @@ "flag.pr.tag_id": "议题标签 ID", "flag.pr.title": "PR 标题", "flag.pr.version_id": "补丁集版本 ID", - "flag.reaction.end_at": "结束 Unix 时间戳", - "flag.reaction.project_id": "GitLink 项目 ID。未提供时会根据 --owner/--repo 自动解析。", - "flag.reaction.start_at": "开始 Unix 时间戳", "flag.release.body": "发布说明", "flag.release.id": "发布 ID", "flag.release.id_or_tag": "发布 ID 或标签", @@ -200,6 +205,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 页面内容(纯文本,自动 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}", diff --git a/shortcuts/register.go b/shortcuts/register.go index 608dbd1..4b1d712 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -51,6 +51,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), "wiki": wiki.Shortcuts(), "workflow": workflow.Shortcuts(), @@ -74,6 +75,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", "wiki": "Wiki operations", "workflow": "AI agent workflow analysis", diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index a54baf3..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", "reaction", + "wiki", "health", } groupSet := map[string]bool{} diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index 6915173..c432001 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -1,29 +1,58 @@ +// 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" ) -func Shortcuts() []*common.Shortcut { +// 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: "pages", - Description: "List repository wiki pages", - Flags: wikiProjectFlags(), + 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 { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - q, err := wikiProjectQuery(ctx) + projectID, err := prepare(ctx) if err != nil { return err } - env, err := ctx.CallAPIWithQuery("GET", "/wiki/wikiPages", q) + q := baseQuery(ctx, projectID) + env, err := ctx.CallAPIWithQuery("GET", "/wiki/open/wikiPages", q) if err != nil { return err } @@ -32,15 +61,14 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "Show a wiki page", - Flags: append(wikiProjectFlags(), - common.Flag{Name: "page", Short: "p", Usage: "Wiki page name", Required: true}, - ), + 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 { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - q, err := wikiProjectQuery(ctx) + projectID, err := prepare(ctx) if err != nil { return err } @@ -48,8 +76,9 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } + 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 } @@ -58,54 +87,29 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a wiki page", - Flags: wikiWriteFlags(true), - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - payload, err := wikiWritePayload(ctx, true) - if err != nil { - return err - } - env, err := ctx.CallAPI("POST", "/wiki/createWiki", payload) - if err != nil { - return err - } - return ctx.Output(env) - }, + Description: tr.T("cmd.wiki.create.short"), + Long: tr.T("cmd.wiki.create.long"), + Flags: writeFlags(), + Run: runWrite("POST", "/wiki/open/createWiki", true), }, { Name: "update", - Description: "Update a wiki page", - Flags: wikiWriteFlags(false), - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - payload, err := wikiWritePayload(ctx, false) - if err != nil { - return err - } - env, err := ctx.CallAPI("PUT", "/wiki/updateWiki", payload) - if err != nil { - return err - } - return ctx.Output(env) - }, + Description: tr.T("cmd.wiki.update.short"), + Long: tr.T("cmd.wiki.update.long"), + Flags: writeFlags(), + Run: runWrite("PUT", "/wiki/open/updateWiki", false), }, { Name: "delete", - Description: "Delete a wiki page", - Flags: append(wikiProjectFlags(), - common.Flag{Name: "page", Short: "p", Usage: "Wiki page name", Required: true}, - common.Flag{Name: "dry-run", Usage: "Preview the delete request without changing wiki state", Bool: true, Default: "false"}, - ), + 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 { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - payload, err := wikiBasePayload(ctx) + projectID, err := prepare(ctx) if err != nil { return err } @@ -113,17 +117,54 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - payload["pageName"] = page - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "action": "delete_wiki_page", - "method": "DELETE", - "path": "/wiki/deleteWiki", - "payload": payload, - }) + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": projectID, + "pageName": page, } - env, err := ctx.CallAPI("DELETE", "/wiki/deleteWiki", payload) + if ctx.Arg("dry-run") == "true" { + return dryRun(ctx, "DELETE", "/wiki/open/deleteWiki", body) + } + env, err := ctx.CallAPI("DELETE", "/wiki/open/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 } @@ -133,100 +174,174 @@ func Shortcuts() []*common.Shortcut { } } -func wikiProjectFlags() []common.Flag { - return []common.Flag{ - {Name: "project-id", Usage: "GitLink project ID", Required: true}, +// 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) } } -func wikiWriteFlags(contentRequired bool) []common.Flag { - return append(wikiProjectFlags(), - common.Flag{Name: "page", Short: "p", Usage: "Wiki page name", Required: true}, - common.Flag{Name: "title", Short: "t", Usage: "Wiki page title", Required: true}, - common.Flag{Name: "message", Short: "m", Usage: "Commit message"}, - common.Flag{Name: "content", Usage: "Wiki content; encoded to base64 before sending"}, - common.Flag{Name: "content-base64", Usage: "Pre-encoded wiki content"}, - ) -} - -func wikiProjectQuery(ctx *common.RuntimeContext) (url.Values, error) { - projectID, err := wikiProjectID(ctx) - if err != nil { - return nil, err +// 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 } - q := url.Values{} - q.Set("owner", ctx.Owner) - q.Set("repo", ctx.Repo) - q.Set("projectId", strconv.Itoa(projectID)) - return q, nil -} - -func wikiWritePayload(ctx *common.RuntimeContext, contentRequired bool) (map[string]interface{}, error) { - payload, err := wikiBasePayload(ctx) - if err != nil { - return nil, err - } - page, err := ctx.RequireArg("page") - if err != nil { - return nil, err - } - title, err := ctx.RequireArg("title") - if err != nil { - return nil, err - } - content, ok, err := wikiContent(ctx, contentRequired) - if err != nil { - return nil, err - } - payload["pageName"] = page - payload["title"] = title - if message := ctx.Arg("message"); message != "" { - payload["message"] = message - } - if ok { - payload["content_base64"] = content - } - return payload, nil -} - -func wikiBasePayload(ctx *common.RuntimeContext) (map[string]interface{}, error) { - projectID, err := wikiProjectID(ctx) - if err != nil { - return nil, err - } - return map[string]interface{}{ - "owner": ctx.Owner, - "repo": ctx.Repo, - "projectId": projectID, - }, nil -} - -func wikiProjectID(ctx *common.RuntimeContext) (int, error) { - value, err := ctx.RequireArg("project-id") + // Resolve project ID first (requires www base URL for /owner/repo endpoint). + projectID, err := resolveProjectID(ctx) if err != nil { return 0, err } - id, err := strconv.Atoi(value) - if err != nil || id <= 0 { - return 0, fmt.Errorf("--project-id must be a positive integer") - } - return id, nil + // Switch to gateway for wiki API calls (/wiki/open/* only available there). + switchToGateway(ctx) + return projectID, nil } -func wikiContent(ctx *common.RuntimeContext, required bool) (string, bool, error) { - content := ctx.Arg("content") - encoded := ctx.Arg("content-base64") - if content != "" && encoded != "" { - return "", false, fmt.Errorf("--content cannot be used with --content-base64") +// 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. +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 content != "" { - return base64.StdEncoding.EncodeToString([]byte(content)), true, nil + if text := ctx.Arg("content"); text != "" { + return base64.StdEncoding.EncodeToString([]byte(text)), true, nil } - if encoded != "" { - return encoded, true, nil - } - if required { - return "", false, fmt.Errorf("required flag --content is missing (or use --content-base64)") + 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 index a1d1b62..99013d6 100644 --- a/shortcuts/wiki/wiki_test.go +++ b/shortcuts/wiki/wiki_test.go @@ -1,218 +1,295 @@ package wiki import ( + "encoding/base64" "encoding/json" - "fmt" + "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/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func TestWikiShortcutsRouteToOpenAPIEndpoints(t *testing.T) { - cases := []struct { - name string - args map[string]string - method string - path string - query map[string]string - payload map[string]interface{} - }{ - { - name: "pages", - args: map[string]string{"project-id": "123"}, - method: "GET", - path: "/wiki/wikiPages.json", - query: map[string]string{"owner": "owner", "repo": "repo", "projectId": "123"}, - }, - { - name: "view", - args: map[string]string{"project-id": "123", "page": "Home"}, - method: "GET", - path: "/wiki/getWiki.json", - query: map[string]string{"owner": "owner", "repo": "repo", "projectId": "123", "pageName": "Home"}, - }, - { - name: "create", - args: map[string]string{"project-id": "123", "page": "Home", "title": "Home", "message": "Add Home", "content": "hello"}, - method: "POST", - path: "/wiki/createWiki.json", - payload: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "projectId": float64(123), - "pageName": "Home", - "title": "Home", - "message": "Add Home", - "content_base64": "aGVsbG8=", - }, - }, - { - name: "update", - args: map[string]string{"project-id": "123", "page": "Home", "title": "Home", "content-base64": "dXBkYXRlZA=="}, - method: "PUT", - path: "/wiki/updateWiki.json", - payload: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "projectId": float64(123), - "pageName": "Home", - "title": "Home", - "content_base64": "dXBkYXRlZA==", - }, - }, - { - name: "delete", - args: map[string]string{"project-id": "123", "page": "Home"}, - method: "DELETE", - path: "/wiki/deleteWiki.json", - payload: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "projectId": float64(123), - "pageName": "Home", - }, - }, +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) +} - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - called := false - server := newWikiTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != tc.method || r.URL.Path != tc.path { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - assertWikiQuery(t, r, tc.query) - if tc.payload != nil { - assertWikiPayload(t, r, tc.payload) - } - called = true - writeWikiJSON(t, w, map[string]interface{}{"message": "success", "data": map[string]interface{}{}}) - }) - defer server.Close() +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 +} - err := runWikiShortcut(server, tc.name, tc.args) - if err != nil { - t.Fatalf("%s shortcut failed: %v", tc.name, err) +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/open/wikiPages": + sawList = true + if got := r.URL.Query().Get("projectId"); got != "4242" { + t.Fatalf("projectId = %q, want 4242", got) } - if !called { - t.Fatal("expected API request") + 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 TestWikiDeleteDryRunDoesNotCallAPI(t *testing.T) { - server := newWikiTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - }) +func TestWikiViewExplicitProjectID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/wiki/open/getWiki" { + 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() - err := runWikiShortcut(server, "delete", map[string]string{ - "project-id": "123", - "page": "Home", - "dry-run": "true", - }) - if err != nil { + 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/open/createWiki" { + 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/open/updateWiki" { + 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 TestWikiShortcutsValidateRequiredArgs(t *testing.T) { - server := newWikiTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - }) +func TestWikiDelete(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/wiki/open/deleteWiki" { + 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() - cases := []struct { - name string - args map[string]string - want string - }{ - {name: "pages", args: map[string]string{}, want: "--project-id"}, - {name: "pages", args: map[string]string{"project-id": "abc"}, want: "--project-id must be a positive integer"}, - {name: "view", args: map[string]string{"project-id": "123"}, want: "--page"}, - {name: "create", args: map[string]string{"project-id": "123", "page": "Home", "title": "Home"}, want: "--content"}, - {name: "create", args: map[string]string{"project-id": "123", "page": "Home", "title": "Home", "content": "x", "content-base64": "eA=="}, want: "--content cannot be used with --content-base64"}, - {name: "update", args: map[string]string{"project-id": "123", "page": "Home"}, want: "--title"}, - {name: "delete", args: map[string]string{"project-id": "123"}, want: "--page"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - err := runWikiShortcut(server, tc.name, tc.args) - if err == nil { - t.Fatal("expected validation error") - } - if !strings.Contains(err.Error(), tc.want) { - t.Fatalf("error = %q, want it to mention %s", err.Error(), tc.want) - } - }) + args := map[string]string{"project-id": "10", "page": "Home"} + if err := runShortcut(t, server, "delete", args); err != nil { + t.Fatalf("delete failed: %v", err) } } -func runWikiShortcut(server *httptest.Server, name string, args map[string]string) error { - for _, shortcut := range Shortcuts() { - if shortcut.Name != name { - continue +// --- 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) } - ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - Tr: i18n.Default(), + q := r.URL.Query() + if q.Get("type") != "markdown" { + t.Fatalf("type = %q, want markdown", q.Get("type")) } - return shortcut.Run(ctx) - } - return fmt.Errorf("shortcut %q not found", name) -} - -func newWikiTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { - t.Helper() - return httptest.NewServer(handler) -} - -func assertWikiQuery(t *testing.T, r *http.Request, want map[string]string) { - t.Helper() - query := r.URL.Query() - if len(query) != len(want) { - t.Fatalf("query = %v, want %v", query, want) - } - for key, value := range want { - if got := query.Get(key); got != value { - t.Fatalf("query %s = %q, want %q", key, got, value) + if q.Get("repoName") != "demo" { + t.Fatalf("repoName = %q, want demo", q.Get("repoName")) } - } -} - -func assertWikiPayload(t *testing.T, r *http.Request, want map[string]interface{}) { - t.Helper() - var got map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&got); err != nil { - t.Fatalf("decode request body: %v", err) - } - if len(got) != len(want) { - t.Fatalf("payload = %v, want %v", got, want) - } - for key, value := range want { - if got[key] != value { - t.Fatalf("payload %s = %v, want %v", key, got[key], value) + 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 writeWikiJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) +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") } }