forked from Gitlink/gitlink-cli
Merge PR #242: feat: 新增 wiki 管理快捷命令
# Conflicts: # internal/i18n/locales/en-US.json # internal/i18n/locales/zh-CN.json # shortcuts/register_test.go # shortcuts/wiki/wiki.go # shortcuts/wiki/wiki_test.go
This commit is contained in:
commit
dc0154018e
27
README.md
27
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:
|
||||
|
|
|
|||
|
|
@ -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 直接调用:
|
||||
|
|
|
|||
|
|
@ -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,为科研项目文档沉淀与导出等场景提供支撑。
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue