diff --git a/internal/client/client.go b/internal/client/client.go index d147d96..d5b2340 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -15,9 +15,10 @@ import ( ) type Client struct { - HTTP *http.Client - BaseURL string - Debug bool + HTTP *http.Client + BaseURL string + Debug bool + SkipJSONSuffix bool } type APIError struct { @@ -44,14 +45,16 @@ func New() (*Client, error) { func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { // Append .json suffix if not already present (GitLink API convention) // Handle paths that may already contain query strings (e.g., /path?key=val) - if idx := strings.Index(path, "?"); idx != -1 { - basePath := path[:idx] - queryStr := path[idx:] - if !strings.HasSuffix(basePath, ".json") { - path = basePath + ".json" + queryStr + if !c.SkipJSONSuffix { + if idx := strings.Index(path, "?"); idx != -1 { + basePath := path[:idx] + queryStr := path[idx:] + if !strings.HasSuffix(basePath, ".json") { + path = basePath + ".json" + queryStr + } + } else if !strings.HasSuffix(path, ".json") { + path += ".json" } - } else if !strings.HasSuffix(path, ".json") { - path += ".json" } fullURL := c.BaseURL + path if query != nil && len(query) > 0 { diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 15441c9..05e7a42 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -48,7 +48,7 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { format := cmdutil.Format if format == "" { - format = "json" + format = "table" } return &RuntimeContext{ diff --git a/shortcuts/register.go b/shortcuts/register.go index 210500e..d511219 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -13,6 +13,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/repo" "github.com/gitlink-org/gitlink-cli/shortcuts/search" "github.com/gitlink-org/gitlink-cli/shortcuts/user" + "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" // 新增wiki管理 ) // RegisterAll mounts all shortcut groups onto the root command. @@ -27,6 +28,7 @@ func RegisterAll(root *cobra.Command) { "user": user.Shortcuts(), "search": search.Shortcuts(), "ci": ci.Shortcuts(), + "wiki": wiki.Shortcuts(), // 新增wiki } descriptions := map[string]string{ @@ -39,6 +41,7 @@ func RegisterAll(root *cobra.Command) { "user": "User operations", "search": "Search operations", "ci": "CI/CD operations", + "wiki": "Wiki operations", // 新增wiki } for name, shortcuts := range groups { diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go new file mode 100644 index 0000000..914f57f --- /dev/null +++ b/shortcuts/wiki/wiki.go @@ -0,0 +1,429 @@ +package wiki + +import ( + "encoding/base64" + "fmt" + "net/url" + "os" + "strconv" + "sync" + + "github.com/gitlink-org/gitlink-cli/internal/auth" + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +const gatewayBaseURL = "https://gateway.gitlink.org.cn/api" + +var ( + projectIDCache sync.Map + gatewayClient *client.Client + gatewayOnce sync.Once +) + +func wikiPath(endpoint string) string { + return "/wiki/open/" + endpoint +} + +func getGatewayClient() *client.Client { + gatewayOnce.Do(func() { + gatewayClient = &client.Client{ + HTTP: auth.NewHTTPClient(), + BaseURL: gatewayBaseURL, + SkipJSONSuffix: true, + } + }) + return gatewayClient +} + +func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) { + gc := getGatewayClient() + gc.Debug = ctx.Client.Debug + 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() + gc.Debug = ctx.Client.Debug + 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: + if v != 200 && v != 201 { + msg, _ := resp["msg"].(string) + return nil, fmt.Errorf("[%.0f] %s", v, msg) + } + } + } + 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") +} + +func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName 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 "", nil + } + decoded, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", fmt.Errorf("failed to decode page content: %w", err) + } + return string(decoded), nil +} + +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 + } + } + } +} + +func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope) error { + data, ok := env.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) + } + } + return ctx.Output(env) +} + +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 { + return 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") + if 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) + q.Set("pageName", title) + env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q) + if err != nil { + return err + } + return outputWithDecodedContent(ctx, env) + }, + }, + { + Name: "create", + Description: "Create a wiki page", + 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") + 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 err + } + return ctx.Output(env) + }, + }, + { + Name: "update", + Description: "Update a wiki page", + 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") + 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, err := fetchPageContent(ctx, projectID, pageName) + if err != nil { + return fmt.Errorf("failed to fetch existing page content for append: %w", err) + } + 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 err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete 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") + if err != nil { + return err + } + projectID, err := resolveProjectID(ctx) + if err != nil { + return err + } + + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": parseProjectIDInt(projectID), + "pageName": title, + "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", + }) + } + return delErr + } + return ctx.OutputData(map[string]string{ + "message": "Wiki page deleted successfully", + }) + }, + }, + } +}