From 3dc69bb070d3e1f8b3fe3f224b1b7f3276e561ef Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Sat, 16 May 2026 23:05:42 +0200 Subject: [PATCH] Edit lock acquisition --- README.md | 4 +- internal/topdata/wiki_deploy.go | 58 +++++++++++- internal/topdata/wiki_deploy_test.go | 129 ++++++++++++++++++++++++++- 3 files changed, 184 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 66cd745..8086d70 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,9 @@ 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. +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. `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 d15c559..000410c 100644 --- a/internal/topdata/wiki_deploy.go +++ b/internal/topdata/wiki_deploy.go @@ -197,11 +197,11 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress entry.PID = created.PID nextManifest.Pages[plan.Page.PageID] = entry case "update": - if err := client.updatePost(plan.Entry.PID, plan.Content, summary); err != nil { + if err := client.updatePost(plan.Entry.TID, plan.Entry.PID, plan.Content, summary); err != nil { return result, err } case "archive": - if err := client.updatePost(plan.Entry.PID, plan.Content, summary); err != nil { + if err := client.updatePost(plan.Entry.TID, plan.Entry.PID, plan.Content, summary); err != nil { return result, err } } @@ -378,6 +378,10 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife if err != nil { return nil, result, next, err } + if entry.TID == 0 && remote.TID != 0 { + entry.TID = remote.TID + next.Pages[pageID] = entry + } remoteHash := computeManagedHash(remote.Content) if manifest.Pages[pageID].Hash != "" && remoteHash != manifest.Pages[pageID].Hash { result.Drifted++ @@ -823,6 +827,10 @@ type nodeBBPost struct { Content string } +type nodeBBEditLock struct { + Token string +} + type nodeBBWikiPage struct { TID int Title string @@ -896,14 +904,44 @@ func (c *nodeBBClient) createTopic(cid int, title, content, summary string) (nod return post, nil } -func (c *nodeBBClient) updatePost(pid int, content, summary string) error { +func (c *nodeBBClient) updatePost(tid, pid int, content, summary string) error { + if tid == 0 { + post, err := c.getPost(pid) + if err != nil { + return err + } + tid = post.TID + } + lock, err := c.acquireEditLock(tid) + if err != nil { + return err + } body := map[string]any{"content": content} + if lock.Token != "" { + body["wikiEditLockToken"] = lock.Token + } if summary != "" { body["_uid_note"] = summary } return c.request("PUT", fmt.Sprintf("/api/v3/posts/%d", pid), body, nil) } +func (c *nodeBBClient) acquireEditLock(tid int) (nodeBBEditLock, error) { + if tid == 0 { + return nodeBBEditLock{}, fmt.Errorf("NodeBB wiki post update requires a topic id for edit locking") + } + body := map[string]any{"tid": tid} + var doc any + if err := c.request("PUT", "/api/v3/plugins/westgate-wiki/edit-lock", body, &doc); err != nil { + return nodeBBEditLock{}, err + } + lock := parseNodeBBEditLock(doc) + if lock.Token == "" { + return nodeBBEditLock{}, fmt.Errorf("NodeBB wiki edit lock response for topic %d did not include token", tid) + } + return lock, nil +} + func (c *nodeBBClient) request(method, path string, payload any, out any) error { var body io.Reader if payload != nil { @@ -973,6 +1011,20 @@ func parseNodeBBPost(doc any) nodeBBPost { } } +func parseNodeBBEditLock(doc any) nodeBBEditLock { + for { + m, ok := doc.(map[string]any) + if !ok { + return nodeBBEditLock{} + } + if response, ok := m["response"]; ok { + doc = response + continue + } + return nodeBBEditLock{Token: firstString(m, "token")} + } +} + func parseNodeBBWikiPageList(doc any) ([]nodeBBWikiPage, string, bool) { for { m, ok := doc.(map[string]any) diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go index 87ea0b4..057d7d9 100644 --- a/internal/topdata/wiki_deploy_test.go +++ b/internal/topdata/wiki_deploy_test.go @@ -262,13 +262,19 @@ func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t 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, "adopt-lock") case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/88": var req struct { - Content string `json:"content"` + 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 != "adopt-lock" { + t.Fatalf("expected adopted update to include wiki 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}}) @@ -356,13 +362,19 @@ func TestDeployWikiMergesHTMLManagedAndManualRegions(t *testing.T) { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7, "content": old}}) + case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock": + respondWikiEditLock(t, w, r, 7, "merge-lock") case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/42": var req struct { - Content string `json:"content"` + 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 != "merge-lock" { + t.Fatalf("expected update to include wiki 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": 42, "tid": 7}}) @@ -389,6 +401,94 @@ func TestDeployWikiMergesHTMLManagedAndManualRegions(t *testing.T) { } } +func TestDeployWikiUpdateAcquiresWestgateWikiEditLock(t *testing.T) { + root := t.TempDir() + sourceDir := filepath.Join(root, "pages") + if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil { + t.Fatalf("create source dir: %v", err) + } + generated := ` + +

