gitlink-cli/shortcuts/wiki/wiki.go

827 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package wiki
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/gitlink-org/gitlink-cli/internal/auth"
"github.com/gitlink-org/gitlink-cli/internal/client"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
var projectIDCache sync.Map
func wikiPath(endpoint string) string {
return "/wiki/open/" + endpoint
}
// getGatewayClient returns a client targeting the Wiki API gateway.
// The BaseURL is resolved from RuntimeContext.GatewayBaseURL, which in turn
// honours (in order): GITLINK_GATEWAY_URL env > config gateway_base_url > default.
// HTTP client falls back to auth.NewHTTPClient() when ctx.GatewayHTTPClient is nil.
func getGatewayClient(ctx *common.RuntimeContext) *client.Client {
baseURL := ctx.GatewayBaseURL
if baseURL == "" {
baseURL = "https://gateway.gitlink.org.cn/api"
}
httpClient := ctx.GatewayHTTPClient
if httpClient == nil {
httpClient = auth.NewHTTPClient()
}
return &client.Client{
HTTP: httpClient,
BaseURL: baseURL,
SkipJSONSuffix: true,
Debug: ctx.Client.Debug,
}
}
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, body, nil)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, nil, query)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) {
resp, ok := env.Data.(map[string]interface{})
if !ok {
return env, nil
}
if code, ok := resp["code"]; ok {
switch v := code.(type) {
case float64:
// HTTP 2xx 全部视为成功200 OK / 201 Created / 202 Accepted / 204 No Content 等)
// 之前只接受 200/201导致 DELETE 返回 204 时被误判为失败
if v < 200 || v >= 300 {
msg, _ := resp["msg"].(string)
// 必须返回 *clierrors.CLIError否则 shortcuts/common.TryPrintError
// 的 errors.As 无法识别,错误就不会按 envelope 格式输出,
// 会回退到 stderr 的纯文本输出(破坏 --format json/table/yaml
kind := clierrors.KindServer
if int(v) == 404 {
kind = clierrors.KindNotFound
} else if int(v) == 401 || int(v) == 403 {
kind = clierrors.KindForbidden
}
return nil, clierrors.New(kind, msg,
"检查 owner/repo 是否正确,或确认仓库已在 GitLink 网页端开启 Wiki 功能")
}
}
}
if innerData, ok := resp["data"]; ok {
return output.SuccessEnvelope(innerData, env.Meta), nil
}
return env, nil
}
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
key := ctx.Owner + "/" + ctx.Repo
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil
}
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return "", fmt.Errorf("failed to fetch project details (needed for projectId): %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("unexpected response from project detail API")
}
pid, ok := data["project_id"]
if !ok {
return "", fmt.Errorf("project_id not found in project detail response")
}
var pidStr string
switch v := pid.(type) {
case float64:
pidStr = fmt.Sprintf("%.0f", v)
case int:
pidStr = fmt.Sprintf("%d", v)
default:
pidStr = fmt.Sprintf("%v", v)
}
projectIDCache.Store(key, pidStr)
return pidStr, nil
}
func parseProjectIDInt(pid string) int {
n, _ := strconv.Atoi(pid)
return n
}
func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (string, error) {
if text != "" {
return text, nil
}
if filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("no content provided")
}
// fetchPageContent 获取 wiki 页面明文内容。
//
// 自动重试策略GitLink 后端创建 wiki 时会自动给 sub_url 追加 ".-" 后缀,
// 而 wiki +list 返回的 title 不带后缀。若首次用原始 pageName 查询失败且
// pageName 不带 ".-" 后缀,自动用 pageName+".-" 重试一次。
//
// 返回值:
// - content: 解码后的明文 markdown
// - actualPageName: 实际查询成功的 pageName可能带 ".-" 后缀),供调用方做后续写操作
// - err: 错误信息
func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (content string, actualPageName string, err error) {
c, actual, err := fetchPageContentOnce(ctx, projectID, pageName)
if err == nil {
return c, actual, nil
}
// 首次失败且 pageName 不带 ".-" 后缀:自动重试一次
if !strings.HasSuffix(pageName, ".-") {
c2, actual2, err2 := fetchPageContentOnce(ctx, projectID, pageName+".-")
if err2 == nil {
return c2, actual2, nil
}
}
return "", "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err)
}
// fetchPageContentOnce 单次尝试获取 wiki 页面内容(不做重试)。
func fetchPageContentOnce(ctx *common.RuntimeContext, projectID, pageName string) (string, string, error) {
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if err != nil {
return "", "", err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", "", fmt.Errorf("unexpected response from getWiki")
}
b64, _ := data["content_base64"].(string)
if b64 == "" {
return "", pageName, nil
}
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", "", fmt.Errorf("failed to decode page content: %w", err)
}
return string(decoded), pageName, nil
}
// fetchWikiPage 按 pageName 查询 wiki 页面,返回完整的 envelope。
// 调用方负责处理错误(包括 404和后续的 sub_url 重试逻辑。
func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*output.Envelope, error) {
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
return callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
}
func resolveContent(ctx *common.RuntimeContext) (string, error) {
if content := ctx.Arg("content"); content != "" {
return content, nil
}
if filePath := ctx.Arg("file"); filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("--content or --file is required to provide wiki page content")
}
func cleanWikiList(env *output.Envelope) {
items, ok := env.Data.([]interface{})
if !ok {
return
}
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
delete(m, "wiki_clone_link")
if raw, ok := m["sub_url"].(string); ok {
if decoded, err := url.QueryUnescape(raw); err == nil {
m["sub_url"] = decoded
}
}
}
}
// outputWithDecodedContent 解码 wiki 响应中的所有 base64 字段,用明文替换原始乱码。
//
// 设计权衡agent 友好性):
// - 后端返回的字段content_base64、sidebar、footer都是 base64 编码,对 agent 不可读
// - 解码后用明文替换/移除原始字段agent 可直接阅读、抽取、总结
// - 节省 ~33% tokenbase64 编码膨胀部分)
//
// 已知字段映射:
// - content_base64 → content重命名删除原字段
// - sidebar → sidebar原地替换仅当解码成功
// - footer → footer原地替换仅当解码成功
//
// 安全策略:仅当 base64.StdEncoding.DecodeString 成功时才替换;
// 若后端某天改为明文,解码失败会自动跳过,不影响兼容性。
func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope) error {
data := env.Data
// 后端某些端点(如 createWiki/updateWiki把 data 返回为 JSON 字符串,
// client.go 会把它解析为 json.RawMessage而非 map这里先转回 map 再处理。
// view/getWiki 端点直接返回 JSON 对象data 已是 map[string]interface{}。
if raw, ok := data.(json.RawMessage); ok {
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err == nil {
data = m
env.Data = m
}
}
m, ok := data.(map[string]interface{})
if !ok {
return ctx.Output(env)
}
// content_base64 → content重命名
if b64, ok := m["content_base64"].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m["content"] = string(decoded)
delete(m, "content_base64")
}
}
// sidebar / footer原地替换仅当能解码为 base64 时)
for _, field := range []string{"sidebar", "footer"} {
if b64, ok := m[field].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m[field] = string(decoded)
}
}
}
return ctx.Output(env)
}
// --- lint types and implementation ---
type LintIssue struct {
Page string `json:"page"`
Level string `json:"level"`
Check string `json:"check"`
Message string `json:"message"`
}
type LintSummary struct {
Repository string `json:"repository"`
TotalPages int `json:"total_pages"`
TotalIssues int `json:"total_issues"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
Results []LintIssue `json:"results"`
}
var (
mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`)
imageLinkRe = regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^)]+)\)`)
)
func isCheckEnabled(checkFilter, name string) bool {
if checkFilter == "" {
return true
}
for _, c := range strings.Split(checkFilter, ",") {
if strings.TrimSpace(c) == name {
return true
}
}
return false
}
func checkEmpty(page, content string) []LintIssue {
if strings.TrimSpace(content) == "" {
return []LintIssue{{Page: page, Level: "error", Check: "empty", Message: "page is empty"}}
}
return nil
}
func checkHeading(page, content string) []LintIssue {
if content != "" && !strings.HasPrefix(strings.TrimSpace(content), "# ") {
return []LintIssue{{Page: page, Level: "warning", Check: "headings", Message: "missing H1 heading"}}
}
return nil
}
func checkShort(page, content string) []LintIssue {
if content != "" && len(content) < 50 {
return []LintIssue{{Page: page, Level: "warning", Check: "short", Message: fmt.Sprintf("content too short (%d chars)", len(content))}}
}
return nil
}
func checkDeadLinks(page, content string, knownTitles map[string]bool) []LintIssue {
var issues []LintIssue
for _, m := range mdLinkRe.FindAllStringSubmatch(content, -1) {
if len(m) < 3 {
continue
}
target := m[2]
// skip external links and anchors
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "#") {
continue
}
decoded, _ := url.PathUnescape(target)
if decoded == "" {
decoded = target
}
if !knownTitles[decoded] && !knownTitles[target] {
issues = append(issues, LintIssue{
Page: page,
Level: "error",
Check: "links",
Message: fmt.Sprintf("dead link: [%s](%s) -> page %q not found", m[1], target, decoded),
})
}
}
return issues
}
func checkImages(page, content string, httpClient *http.Client) []LintIssue {
var issues []LintIssue
for _, m := range imageLinkRe.FindAllStringSubmatch(content, -1) {
if len(m) < 3 {
continue
}
imgURL := m[2]
req, err := http.NewRequest("HEAD", imgURL, nil)
if err != nil {
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> invalid URL", imgURL)})
continue
}
resp, err := httpClient.Do(req)
if err != nil {
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> unreachable", imgURL)})
continue
}
resp.Body.Close()
if resp.StatusCode >= 400 {
issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> %d", imgURL, resp.StatusCode)})
}
}
return issues
}
func runLint(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
checkFilter := ctx.Arg("check")
fmt.Fprintf(os.Stderr, "Linting wiki pages for %s/%s...\n\n", ctx.Owner, ctx.Repo)
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
// Fetch page list
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("wikiPages"), q)
if err != nil {
return fmt.Errorf("failed to list wiki pages: %w", err)
}
// Parse pages: title for display/link-check, sub_url for fetching
type pageInfo struct {
title string
subURL string
}
knownTitles := make(map[string]bool)
var pages []pageInfo
if items, ok := env.Data.([]interface{}); ok {
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
title, _ := m["title"].(string)
subURL, _ := m["sub_url"].(string)
if title != "" {
knownTitles[title] = true
if subURL == "" {
subURL = title
}
pages = append(pages, pageInfo{title: title, subURL: subURL})
}
}
}
if len(pages) == 0 {
fmt.Fprintln(os.Stderr, "No wiki pages found.")
return ctx.OutputData(LintSummary{
Repository: ctx.Owner + "/" + ctx.Repo,
Results: []LintIssue{},
})
}
// HTTP client for image checks with 5s timeout
httpClient := &http.Client{Timeout: 5 * time.Second}
var allIssues []LintIssue
for _, p := range pages {
// Skip system pages (e.g. _Sidebar, _Footer, _Header)
if strings.HasPrefix(p.title, "_") {
continue
}
content, _, err := fetchPageContent(ctx, projectID, p.subURL)
if err != nil {
allIssues = append(allIssues, LintIssue{Page: p.title, Level: "error", Check: "fetch", Message: fmt.Sprintf("failed to fetch: %v", err)})
continue
}
if isCheckEnabled(checkFilter, "empty") {
allIssues = append(allIssues, checkEmpty(p.title, content)...)
}
if isCheckEnabled(checkFilter, "headings") {
allIssues = append(allIssues, checkHeading(p.title, content)...)
}
if isCheckEnabled(checkFilter, "short") {
allIssues = append(allIssues, checkShort(p.title, content)...)
}
if isCheckEnabled(checkFilter, "links") {
allIssues = append(allIssues, checkDeadLinks(p.title, content, knownTitles)...)
}
if isCheckEnabled(checkFilter, "images") {
allIssues = append(allIssues, checkImages(p.title, content, httpClient)...)
}
}
// Count errors/warnings
var errCount, warnCount int
for _, issue := range allIssues {
if issue.Level == "error" {
errCount++
} else {
warnCount++
}
}
// Print issues to stderr
for _, issue := range allIssues {
if issue.Level == "error" {
fmt.Fprintf(os.Stderr, " ✗ %s - %s\n", issue.Page, issue.Message)
} else {
fmt.Fprintf(os.Stderr, " ⚠ %s - %s\n", issue.Page, issue.Message)
}
}
fmt.Fprintf(os.Stderr, "\nSummary: %d pages, %d errors, %d warnings\n", len(pages), errCount, warnCount)
return ctx.OutputData(LintSummary{
Repository: ctx.Owner + "/" + ctx.Repo,
TotalPages: len(pages),
TotalIssues: len(allIssues),
Errors: errCount,
Warnings: warnCount,
Results: allIssues,
})
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List all wiki pages",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("wikiPages"), q)
if err != nil {
// wiki +list 的 404 几乎总是意味着仓库未在 GitLink 网页端开启 Wiki 功能。
// 这种情况下不要再提示"用 +list 查看"(循环引用),改为引导用户去开启 Wiki。
var cliErr *clierrors.CLIError
if errors.As(err, &cliErr) && cliErr.Kind == clierrors.KindNotFound {
return clierrors.New(clierrors.KindNotFound,
fmt.Sprintf("仓库 %s/%s 没有 Wiki 页面", ctx.Owner, ctx.Repo),
fmt.Sprintf("请前往 GitLink 网页端 → 仓库 %s/%s → 设置 → 开启 Wiki 功能,开启后再创建页面", ctx.Owner, ctx.Repo))
}
return fmt.Errorf("获取 Wiki 页面列表失败: %w", err)
}
cleanWikiList(env)
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View a wiki page",
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Page title", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
env, err := fetchWikiPage(ctx, projectID, title)
if err != nil {
// GitLink 后端命名规则: 创建 wiki 时会自动给 sub_url 追加 ".-" 后缀,
// 而 wiki +list 返回的 title 不带后缀。若 title 不带后缀且首次查询失败,
// 自动用 title + ".-" 重试一次。
if !strings.HasSuffix(title, ".-") {
env, err = fetchWikiPage(ctx, projectID, title+".-")
}
}
if err != nil {
// 用 CLIError 包装,保留底层错误类型,让 TryPrintError 能按 envelope 输出。
// suggestion 同时涵盖两种常见根因:(1) 仓库未开启 Wiki(2) 页面名拼错。
return clierrors.Wrap(clierrors.KindNotFound,
fmt.Sprintf("Wiki 页面 %q 不存在", title),
fmt.Sprintf("请确认:(1) 仓库 %s/%s 已在 GitLink 网页端开启 Wiki 功能;(2) 页面名拼写正确。可用 `gitlink-cli wiki +list --owner %s --repo %s` 查看实际存在的页面",
ctx.Owner, ctx.Repo, ctx.Owner, ctx.Repo),
err)
}
return outputWithDecodedContent(ctx, env)
},
},
{
Name: "create",
Description: "Create a wiki page",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
return fmt.Sprintf("Create wiki page: %s", title), nil
},
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Page title", Required: true},
{Name: "content", Short: "c", Usage: "Wiki page content (plain text, will be base64-encoded)"},
{Name: "file", Short: "f", Usage: "Read content from file"},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
content, err := resolveContent(ctx)
if err != nil {
return err
}
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": parseProjectIDInt(projectID),
"pageName": title,
"title": title,
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
}
if msg := ctx.Arg("message"); msg != "" {
body["message"] = msg
}
env, err := callWikiAPI(ctx, "POST", wikiPath("createWiki"), body)
if err != nil {
return fmt.Errorf("创建 Wiki 页面失败: %w", err)
}
return outputWithDecodedContent(ctx, env)
},
},
{
Name: "update",
Description: "Update a wiki page",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
return fmt.Sprintf("Update wiki page: %s", title), nil
},
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Current page title to find (defaults to --title)"},
{Name: "title", Short: "t", Usage: "New page title", Required: true},
{Name: "cover", Short: "c", Usage: "Replace entire page content with this text"},
{Name: "add", Short: "a", Usage: "Append text to existing page content"},
{Name: "file", Short: "f", Usage: "Read content from file (used with --cover or --add)"},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err
}
pageName := ctx.Arg("page")
if pageName == "" {
pageName = title
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
coverText := ctx.Arg("cover")
addText := ctx.Arg("add")
filePath := ctx.Arg("file")
message := ctx.Arg("message")
var finalContent string
if coverText != "" || filePath != "" && coverText == "" && addText == "" {
// --cover or --file alone: overwrite
content, err := resolveUpdateContent(ctx, coverText, filePath)
if err != nil {
return err
}
finalContent = content
} else if addText != "" {
// --add: append to existing content
newPart, err := resolveUpdateContent(ctx, addText, filePath)
if err != nil {
return err
}
existing, actualPageName, err := fetchPageContent(ctx, projectID, pageName)
if err != nil {
return fmt.Errorf("failed to fetch existing page content for append: %w", err)
}
// fetchPageContent 可能因 GitLink 后端 ".-" 命名规则触发自动重试,
// 用实际成功的 pageName可能带 .- 后缀)作为 PUT 目标,否则后端会再次 404。
pageName = actualPageName
finalContent = existing + newPart
}
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": parseProjectIDInt(projectID),
"pageName": url.QueryEscape(pageName),
"title": title,
"message": message,
}
if finalContent != "" {
body["content_base64"] = base64.StdEncoding.EncodeToString([]byte(finalContent))
}
env, err := callWikiAPI(ctx, "PUT", wikiPath("updateWiki"), body)
if err != nil {
return fmt.Errorf("更新 Wiki 页面失败: %w", err)
}
return outputWithDecodedContent(ctx, env)
},
},
{
Name: "delete",
Description: "Delete a wiki page",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
return fmt.Sprintf("Delete wiki page: %s", title), nil
},
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Page title", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
// 1. 解析实际 pageNamefetchPageContent 内部自动重试 ".-" 后缀)
// 不做这步直接用 title DELETE会导致后端"接受了请求但没真删"
// (后端期望的 pageName 是 "X.-" 而非 "X"
_, actualPageName, err := fetchPageContent(ctx, projectID, title)
if err != nil {
// 页面查不到 — 后端 getWiki API 返回 404。
// suggestion 同时涵盖两种常见根因:(1) 仓库未开启 Wiki(2) 页面名拼错。
// 注意GitLink 网页端对任意 ?wiki=xxx 都会渲染 SPA 壳子,
// 不代表页面真实存在;以 wiki +list 的结果为准。
return clierrors.New(clierrors.KindNotFound,
fmt.Sprintf("Wiki 页面 %q 不存在", title),
fmt.Sprintf("请确认:(1) 仓库 %s/%s 已在 GitLink 网页端开启 Wiki 功能;(2) 页面名拼写正确。可用 `gitlink-cli wiki +list --owner %s --repo %s` 查看实际存在的页面",
ctx.Owner, ctx.Repo, ctx.Owner, ctx.Repo))
}
// 2. 用实际 pageName 调用 DELETE
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": parseProjectIDInt(projectID),
"pageName": actualPageName,
"message": "",
}
if _, delErr := callWikiAPI(ctx, "DELETE", wikiPath("deleteWiki"), body); delErr != nil {
return fmt.Errorf("删除 Wiki 页面失败: %w", delErr)
}
// 3. 删除后强制验证GitLink deleteWiki 端点不可靠:即使返回 200
// 页面有时仍然存在。GET 一次确认页面真的没了。
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", actualPageName)
if _, viewErr := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q); viewErr == nil {
// GET 还能查到 — 后端接受了 DELETE 但没真删
return fmt.Errorf("删除 Wiki 页面失败:后端接受了请求但页面仍存在 (pageName=%s)", actualPageName)
}
// 删除成功 — 返回结构化数据,让用户/agent 能自助验证。
// 说明三种用户常见的"以为没删干净"的现象:
// 1) 网页端 ?wiki=xxx 仍可访问 → GitLink SPA 占位符(任何参数都渲染壳子)
// 2) git log 仍能看到删除 commit → git 设计就是保留历史
// 3) edit 跳转到 wiki=undefined → 网页端前端未正确处理 404
// 这些都不是 CLI 删除不彻底,是 GitLink 网页端的 UX 问题。
return ctx.OutputData(map[string]interface{}{
"message": "Wiki page deleted successfully",
"deleted_title": title,
"actual_page_name": actualPageName,
"wiki_repo": fmt.Sprintf("https://gitlink.org.cn/%s/%s.wiki.git", ctx.Owner, ctx.Repo),
"notes": []string{
"页面已从 wiki 仓库 HEAD 彻底删除git 工作树无残留)",
"网页端 ?wiki=xxx URL 仍可访问是 SPA 占位符,不代表页面存在",
"git 历史 commits 仍保留删除记录git 的正常行为,非残留)",
},
"verify_commands": []string{
fmt.Sprintf("gitlink-cli wiki +list --owner %s --repo %s", ctx.Owner, ctx.Repo),
fmt.Sprintf("git clone https://gitlink.org.cn/%s/%s.wiki.git /tmp/wiki-check && git -C /tmp/wiki-check ls-files", ctx.Owner, ctx.Repo),
},
})
},
},
{
Name: "lint",
Description: "Check wiki pages for quality issues",
Flags: []common.Flag{
{Name: "check", Usage: "Specific checks to run (comma-separated): links,headings,images,empty. Default: all"},
},
Run: runLint,
},
}
}