forked from Gitlink/gitlink-cli
827 lines
27 KiB
Go
827 lines
27 KiB
Go
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% token(base64 编码膨胀部分)
|
||
//
|
||
// 已知字段映射:
|
||
// - 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. 解析实际 pageName(fetchPageContent 内部自动重试 ".-" 后缀)
|
||
// 不做这步直接用 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,
|
||
},
|
||
}
|
||
}
|