diff --git a/README.md b/README.md index 8086d70..c258363 100644 --- a/README.md +++ b/README.md @@ -325,9 +325,12 @@ sow-toolkit deploy-wiki --dry-run --namespace spells --category spells=42 When a deploy manifest is missing but matching wiki pages already exist in NodeBB, `deploy-wiki` checks the Westgate Wiki namespace directory API and adopts those topic/post mappings before planning updates. `--create` is still -required for genuinely new pages that have no existing remote topic. Updates -and stale-page archives acquire a Westgate Wiki edit lock before saving, then -send the returned `wikiEditLockToken` with the NodeBB post edit request. +required for genuinely new pages that have no existing remote topic. If NodeBB +rejects a create because the clean wiki URL already exists, `deploy-wiki` +re-queries the namespace by page leaf, adopts the matching topic, and updates it +instead. Updates and stale-page archives acquire a Westgate Wiki edit lock +before saving, then send the returned `wikiEditLockToken` with the NodeBB post +edit request. `build-changelog` renders a release changelog from tagged pushes. Pull-request style commits keep their PR links and authors from the Gitea API; direct pushes diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go index 000410c..54c6e95 100644 --- a/internal/topdata/wiki_deploy.go +++ b/internal/topdata/wiki_deploy.go @@ -190,7 +190,18 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress case "create": created, err := client.createTopic(plan.Entry.CID, plan.Page.Title, plan.Content, summary) if err != nil { - return result, err + if isNodeBBWikiPageCollision(err) { + recovered, recoverErr := recoverCreateCollision(plan, nextManifest, client, summary) + if recoverErr != nil { + return result, fmt.Errorf("deploy wiki page %q: recover existing NodeBB page after create collision: %w", plan.Page.PageID, recoverErr) + } + if recovered { + result.Created-- + result.Updated++ + continue + } + } + return result, fmt.Errorf("deploy wiki page %q: create NodeBB topic %q in category %d: %w", plan.Page.PageID, plan.Page.Title, plan.Entry.CID, err) } entry := nextManifest.Pages[plan.Page.PageID] entry.TID = created.TID @@ -198,11 +209,11 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress nextManifest.Pages[plan.Page.PageID] = entry case "update": if err := client.updatePost(plan.Entry.TID, plan.Entry.PID, plan.Content, summary); err != nil { - return result, err + return result, fmt.Errorf("deploy wiki page %q: update NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err) } case "archive": if err := client.updatePost(plan.Entry.TID, plan.Entry.PID, plan.Content, summary); err != nil { - return result, err + return result, fmt.Errorf("deploy wiki page %q: archive NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err) } } } @@ -431,6 +442,31 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife return plans, result, next, nil } +func recoverCreateCollision(plan wikiDeployPlan, manifest wikiDeployManifest, client *nodeBBClient, summary string) (bool, error) { + entry, ok, err := findExistingNodeBBPage(plan.Page, plan.Entry.CID, client) + if err != nil || !ok { + return ok, err + } + remote, err := client.getPost(entry.PID) + if err != nil { + return false, err + } + if entry.TID == 0 && remote.TID != 0 { + entry.TID = remote.TID + } + content := mergeManagedContent(remote.Content, plan.Content) + if err := client.updatePost(entry.TID, entry.PID, content, summary); err != nil { + return false, err + } + entry.Hash = plan.Entry.Hash + entry.LastSeenHash = plan.Entry.LastSeenHash + entry.Title = plan.Entry.Title + entry.Namespace = plan.Entry.Namespace + entry.Stale = false + manifest.Pages[plan.Page.PageID] = entry + return true, nil +} + func adoptExistingNodeBBPage(page wikiDeployPage, cid int, remotePagesByCID map[int][]nodeBBWikiPage, client *nodeBBClient) (wikiDeployManifestPage, bool, error) { remotePages, ok := remotePagesByCID[cid] if !ok { @@ -445,6 +481,29 @@ func adoptExistingNodeBBPage(page wikiDeployPage, cid int, remotePagesByCID map[ if !ok || remotePage.TID == 0 { return wikiDeployManifestPage{}, false, nil } + return fetchNodeBBWikiPageMapping(page, cid, remotePage, client) +} + +func findExistingNodeBBPage(page wikiDeployPage, cid int, client *nodeBBClient) (wikiDeployManifestPage, bool, error) { + queries := []string{pageIDLeaf(page.PageID), page.Title, pageIDRemainder(page.PageID)} + for _, query := range queries { + query = strings.TrimSpace(query) + if query == "" { + continue + } + remotePages, err := client.searchNamespacePages(cid, query) + if err != nil { + return wikiDeployManifestPage{}, false, err + } + remotePage, ok := matchExistingNodeBBPage(page, remotePages) + if ok && remotePage.TID != 0 { + return fetchNodeBBWikiPageMapping(page, cid, remotePage, client) + } + } + return wikiDeployManifestPage{}, false, nil +} + +func fetchNodeBBWikiPageMapping(page wikiDeployPage, cid int, remotePage nodeBBWikiPage, client *nodeBBClient) (wikiDeployManifestPage, bool, error) { topic, err := client.getTopic(remotePage.TID) if err != nil { return wikiDeployManifestPage{}, false, err @@ -821,6 +880,17 @@ type nodeBBClient struct { timeout time.Duration } +type nodeBBHTTPError struct { + Method string + Path string + Status int + Body string +} + +func (e nodeBBHTTPError) Error() string { + return fmt.Sprintf("NodeBB %s %s failed: HTTP %d: %s", e.Method, e.Path, e.Status, e.Body) +} + type nodeBBPost struct { PID int TID int @@ -868,10 +938,21 @@ func (c *nodeBBClient) getTopic(tid int) (nodeBBPost, error) { } func (c *nodeBBClient) listNamespacePages(cid int) ([]nodeBBWikiPage, error) { + return c.listNamespacePagesWithQuery(cid, "") +} + +func (c *nodeBBClient) searchNamespacePages(cid int, query string) ([]nodeBBWikiPage, error) { + return c.listNamespacePagesWithQuery(cid, query) +} + +func (c *nodeBBClient) listNamespacePagesWithQuery(cid int, query string) ([]nodeBBWikiPage, error) { var out []nodeBBWikiPage after := "" for { path := fmt.Sprintf("/api/v3/plugins/westgate-wiki/namespace/%d/pages?limit=80", cid) + if query != "" { + path += "&q=" + url.QueryEscape(query) + } if after != "" { path += "&after=" + url.QueryEscape(after) } @@ -971,7 +1052,7 @@ func (c *nodeBBClient) request(method, path string, payload any, out any) error return err } if resp.StatusCode >= 400 { - return fmt.Errorf("NodeBB %s %s failed: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw))) + return nodeBBHTTPError{Method: method, Path: path, Status: resp.StatusCode, Body: strings.TrimSpace(string(raw))} } if out == nil || len(bytes.TrimSpace(raw)) == 0 { return nil @@ -1054,6 +1135,20 @@ func parseNodeBBWikiPageList(doc any) ([]nodeBBWikiPage, string, bool) { } } +func isNodeBBWikiPageCollision(err error) bool { + var httpErr nodeBBHTTPError + if !errors.As(err, &httpErr) { + return false + } + if httpErr.Status != http.StatusBadRequest { + return false + } + body := strings.ToLower(httpErr.Body) + return strings.Contains(body, "wiki page with this url already exists") || + strings.Contains(body, "page-collision") || + strings.Contains(body, "namespace-page-collision") +} + func boolFromAny(value any) bool { switch typed := value.(type) { case bool: diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go index 057d7d9..9585165 100644 --- a/internal/topdata/wiki_deploy_test.go +++ b/internal/topdata/wiki_deploy_test.go @@ -489,6 +489,109 @@ func TestDeployWikiUpdateAcquiresWestgateWikiEditLock(t *testing.T) { } } +func TestDeployWikiCreateCollisionAdoptsExistingNodeBBPage(t *testing.T) { + root := t.TempDir() + sourceDir := filepath.Join(root, "pages") + if err := os.MkdirAll(filepath.Join(sourceDir, "classes"), 0755); err != nil { + t.Fatalf("create source dir: %v", err) + } + generated := ` + +

Bard

+

Generated bard page

+ +` + if err := os.WriteFile(filepath.Join(sourceDir, "classes", "bard.html"), []byte(generated), 0644); err != nil { + t.Fatalf("write source page: %v", err) + } + old := ` + +

Bard

+

Old bard page

+ +` + + createCalls := 0 + var updated string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages" && r.URL.Query().Get("q") == "": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) + case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics": + createCalls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": map[string]any{ + "code": "bad-request", + "message": "A wiki page with this URL already exists in this namespace. Rename the page before publishing.", + }, + "response": map[string]any{}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages" && r.URL.Query().Get("q") == "bard": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "response": map[string]any{ + "pages": []map[string]any{ + {"tid": 77, "title": "Bard", "titleLeaf": "Bard", "slug": "77/bard", "wikiPath": "/wiki/classes/bard"}, + }, + "hasMore": false, + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/77": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 77, "mainPid": 88}}) + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/88": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 88, "tid": 77, "content": old}}) + case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock": + respondWikiEditLock(t, w, r, 77, "collision-lock") + case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/88": + var req struct { + Content string `json:"content"` + WikiEditLockToken string `json:"wikiEditLockToken"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode update request: %v", err) + } + if req.WikiEditLockToken != "collision-lock" { + t.Fatalf("expected collision recovery update to include edit lock token, got %q", req.WikiEditLockToken) + } + updated = req.Content + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 88, "tid": 77}}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + manifestPath := filepath.Join(root, "deploy-manifest.json") + result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ + SourceDir: sourceDir, + Endpoint: server.URL, + Token: "nodebb-token", + ManifestPath: manifestPath, + CategoryIDs: map[string]int{"classes": 9}, + AllowCreates: true, + }, nil) + if err != nil { + t.Fatalf("DeployWikiWithOptions collision recovery failed: %v", err) + } + if result.Created != 0 || result.Updated != 1 || createCalls != 1 { + t.Fatalf("expected collision recovery to update existing page, result=%#v createCalls=%d", result, createCalls) + } + if !containsAll(updated, "

Generated bard page

") { + t.Fatalf("expected collision recovery to update adopted page:\n%s", updated) + } + manifest := loadDeployManifest(manifestPath) + entry := manifest.Pages["classes:bard"] + if entry.TID != 77 || entry.PID != 88 || entry.CID != 9 || entry.Hash != computeManagedHash(generated) { + t.Fatalf("unexpected adopted manifest entry: %#v", entry) + } +} + func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) { root := t.TempDir() sourceDir := filepath.Join(root, "pages")