Athletics

+

Generated athletics page

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

Athletics

+

Old athletics page

+ +` + manifestPath := filepath.Join(root, "deploy-manifest.json") + if err := saveDeployManifest(manifestPath, wikiDeployManifest{ + Version: "nodebb-v1", + Pages: map[string]wikiDeployManifestPage{ + "skills:athletics": {Hash: computeManagedHash(old), TID: 7, PID: 42, CID: 3}, + }, + }); err != nil { + t.Fatalf("write deploy manifest: %v", err) + } + + lockAcquired := false + var updateToken string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7, "content": old}}) + case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock": + var req struct { + TID int `json:"tid"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode edit lock request: %v", err) + } + if req.TID != 7 { + t.Fatalf("expected edit lock for tid 7, got %d", req.TID) + } + lockAcquired = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"status": "ok", "tid": 7, "token": "lock-token"}}) + case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/42": + 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) + } + updateToken = req.WikiEditLockToken + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ + SourceDir: sourceDir, + Endpoint: server.URL, + Token: "nodebb-token", + ManifestPath: manifestPath, + }, nil) + if err != nil { + t.Fatalf("DeployWikiWithOptions update failed: %v", err) + } + if result.Updated != 1 { + t.Fatalf("expected one update, got %#v", result) + } + if !lockAcquired { + t.Fatalf("expected deployer to acquire a wiki edit lock before update") + } + if updateToken != "lock-token" { + t.Fatalf("expected update to include wiki edit lock token, got %q", updateToken) + } +} + func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) { root := t.TempDir() sourceDir := filepath.Join(root, "pages") @@ -407,15 +507,23 @@ func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) { var archived string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock" { + respondWikiEditLock(t, w, r, 7, "archive-lock") + return + } if r.Method != http.MethodPut || r.URL.Path != "/api/v3/posts/42" { t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } var req struct { - Content string `json:"content"` + Content string `json:"content"` + WikiEditLockToken string `json:"wikiEditLockToken"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode archive request: %v", err) } + if req.WikiEditLockToken != "archive-lock" { + t.Fatalf("expected archive to include wiki edit lock token, got %q", req.WikiEditLockToken) + } archived = req.Content w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}}) @@ -468,3 +576,18 @@ func containsAll(haystack string, needles ...string) bool { } return true } + +func respondWikiEditLock(t *testing.T, w http.ResponseWriter, r *http.Request, expectedTID int, token string) { + t.Helper() + var req struct { + TID int `json:"tid"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode edit lock request: %v", err) + } + if req.TID != expectedTID { + t.Fatalf("expected edit lock for tid %d, got %d", expectedTID, req.TID) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"status": "ok", "tid": expectedTID, "token": token}}) +}