From d98ff4b10d62b40ec1d93968bed3993ae6e65445 Mon Sep 17 00:00:00 2001 From: Loose Cannon Date: Thu, 26 Mar 2026 17:24:32 -0400 Subject: [PATCH] fix: convert wiki page titles to sub_url slugs for get/edit/delete The Gitea API expects the sub_url slug format for wiki page endpoints, not the display title. This caused 404 errors when callers passed page titles (e.g. "architecture/overview") to get_wiki_page, edit_wiki_page, or delete_wiki_page. The new wikiPageNameToSlug helper converts titles to slugs: - Replaces spaces with hyphens - URL-encodes path separators (/ -> %2F) - Appends ".-" suffix for pages with path separators (Gitea convention) - Passes through already-encoded sub_url values unchanged Includes 7 unit tests covering flat pages, nested pages, deep nesting, spaces, and already-encoded passthrough. Fixes #5 --- tools/client_wiki.go | 49 ++++++++++++++++++++++++++++++-- tools/client_wiki_test.go | 60 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 tools/client_wiki_test.go diff --git a/tools/client_wiki.go b/tools/client_wiki.go index ae7ceb2..e033038 100644 --- a/tools/client_wiki.go +++ b/tools/client_wiki.go @@ -8,10 +8,49 @@ package tools import ( "fmt" + "net/url" + "strings" "github.com/raohwork/forgejo-mcp/types" ) +// wikiPageNameToSlug converts a wiki page title to the URL slug format +// expected by the Gitea API. Gitea's wiki API endpoints require the +// "sub_url" slug rather than the display title. The slug format: +// - Spaces are replaced with hyphens +// - Forward slashes are URL-encoded as %2F +// - Pages with slashes get a ".-" suffix appended +// - If the input already looks like a sub_url (contains %2F), it is +// returned as-is to avoid double-encoding +func wikiPageNameToSlug(pageName string) string { + // If it already contains %2F, assume it's already a sub_url slug + if strings.Contains(pageName, "%2F") || strings.Contains(pageName, "%2f") { + return pageName + } + + // Replace spaces with hyphens (Gitea wiki convention) + slug := strings.ReplaceAll(pageName, " ", "-") + + // If no slashes, return as-is (flat page like "Home") + if !strings.Contains(slug, "/") { + return url.PathEscape(slug) + } + + // URL-encode each path segment and join with %2F + parts := strings.Split(slug, "/") + for i, part := range parts { + parts[i] = url.PathEscape(part) + } + result := strings.Join(parts, "%2F") + + // Gitea appends ".-" to slugs for pages with path separators + if !strings.HasSuffix(result, ".-") { + result += ".-" + } + + return result +} + // MyListWikiPages lists all wiki pages in a repository. // GET /repos/{owner}/{repo}/wiki/pages func (c *Client) MyListWikiPages(owner, repo string) ([]*types.MyWikiPageMetaData, error) { @@ -27,9 +66,11 @@ func (c *Client) MyListWikiPages(owner, repo string) ([]*types.MyWikiPageMetaDat } // MyGetWikiPage gets a single wiki page by name. +// The pageName can be either the display title (e.g. "architecture/overview") +// or the sub_url slug — it will be converted automatically. // GET /repos/{owner}/{repo}/wiki/page/{pageName} func (c *Client) MyGetWikiPage(owner, repo, pageName string) (*types.MyWikiPage, error) { - endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName) + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, wikiPageNameToSlug(pageName)) var result types.MyWikiPage err := c.sendSimpleRequest("GET", endpoint, nil, &result) @@ -55,9 +96,10 @@ func (c *Client) MyCreateWikiPage(owner, repo string, options types.MyCreateWiki } // MyDeleteWikiPage deletes a wiki page. +// The pageName can be either the display title or the sub_url slug. // DELETE /repos/{owner}/{repo}/wiki/page/{pageName} func (c *Client) MyDeleteWikiPage(owner, repo, pageName string) error { - endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName) + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, wikiPageNameToSlug(pageName)) // DELETE returns 204 No Content on success var result interface{} @@ -70,9 +112,10 @@ func (c *Client) MyDeleteWikiPage(owner, repo, pageName string) error { } // MyEditWikiPage edits an existing wiki page. +// The pageName can be either the display title or the sub_url slug. // PATCH /repos/{owner}/{repo}/wiki/page/{pageName} func (c *Client) MyEditWikiPage(owner, repo, pageName string, options types.MyCreateWikiPageOptions) (*types.MyWikiPage, error) { - endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName) + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, wikiPageNameToSlug(pageName)) var result types.MyWikiPage err := c.sendSimpleRequest("PATCH", endpoint, options, &result) diff --git a/tools/client_wiki_test.go b/tools/client_wiki_test.go new file mode 100644 index 0000000..cfe77e5 --- /dev/null +++ b/tools/client_wiki_test.go @@ -0,0 +1,60 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package tools + +import "testing" + +func TestWikiPageNameToSlug(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "flat page no conversion needed", + input: "Home", + expected: "Home", + }, + { + name: "flat page with spaces", + input: "Getting Started", + expected: "Getting-Started", + }, + { + name: "single level nested page", + input: "architecture/overview", + expected: "architecture%2Foverview.-", + }, + { + name: "deep nested page", + input: "architecture/mflow/MFLOW-glossary", + expected: "architecture%2Fmflow%2FMFLOW-glossary.-", + }, + { + name: "nested page with spaces", + input: "getting started/quick reference", + expected: "getting-started%2Fquick-reference.-", + }, + { + name: "already encoded sub_url passthrough", + input: "architecture%2Foverview.-", + expected: "architecture%2Foverview.-", + }, + { + name: "already encoded lowercase passthrough", + input: "architecture%2foverview.-", + expected: "architecture%2foverview.-", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wikiPageNameToSlug(tt.input) + if got != tt.expected { + t.Errorf("wikiPageNameToSlug(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +}