Merge pull request '修复增加加wiki管理shortcut命令遗留下来的问题' (#13) from mc_branch into master

This commit is contained in:
mengcheng 2026-06-14 22:43:27 +08:00
commit 4145897dcf
5 changed files with 508 additions and 52 deletions

View File

@ -48,14 +48,12 @@ 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 !c.SkipJSONSuffix {
if c.shouldAppendJSONSuffix(path) {
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 = basePath + ".json" + queryStr
} else {
path += ".json"
}
}
@ -225,3 +223,24 @@ func lookupStatusInfo(code int) statusInfo {
message: fmt.Sprintf("API 返回错误码 %d", code),
}
}
// shouldAppendJSONSuffix reports whether the .json suffix should be appended to path.
// Returns false (skip append) when:
// - c.SkipJSONSuffix is set (explicit opt-out for non-JSON endpoints such as gateway)
// - path already ends with .json
// - path matches the raw content pattern (e.g., /api/:owner/:repo/raw/...)
func (c *Client) shouldAppendJSONSuffix(path string) bool {
if c.SkipJSONSuffix {
return false
}
if strings.HasSuffix(path, ".json") {
return false
}
parts := strings.Split(strings.Trim(path, "/"), "/")
for i, part := range parts {
if part == "raw" && i >= 2 && i+2 < len(parts) {
return false
}
}
return true
}

View File

