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 <noreply@anthropic.com>
This commit is contained in:
luwanzhou 2026-06-14 00:38:19 +08:00
parent 52b7093846
commit 13fa90183f
9 changed files with 813 additions and 17 deletions

View File

@ -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:

View File

@ -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
# 导出 Wikimarkdown、pdf 或 html
gitlink-cli wiki +export --owner Gitlink --repo gitlink-cli --type markdown
```
### Raw API
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:

View File

@ -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为科研项目文档沉淀与导出等场景提供支撑。

View File

@ -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: ",

View File

@ -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": "用户名/邮箱/手机号:",

View File

@ -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",
}

View File

@ -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{}

333
shortcuts/wiki/wiki.go Normal file
View File

@ -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()
}

295
shortcuts/wiki/wiki_test.go Normal file
View File

@ -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")
}
}