forked from Gitlink/gitlink-cli
修复子任务一中部分展示bug #21
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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 <login> 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
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
shortcuts := []*common.Shortcut{
|
||||
newBatchCreateShortcut(),
|
||||
newBatchUpdateShortcut(),
|
||||
newBatchDeleteShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repositories for a user or organization",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue