diff --git a/internal/client/client.go b/internal/client/client.go index 7db76fbb..9dc465d9 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go index e8ec429b..885b3dbb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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": diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 029c5ccb..17e0bd08 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -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 } diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index 31777eb3..93e70314 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -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 ( diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go index 6304f48a..420a539b 100644 --- a/shortcuts/wiki/wiki_test.go +++ b/shortcuts/wiki/wiki_test.go @@ -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") + } +}