@ -8,21 +8,27 @@ import (
)
const (
DefaultBaseURL = "https://www.gitlink.org.cn/api"
DefaultFormat = "table"
DefaultBaseURL = "https://www.gitlink.org.cn/api"
DefaultGatewayBaseURL = "https://gateway.gitlink.org.cn/api"
DefaultFormat = "table"
// EnvGatewayBaseURL overrides GatewayBaseURL when set.
EnvGatewayBaseURL = "GITLINK_GATEWAY_URL"
)
type Config struct {
BaseURL string `yaml:"base_url"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
BaseURL string `yaml:"base_url"`
GatewayBaseURL string `yaml:"gateway_base_url,omitempty"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
}
func DefaultConfig() *Config {
return &Config{
BaseURL: DefaultBaseURL,
Format: DefaultFormat,
BaseURL: DefaultBaseURL,
GatewayBaseURL: DefaultGatewayBaseURL,
Format: DefaultFormat,
}
}
@ -53,6 +59,12 @@ func Load() (*Config, error) {
if cfg.BaseURL == "" {
cfg.BaseURL = DefaultBaseURL
}
if cfg.GatewayBaseURL == "" {
cfg.GatewayBaseURL = DefaultGatewayBaseURL
}
if v := os.Getenv(EnvGatewayBaseURL); v != "" {
cfg.GatewayBaseURL = v
}
if cfg.Format == "" {
cfg.Format = DefaultFormat
}
@ -79,6 +91,8 @@ func Get(key string) (string, error) {
switch key {
case "base_url":
return cfg.BaseURL, nil
case "gateway_base_url":
return cfg.GatewayBaseURL, nil
case "default_format":
return cfg.Format, nil
case "editor":
@ -98,6 +112,8 @@ func Set(key, value string) error {
switch key {
case "base_url":
cfg.BaseURL = value
case "gateway_base_url":
cfg.GatewayBaseURL = value
case "default_format":
cfg.Format = value
case "editor":

View File

@ -4,12 +4,14 @@ import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/context"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
@ -21,7 +23,7 @@ type Shortcut struct {
Description string
Flags []Flag
DryRun bool // 是否支持 dry-run
DryRunHint func(ctx *RuntimeContext) (string, error) // 返回预览描述
DryRunHint func(ctx *RuntimeContext) (string, error) // 返回预览描述
Run func(ctx *RuntimeContext) error
}
@ -37,12 +39,14 @@ type Flag struct {
// RuntimeContext provides helpers for shortcut implementations.
type RuntimeContext struct {
Client *client.Client
Owner string
Repo string
Format string
CommandName string
Args map[string]string
Client *client.Client
Owner string
Repo string
Format string
CommandName string
Args map[string]string
GatewayBaseURL string
GatewayHTTPClient *http.Client // optional; nil = use auth.NewHTTPClient (mainly for tests)
}
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
@ -58,13 +62,20 @@ func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeCont
format = "json"
}
gatewayBaseURL := config.DefaultGatewayBaseURL
if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" {
gatewayBaseURL = cfg.GatewayBaseURL
}
return &RuntimeContext{
Client: cli,
Owner: cmdutil.Owner,
Repo: cmdutil.Repo,
Format: format,
CommandName: commandName,
Args: args,
Client: cli,
Owner: cmdutil.Owner,
Repo: cmdutil.Repo,
Format: format,
CommandName: commandName,
Args: args,
GatewayBaseURL: gatewayBaseURL,
GatewayHTTPClient: nil,
}, nil
}

View File

@ -18,32 +18,35 @@ import (
"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
)
var projectIDCache sync.Map
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
// 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()
gc.Debug = ctx.Client.Debug
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, body, nil)
if err != nil {
return nil, err
@ -52,8 +55,7 @@ func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface
}
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
gc := getGatewayClient()
gc.Debug = ctx.Client.Debug
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, nil, query)
if err != nil {
return nil, err
@ -218,12 +220,12 @@ type LintIssue struct {
}
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"`
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 (

View File

@ -1,9 +1,13 @@
package wiki
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
@ -234,3 +238,407 @@ func TestResolveProjectID_APIError(t *testing.T) {
t.Fatal("expected error, got nil")
}
}
// ---- callWikiAPI HTTP request path tests ----
func TestCallWikiAPI_Success(t *testing.T) {
resetProjectIDCache()
var receivedPath, receivedMethod string
var receivedBody []byte
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
receivedMethod = r.Method
if r.Body != nil {
buf := make([]byte, 1024)
n, _ := r.Body.Read(buf)
receivedBody = buf[:n]
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":200,"msg":"ok","data":{"id":42}}`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
env, err := callWikiAPI(ctx, "POST", "/wiki/open/test", map[string]string{"foo": "bar"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedMethod != "POST" {
t.Errorf("method = %q, want POST", receivedMethod)
}
if receivedPath != "/wiki/open/test" {
t.Errorf("path = %q, want /wiki/open/test", receivedPath)
}
if !strings.Contains(string(receivedBody), `"foo"`) {
t.Errorf("body should contain foo: %s", string(receivedBody))
}
data, ok := env.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map data, got %T", env.Data)
}
if data["id"] != float64(42) {
t.Errorf("data[id] = %v, want 42", data["id"])
}
}
func TestCallWikiAPI_BusinessError(t *testing.T) {
resetProjectIDCache()
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":400,"msg":"bad request","data":null}`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
_, err := callWikiAPI(ctx, "GET", "/test", nil)
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())
}
}
func TestCallWikiAPI_GatewayHTTPError(t *testing.T) {
resetProjectIDCache()
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(`upstream error`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
_, err := callWikiAPI(ctx, "GET", "/test", nil)
if err == nil {
t.Fatal("expected error on 502")
}
}
func TestCallWikiAPI_SkipsJSONSuffix(t *testing.T) {
// Verifies the SkipJSONSuffix path is correctly taken for gateway:
// the URL should NOT have a .json appended.
resetProjectIDCache()
var receivedPath string
server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
w.Write([]byte(`{"code":200,"data":{}}`))
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
GatewayBaseURL: server.URL,
GatewayHTTPClient: server.Client(),
}
_, err := callWikiAPI(ctx, "GET", "/wiki/open/list", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedPath != "/wiki/open/list" {
t.Errorf("path = %q, want /wiki/open/list (no .json suffix)", receivedPath)
}
if strings.HasSuffix(receivedPath, ".json") {
t.Errorf("path %q should NOT have .json suffix (gateway expects no suffix)", receivedPath)
}
}
func TestCallWikiAPI_ConnectionRefused(t *testing.T) {
resetProjectIDCache()
// Use an unbound port to simulate connection failure
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: &http.Client{}, BaseURL: "http://127.0.0.1:1"},
GatewayBaseURL: "http://127.0.0.1:1",
GatewayHTTPClient: &http.Client{},
}
_, err := callWikiAPI(ctx, "GET", "/test", nil)
if err == nil {
t.Fatal("expected connection error")
}
}
// ---- runLint / check rules tests ----
func TestIsCheckEnabled_Empty(t *testing.T) {
if !isCheckEnabled("", "any") {
t.Error("empty filter should enable all checks")
}
if !isCheckEnabled("empty,headings", "empty") {
t.Error("should enable 'empty' in filter list")
}
if isCheckEnabled("headings", "empty") {
t.Error("should not enable 'empty' when not in filter list")
}
if !isCheckEnabled(" empty , headings ", "empty") {
t.Error("should trim whitespace")
}
}
func TestCheckEmpty(t *testing.T) {
issues := checkEmpty("p1", "")
if len(issues) != 1 || issues[0].Level != "error" || issues[0].Check != "empty" {
t.Errorf("expected 1 error-level 'empty' issue, got %+v", issues)
}
if issues := checkEmpty("p1", "some content"); issues != nil {
t.Errorf("non-empty content should not produce issues, got %+v", issues)
}
if issues := checkEmpty("p1", " \n\t "); len(issues) != 1 {
t.Errorf("whitespace-only content should be empty, got %+v", issues)
}
}
func TestCheckHeading(t *testing.T) {
// missing H1
if issues := checkHeading("p1", "Some text without heading"); len(issues) != 1 {
t.Errorf("expected 1 missing-heading issue, got %+v", issues)
}
// has H1
if issues := checkHeading("p1", "# Title\nbody"); issues != nil {
t.Errorf("H1 should not produce issues, got %+v", issues)
}
// empty content skipped
if issues := checkHeading("p1", ""); issues != nil {
t.Errorf("empty content should be skipped, got %+v", issues)
}
// whitespace prefix
if issues := checkHeading("p1", " \n# Real Title"); issues != nil {
t.Errorf("H1 after whitespace should not produce issues, got %+v", issues)
}
}
func TestCheckShort(t *testing.T) {
if issues := checkShort("p1", ""); issues != nil {
t.Errorf("empty content should be skipped, got %+v", issues)
}
if issues := checkShort("p1", "short"); len(issues) != 1 {
t.Errorf("expected 1 short issue, got %+v", issues)
}
long := strings.Repeat("a", 100)
if issues := checkShort("p1", long); issues != nil {
t.Errorf("long content should not produce issues, got %+v", issues)
}
// exactly 49 chars triggers
if issues := checkShort("p1", strings.Repeat("a", 49)); len(issues) != 1 {
t.Errorf("49-char content should be 'short', got %+v", issues)
}
}
func TestCheckDeadLinks(t *testing.T) {
known := map[string]bool{"Home": true, "Guide": true}
// All known: no issues
if issues := checkDeadLinks("p1", "[Home](Home) and [Guide](Guide)", known); issues != nil {
t.Errorf("all-known should not produce issues, got %+v", issues)
}
// Unknown link
issues := checkDeadLinks("p1", "[Unknown](Unknown)", known)
if len(issues) != 1 || issues[0].Check != "links" {
t.Errorf("expected 1 dead link issue, got %+v", issues)
}
// External links skipped
if issues := checkDeadLinks("p1", "[ext](https://example.com)", known); issues != nil {
t.Errorf("external links should be skipped, got %+v", issues)
}
// Anchor links skipped
if issues := checkDeadLinks("p1", "[anchor](#section)", known); issues != nil {
t.Errorf("anchor links should be skipped, got %+v", issues)
}
// Mixed
issues = checkDeadLinks("p1", "[Home](Home) and [Bad](BadPage)", known)
if len(issues) != 1 {
t.Errorf("expected 1 dead link in mixed, got %+v", issues)
}
// Empty content
if issues := checkDeadLinks("p1", "", known); issues != nil {
t.Errorf("empty content should not produce issues, got %+v", issues)
}
}
func TestCheckImages(t *testing.T) {
// Mock image server
imgServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "HEAD" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
})
defer imgServer.Close()
brokenServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
defer brokenServer.Close()
httpClient := imgServer.Client()
// Valid image (200)
if issues := checkImages("p1", "![ok]("+imgServer.URL+"/img.png)", httpClient); issues != nil {
t.Errorf("200 image should not produce issues, got %+v", issues)
}
// Broken image (404)
issues := checkImages("p1", "![bad]("+brokenServer.URL+"/missing.png)", httpClient)
if len(issues) != 1 {
t.Errorf("expected 1 broken image issue, got %+v", issues)
}
// No images
if issues := checkImages("p1", "no images here", httpClient); issues != nil {
t.Errorf("no images should not produce issues, got %+v", issues)
}
// Malformed HTTP URL (regex matches https?:// but http.NewRequest fails to parse)
if issues := checkImages("p1", "![bad](http://[)", httpClient); len(issues) != 1 {
t.Errorf("expected 1 invalid-URL issue, got %+v", issues)
}
}
func TestRunLint_Integration(t *testing.T) {
resetProjectIDCache()
// Mock main API (project detail) and gateway (wiki list + get)
mainServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/detail.json") {
writeJSON(t, w, map[string]interface{}{"project_id": float64(999)})
return
}
t.Errorf("unexpected main API call: %s %s", r.Method, r.URL.Path)
})
defer mainServer.Close()
// Page content (base64 encoded)
goodContent := base64.StdEncoding.EncodeToString([]byte("# Good Page\n\n" + strings.Repeat("This is a well-formed page with enough content to pass the short check. ", 3)))
wikiServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/wiki/open/wikiPages":
writeJSON(t, w, map[string]interface{}{
"code": 200,
"data": []map[string]interface{}{
{"title": "Good", "sub_url": "Good"},
{"title": "Empty", "sub_url": "Empty"},
{"title": "_Sidebar", "sub_url": "_Sidebar"}, // system page - skipped
},
})
case "/wiki/open/getWiki":
pageName := r.URL.Query().Get("pageName")
var content string
if pageName == "Empty" {
content = "" // empty page
} else {
content = goodContent
}
writeJSON(t, w, map[string]interface{}{
"code": 200,
"data": map[string]interface{}{"content_base64": content},
})
default:
t.Errorf("unexpected wiki path: %s", r.URL.Path)
}
})
defer wikiServer.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: mainServer.Client(), BaseURL: mainServer.URL},
Owner: "owner1",
Repo: "repo1",
Format: "json",
GatewayBaseURL: wikiServer.URL,
GatewayHTTPClient: wikiServer.Client(),
}
if err := runLint(ctx); err != nil {
t.Fatalf("runLint: %v", err)
}
// _Sidebar is skipped, so TotalPages=2 (Good, Empty)
// Empty page produces 1 "empty" error
// We can't directly inspect the output envelope, but if no error, the function ran end-to-end
}
// ---- resolveContent tests ----
func TestResolveContent_FromArg(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{"content": "inline content"},
}
got, err := resolveContent(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "inline content" {
t.Errorf("got %q, want %q", got, "inline content")
}
}
func TestResolveContent_FromFile(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "wiki.md")
want := "# Title\n\nBody content from file"
if err := os.WriteFile(path, []byte(want), 0600); err != nil {
t.Fatalf("setup: %v", err)
}
ctx := &common.RuntimeContext{
Args: map[string]string{"file": path},
}
got, err := resolveContent(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestResolveContent_ArgTakesPrecedence(t *testing.T) {
// When both --content and --file are set, --content wins
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "wiki.md")
if err := os.WriteFile(path, []byte("from file"), 0600); err != nil {
t.Fatalf("setup: %v", err)
}
ctx := &common.RuntimeContext{
Args: map[string]string{
"content": "from arg",
"file": path,
},
}
got, err := resolveContent(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "from arg" {
t.Errorf("arg should take precedence; got %q", got)
}
}
func TestResolveContent_Missing(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{},
}
_, err := resolveContent(ctx)
if err == nil {
t.Fatal("expected error when neither --content nor --file is provided")
}
if !strings.Contains(err.Error(), "required") {
t.Errorf("err = %q, want to mention 'required'", err.Error())
}
}
func TestResolveContent_FileNotFound(t *testing.T) {
ctx := &common.RuntimeContext{
Args: map[string]string{"file": "/nonexistent/path/to/wiki.md"},
}
_, err := resolveContent(ctx)
if err == nil {
t.Fatal("expected error for nonexistent file")
}
}