增加wiki管理的shortcut

This commit is contained in:
camelliamc 2026-05-23 08:28:41 +08:00
parent fde322669a
commit 5702ce9959
4 changed files with 446 additions and 11 deletions

View File

@ -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 {

View File

@ -48,7 +48,7 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) {
format := cmdutil.Format
if format == "" {
format = "json"
format = "table"
}
return &RuntimeContext{

View File

@ -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 {

429
shortcuts/wiki/wiki.go Normal file
View File

@ -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",
})
},
},
}
}