diff --git a/cmd/api/api.go b/cmd/api/api.go index 20c09ee7..e2b42393 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/url" + "os" "strings" "github.com/spf13/cobra" @@ -20,12 +21,14 @@ func NewAPICmd() *cobra.Command { Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`, Example: ` gitlink-cli api GET /users/me gitlink-cli api GET /projects --query 'page=1&limit=10' - gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`, + gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}' + gitlink-cli api POST /:owner/:repo/issues --body-file ./issue.json`, Args: cobra.ExactArgs(2), RunE: runAPI, } apiCmd.Flags().String("body", "", "Request body (JSON string)") + apiCmd.Flags().String("body-file", "", "Read JSON body from a file (avoids shell quoting issues)") apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)") apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)") @@ -48,9 +51,20 @@ func runAPI(c *cobra.Command, args []string) error { var body interface{} bodyStr, _ := c.Flags().GetString("body") + bodyFile, _ := c.Flags().GetString("body-file") + if bodyStr != "" && bodyFile != "" { + return printAPIError(400, "cannot use both --body and --body-file", "请只用其中一个:--body 用于内联 JSON,--body-file 用于从文件读取") + } + if bodyFile != "" { + raw, err := os.ReadFile(bodyFile) + if err != nil { + return printAPIError(400, fmt.Sprintf("read --body-file failed: %v", err), "检查 --body-file 路径是否正确、文件是否存在且有读权限") + } + bodyStr = string(raw) + } if bodyStr != "" { if err := json.Unmarshal([]byte(bodyStr), &body); err != nil { - return fmt.Errorf("invalid JSON body: %w", err) + return printAPIError(400, fmt.Sprintf("invalid JSON body: %v", err), "确认 body 是合法 JSON;PowerShell 调用 .exe 时会剥离内嵌双引号,推荐改用 --body-file 从文件读取") } } @@ -60,7 +74,7 @@ func runAPI(c *cobra.Command, args []string) error { var err error query, err = url.ParseQuery(queryStr) if err != nil { - return fmt.Errorf("invalid query string: %w", err) + return printAPIError(400, fmt.Sprintf("invalid query string: %v", err), "query 应为 key=value&key2=value2 形式,注意值需要 URL 编码") } } @@ -68,14 +82,26 @@ func runAPI(c *cobra.Command, args []string) error { if err != nil { if apiErr, ok := err.(*client.APIError); ok { errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion) - return output.Print(errEnv, resolveFormat()) + _ = output.Print(errEnv, resolveFormat()) + return cmdutil.ErrSilent } - return fmt.Errorf("API 请求失败 [%s %s]: %w", method, path, err) + // 网络错误 / DNS 失败 / 超时等非 API 错误,也按 envelope 输出保持一致 + return printAPIError(503, fmt.Sprintf("API 请求失败 [%s %s]: %v", method, path, err), "检查网络连接、GitLink 主机可达性、token 是否有效") } return output.Print(env, resolveFormat()) } +// printAPIError 把本地校验/IO/网络错误统一按标准 envelope 输出到 stdout, +// 并返回 cmdutil.ErrSilent 让 cmd.Execute 跳过 stderr 重复打印,仅保留非零退出码。 +// 设计意图:让 `api` 命令的所有错误(包括 JSON 解析、参数冲突、读文件失败、APIError、 +// 网络错误)输出格式与 shortcut 一致,便于 `--format json` + jq 自动化解析。 +func printAPIError(code int, message, suggestion string) error { + env := output.ErrorEnvelope(code, message, suggestion) + _ = output.Print(env, resolveFormat()) + return cmdutil.ErrSilent +} + func resolveFormat() string { f := cmdutil.Format if f == "" { diff --git a/shortcuts/repo/batch_delete.go b/shortcuts/repo/batch_delete.go new file mode 100644 index 00000000..42cd0271 --- /dev/null +++ b/shortcuts/repo/batch_delete.go @@ -0,0 +1,94 @@ +package repo + +import ( + "fmt" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// newBatchDeleteShortcut 实现 repo +batch-delete 命令。 +// +// 设计参考 issue +batch-close 与 repo +batch-update: +// - --names : 内联逗号分隔的仓库名列表 +// - --from : CSV 文件路径(只读 name 列,复用 readNamesFromCSV) +// - --dry-run : 仅预览不实际删除 +// +// 与单条 repo +delete 的区别:批量操作下没有"当前仓库"语义, +// 因此只要求 --owner(不需要 --repo),所有要删除的仓库都位于该 owner 名下。 +func newBatchDeleteShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-delete", + Description: "Delete multiple repositories by names or a CSV file", + Flags: []common.Flag{ + {Name: "names", Short: "n", Usage: "Comma-separated repository names, e.g. repo-a,repo-b"}, + {Name: "from", Usage: "CSV file path"}, + {Name: "dry-run", Usage: "Preview without deleting", Bool: true, Default: "false"}, + }, + Run: runBatchDelete, + } +} + +func runBatchDelete(ctx *common.RuntimeContext) error { + owner := ctx.Owner + if owner == "" { + return fmt.Errorf("--owner is required; pass --owner to specify the account that owns the repos") + } + + var repoNames []string + if namesStr := ctx.Arg("names"); namesStr != "" { + for _, name := range strings.Split(namesStr, ",") { + name = strings.TrimSpace(name) + if name != "" { + repoNames = append(repoNames, name) + } + } + } + if csvPath := ctx.Arg("from"); csvPath != "" { + csvNames, err := readNamesFromCSV(csvPath) + if err != nil { + return err + } + repoNames = append(repoNames, csvNames...) + } + if len(repoNames) == 0 { + return fmt.Errorf("no repository names provided; use -n repo-a,repo-b or --from repos.csv") + } + + dryRun := ctx.Arg("dry-run") == "true" + summary := repoBatchSummary{ + Owner: owner, + Action: "delete", + DryRun: dryRun, + Total: len(repoNames), + Results: make([]repoBatchResult, 0, len(repoNames)), + } + + for _, name := range repoNames { + result := repoBatchResult{Name: name} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s", owner, name), nil); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "deleted" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d repo(s) failed to delete", summary.Failed, summary.Total) + } + return nil +} diff --git a/shortcuts/repo/batch_delete_test.go b/shortcuts/repo/batch_delete_test.go new file mode 100644 index 00000000..ab37ea21 --- /dev/null +++ b/shortcuts/repo/batch_delete_test.go @@ -0,0 +1,220 @@ +package repo + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func runBatchDeleteShortcut(t *testing.T, server *httptest.Server, owner string, args map[string]string) error { + t.Helper() + s := findShortcut(t, "batch-delete") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: owner, + Format: "json", + Args: args, + } + return s.Run(ctx) +} + +func TestBatchDelete_DryRun(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("no API calls expected in dry-run mode, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "testuser", map[string]string{ + "names": "repo1,repo2", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBatchDelete_FromNames(t *testing.T) { + var deletedPaths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Fatalf("expected DELETE, got %s", r.Method) + } + deletedPaths = append(deletedPaths, r.URL.Path) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{ + "names": "test-batch-1,test-batch-2,test-batch-3", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(deletedPaths) != 3 { + t.Fatalf("expected 3 DELETE calls, got %d", len(deletedPaths)) + } + expected := []string{"/zzx-coder/test-batch-1.json", "/zzx-coder/test-batch-2.json", "/zzx-coder/test-batch-3.json"} + for i, p := range deletedPaths { + if p != expected[i] { + t.Fatalf("path[%d]: got %q, want %q", i, p, expected[i]) + } + } +} + +func TestBatchDelete_NoNames(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "no repository names provided") { + t.Fatalf("error should mention no names, got: %v", err) + } +} + +func TestBatchDelete_NoOwner(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "", map[string]string{ + "names": "repo1,repo2", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "--owner is required") { + t.Fatalf("error should mention --owner required, got: %v", err) + } +} + +func TestBatchDelete_DryRunNoNamesErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{"dry-run": "true"}) + if err == nil { + t.Fatal("expected error for no names even in dry-run, got nil") + } +} + +func TestBatchDelete_FromCSV(t *testing.T) { + csvPath := writeTempCSV(t, "name\ncsv-repo1\ncsv-repo2\n") + + var deletedPaths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Fatalf("expected DELETE, got %s", r.Method) + } + deletedPaths = append(deletedPaths, r.URL.Path) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{"from": csvPath}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(deletedPaths) != 2 { + t.Fatalf("expected 2 DELETE calls, got %d", len(deletedPaths)) + } + if deletedPaths[0] != "/zzx-coder/csv-repo1.json" { + t.Fatalf("first path: got %q", deletedPaths[0]) + } + if deletedPaths[1] != "/zzx-coder/csv-repo2.json" { + t.Fatalf("second path: got %q", deletedPaths[1]) + } +} + +func TestBatchDelete_CSVAndNamesCombined(t *testing.T) { + csvPath := writeTempCSV(t, "name\ncsv-repo\n") + + var deletedPaths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + deletedPaths = append(deletedPaths, r.URL.Path) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{ + "names": "inline-repo", + "from": csvPath, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(deletedPaths) != 2 { + t.Fatalf("expected 2 deletes (inline + csv), got %d", len(deletedPaths)) + } + if deletedPaths[0] != "/zzx-coder/inline-repo.json" { + t.Fatalf("first: got %q", deletedPaths[0]) + } + if deletedPaths[1] != "/zzx-coder/csv-repo.json" { + t.Fatalf("second: got %q", deletedPaths[1]) + } +} + +func TestBatchDelete_PartialFailure(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 2 { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message":"repo not found"}`)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{ + "names": "ok1,fail1,ok2", + }) + if err == nil { + t.Fatal("expected error from partial failure, got nil") + } + if !strings.Contains(err.Error(), "failed to delete") { + t.Fatalf("error should mention failed count, got: %v", err) + } +} + +func TestBatchDelete_TrimsWhitespace(t *testing.T) { + var deletedPaths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + deletedPaths = append(deletedPaths, r.URL.Path) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{ + "names": " repo-a , repo-b ,", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(deletedPaths) != 2 { + t.Fatalf("expected 2 deletes after trimming, got %d", len(deletedPaths)) + } + if deletedPaths[0] != "/zzx-coder/repo-a.json" { + t.Fatalf("first: got %q", deletedPaths[0]) + } + if deletedPaths[1] != "/zzx-coder/repo-b.json" { + t.Fatalf("second: got %q", deletedPaths[1]) + } +} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 761efb53..6b92041b 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -12,6 +12,7 @@ func Shortcuts() []*common.Shortcut { shortcuts := []*common.Shortcut{ newBatchCreateShortcut(), newBatchUpdateShortcut(), + newBatchDeleteShortcut(), { Name: "list", Description: "List repositories for a user or organization", diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index caaeb425..02845213 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -2,6 +2,8 @@ package wiki import ( "encoding/base64" + "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -14,6 +16,7 @@ import ( "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" ) @@ -71,9 +74,21 @@ func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) { if code, ok := resp["code"]; ok { switch v := code.(type) { case float64: - if v != 200 && v != 201 { + // HTTP 2xx 全部视为成功(200 OK / 201 Created / 202 Accepted / 204 No Content 等) + // 之前只接受 200/201,导致 DELETE 返回 204 时被误判为失败 + if v < 200 || v >= 300 { msg, _ := resp["msg"].(string) - return nil, fmt.Errorf("[%.0f] %s", v, msg) + // 必须返回 *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 功能") } } } @@ -138,7 +153,33 @@ func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (st return "", fmt.Errorf("no content provided") } -func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (string, error) { +// 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) @@ -146,21 +187,21 @@ func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (s q.Set("pageName", pageName) env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q) if err != nil { - return "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err) + return "", "", err } data, ok := env.Data.(map[string]interface{}) if !ok { - return "", fmt.Errorf("unexpected response from getWiki") + return "", "", fmt.Errorf("unexpected response from getWiki") } b64, _ := data["content_base64"].(string) if b64 == "" { - return "", nil + return "", pageName, nil } decoded, err := base64.StdEncoding.DecodeString(b64) if err != nil { - return "", fmt.Errorf("failed to decode page content: %w", err) + return "", "", fmt.Errorf("failed to decode page content: %w", err) } - return string(decoded), nil + return string(decoded), pageName, nil } // fetchWikiPage 按 pageName 查询 wiki 页面,返回完整的 envelope。 @@ -207,15 +248,51 @@ func cleanWikiList(env *output.Envelope) { } } +// 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, ok := env.Data.(map[string]interface{}) + 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) } - if b64, ok := data["content_base64"].(string); ok && b64 != "" { - decoded, err := base64.StdEncoding.DecodeString(b64) - if err == nil { - data["content_decoded"] = string(decoded) + // 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) @@ -395,7 +472,7 @@ func runLint(ctx *common.RuntimeContext) error { if strings.HasPrefix(p.title, "_") { continue } - content, err := fetchPageContent(ctx, projectID, p.subURL) + 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 @@ -467,6 +544,14 @@ func Shortcuts() []*common.Shortcut { 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) @@ -501,7 +586,13 @@ func Shortcuts() []*common.Shortcut { } } if err != nil { - return fmt.Errorf("查看 Wiki 页面失败: %w", err) + // 用 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) }, @@ -554,7 +645,7 @@ func Shortcuts() []*common.Shortcut { if err != nil { return fmt.Errorf("创建 Wiki 页面失败: %w", err) } - return ctx.Output(env) + return outputWithDecodedContent(ctx, env) }, }, { @@ -609,10 +700,13 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - existing, err := fetchPageContent(ctx, projectID, pageName) + 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 } @@ -632,7 +726,7 @@ func Shortcuts() []*common.Shortcut { if err != nil { return fmt.Errorf("更新 Wiki 页面失败: %w", err) } - return ctx.Output(env) + return outputWithDecodedContent(ctx, env) }, }, { @@ -659,31 +753,64 @@ func Shortcuts() []*common.Shortcut { 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": title, + "pageName": actualPageName, "message": "", } - - _, delErr := callWikiAPI(ctx, "DELETE", wikiPath("deleteWiki"), body) - if delErr != nil { - q := url.Values{} - q.Set("owner", ctx.Owner) - q.Set("repo", ctx.Repo) - q.Set("projectId", projectID) - q.Set("pageName", title) - _, viewErr := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q) - if viewErr != nil { - return ctx.OutputData(map[string]string{ - "message": "Wiki page deleted successfully", - }) - } + if _, delErr := callWikiAPI(ctx, "DELETE", wikiPath("deleteWiki"), body); delErr != nil { return fmt.Errorf("删除 Wiki 页面失败: %w", delErr) } - return ctx.OutputData(map[string]string{ - "message": "Wiki page deleted successfully", + + // 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), + }, }) }, }, diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go index 420a539b..467144b1 100644 --- a/shortcuts/wiki/wiki_test.go +++ b/shortcuts/wiki/wiki_test.go @@ -3,6 +3,7 @@ package wiki import ( "encoding/base64" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -12,6 +13,7 @@ import ( "testing" "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" ) @@ -79,8 +81,16 @@ func TestUnwrapGatewayResponse_BusinessError(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if err.Error() != "[500] 内部错误" { - t.Fatalf("got %q, want %q", err.Error(), "[500] 内部错误") + // 必须是 *clierrors.CLIError,否则 TryPrintError 无法按 envelope 输出 + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("expected *clierrors.CLIError, got %T: %v", err, err) + } + if cliErr.Kind != clierrors.KindServer { + t.Errorf("Kind = %q, want %q", cliErr.Kind, clierrors.KindServer) + } + if cliErr.Message != "内部错误" { + t.Errorf("Message = %q, want %q", cliErr.Message, "内部错误") } } @@ -303,8 +313,17 @@ func TestCallWikiAPI_BusinessError(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), "bad request") { - t.Errorf("err = %q, want to contain 400 and bad request", err.Error()) + // 必须是 *clierrors.CLIError,code=400 对应 KindForbidden(401/403)或 KindServer + // (其他非 200/404 错误),这里 400 走 KindServer 分支 + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("expected *clierrors.CLIError, got %T: %v", err, err) + } + if cliErr.Message != "bad request" { + t.Errorf("Message = %q, want %q", cliErr.Message, "bad request") + } + if cliErr.Suggestion == "" { + t.Errorf("Suggestion should not be empty (helps user recover)") } }