Add managed namespace wiki reset deploy

This commit is contained in:
2026-05-23 17:51:27 +02:00
parent 1221156084
commit 22d7fa5e6f
5 changed files with 228 additions and 17 deletions
+6 -1
View File
@@ -71,7 +71,7 @@ sow-toolkit build-top-package [--force]
sow-toolkit compare-topdata sow-toolkit compare-topdata
sow-toolkit convert-topdata <2da-to-json|2da-to-module|json-to-2da> ... sow-toolkit convert-topdata <2da-to-json|2da-to-module|json-to-2da> ...
sow-toolkit build-wiki [--force] sow-toolkit build-wiki [--force]
sow-toolkit deploy-wiki [--source-dir <path>] [--endpoint <url>] [--token <token>] [--version <ver>] [--namespace <ns>] [--category <namespace=cid>] [--manifest <path>] [--stale-policy <report|archive|purge>] [--dry-run] [--create] [--force] sow-toolkit deploy-wiki [--source-dir <path>] [--endpoint <url>] [--token <token>] [--version <ver>] [--namespace <ns>] [--category <namespace=cid>] [--manifest <path>] [--stale-policy <report|archive|purge>] [--dry-run] [--create] [--force] [--reset-managed-namespaces]
sow-toolkit build-changelog [--config <path>] [--output <path>] [--current-tag <tag>] [--previous-tag <tag>] [--api-base-url <url>] [--token <token>] sow-toolkit build-changelog [--config <path>] [--output <path>] [--current-tag <tag>] [--previous-tag <tag>] [--api-base-url <url>] [--token <token>]
``` ```
@@ -436,6 +436,11 @@ reported by default.
managed content. `--stale-policy purge` permanently removes only stale generated managed content. `--stale-policy purge` permanently removes only stale generated
topics already tracked in the wiki deploy manifest. Dry-run that policy before topics already tracked in the wiki deploy manifest. Dry-run that policy before
the live run; it never discovers manual wiki pages from NodeBB for purge. the live run; it never discovers manual wiki pages from NodeBB for purge.
`--reset-managed-namespaces` is a stronger destructive mode for slug or path
standard migrations: it permanently removes every topic currently listed by the
configured managed namespace categories, ignores the previous deploy manifest,
and recreates the current generated pages. It requires `--create` when local
generated pages are present.
For newly created NodeBB topics, `topdata.wiki.title_prefix_min_length` For newly created NodeBB topics, `topdata.wiki.title_prefix_min_length`
controls when the deployer prefixes the namespace in the topic title. The controls when the deployer prefixes the namespace in the topic title. The
+5 -1
View File
@@ -1950,8 +1950,10 @@ func parseDeployWikiArgs(commandName string, args []string) (topdata.DeployWikiO
opts.Force = true opts.Force = true
case "--create": case "--create":
opts.AllowCreates = true opts.AllowCreates = true
case "--reset-managed-namespaces":
opts.ResetManagedNamespaces = true
case "-h", "--help": case "-h", "--help":
return opts, fmt.Errorf("usage: %s [--source-dir <path>] [--endpoint <url>] [--token <token>] [--version <ver>] [--namespace <ns> ...] [--category <namespace=cid> ...] [--manifest <path>] [--stale-policy <report|archive|purge>] [--dry-run] [--create] [--force]", commandName) return opts, fmt.Errorf("usage: %s [--source-dir <path>] [--endpoint <url>] [--token <token>] [--version <ver>] [--namespace <ns> ...] [--category <namespace=cid> ...] [--manifest <path>] [--stale-policy <report|archive|purge>] [--dry-run] [--create] [--force] [--reset-managed-namespaces]", commandName)
default: default:
if value, ok, err := requireInlineFlagValue(arg, "--source-dir"); ok || err != nil { if value, ok, err := requireInlineFlagValue(arg, "--source-dir"); ok || err != nil {
if err != nil { if err != nil {
@@ -2014,6 +2016,8 @@ func parseDeployWikiArgs(commandName string, args []string) (topdata.DeployWikiO
opts.Force = true opts.Force = true
} else if arg == "--create" { } else if arg == "--create" {
opts.AllowCreates = true opts.AllowCreates = true
} else if arg == "--reset-managed-namespaces" {
opts.ResetManagedNamespaces = true
} else { } else {
return opts, fmt.Errorf("unknown %s argument %q", commandName, arg) return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
} }
+10
View File
@@ -271,6 +271,16 @@ func TestParseDeployWikiHelpListsPurgeStalePolicy(t *testing.T) {
} }
} }
func TestParseDeployWikiResetManagedNamespacesFlag(t *testing.T) {
opts, err := parseDeployWikiArgs("deploy-wiki", []string{"--reset-managed-namespaces"})
if err != nil {
t.Fatalf("parse deploy wiki reset flag: %v", err)
}
if !opts.ResetManagedNamespaces {
t.Fatalf("expected --reset-managed-namespaces to enable namespace reset")
}
}
func TestProjectConsoleSuppressesBuildModuleProgressInNormalMode(t *testing.T) { func TestProjectConsoleSuppressesBuildModuleProgressInNormalMode(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
console := &projectConsole{ console := &projectConsole{
+64
View File
@@ -43,6 +43,7 @@ type DeployWikiOptions struct {
DryRun bool DryRun bool
Force bool Force bool
AllowCreates bool AllowCreates bool
ResetManagedNamespaces bool
StalePolicy string StalePolicy string
Username string Username string
Password string Password string
@@ -456,6 +457,23 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
remotePagesByCID := map[int][]nodeBBWikiPage{} remotePagesByCID := map[int][]nodeBBWikiPage{}
var plans []wikiDeployPlan var plans []wikiDeployPlan
var result deployResult var result deployResult
if opts.ResetManagedNamespaces {
if !opts.AllowCreates && len(pageIDs) > 0 {
return nil, result, next, errors.New("wiki managed namespace reset requires --create so current generated pages can be recreated")
}
resetPlans, purged, err := planManagedNamespaceReset(opts, client)
if err != nil {
return nil, result, next, err
}
plans = append(plans, resetPlans...)
result.Purged += purged
manifest = wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
for _, cid := range opts.CategoryIDs {
if cid != 0 {
remotePagesByCID[cid] = []nodeBBWikiPage{}
}
}
}
for _, pageID := range pageIDs { for _, pageID := range pageIDs {
page := pages[pageID] page := pages[pageID]
@@ -615,6 +633,52 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
return plans, result, next, nil return plans, result, next, nil
} }
func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient) ([]wikiDeployPlan, int, error) {
namespaces := slices.Clone(opts.Namespaces)
if len(namespaces) == 0 {
for namespace := range opts.CategoryIDs {
namespaces = append(namespaces, namespace)
}
}
slices.Sort(namespaces)
seenTIDs := map[int]struct{}{}
var plans []wikiDeployPlan
for _, namespace := range namespaces {
cid := opts.CategoryIDs[namespace]
if cid == 0 {
continue
}
remotePages, err := client.listNamespacePages(cid)
if err != nil {
return nil, 0, err
}
slices.SortFunc(remotePages, func(a, b nodeBBWikiPage) int {
return a.TID - b.TID
})
for _, remotePage := range remotePages {
if remotePage.TID == 0 {
continue
}
if _, ok := seenTIDs[remotePage.TID]; ok {
continue
}
seenTIDs[remotePage.TID] = struct{}{}
title := strings.TrimSpace(remotePage.Title)
if title == "" {
title = strings.TrimSpace(remotePage.TitleLeaf)
}
pageID := fmt.Sprintf("%s:reset-topic-%d", namespace, remotePage.TID)
plans = append(plans, wikiDeployPlan{
Page: wikiDeployPage{PageID: pageID, Title: title, Namespace: namespace},
Entry: wikiDeployManifestPage{TID: remotePage.TID, CID: cid, Namespace: namespace, Title: title},
Action: "purge",
})
}
}
return plans, len(plans), nil
}
func findMappedRemotePage(entry wikiDeployManifestPage, remotePagesByCID map[int][]nodeBBWikiPage, client *nodeBBClient) (nodeBBWikiPage, bool, error) { func findMappedRemotePage(entry wikiDeployManifestPage, remotePagesByCID map[int][]nodeBBWikiPage, client *nodeBBClient) (nodeBBWikiPage, bool, error) {
remotePages, ok := remotePagesByCID[entry.CID] remotePages, ok := remotePagesByCID[entry.CID]
if !ok { if !ok {
+128
View File
@@ -1575,6 +1575,134 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes
} }
} }
func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshManifest(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 := `<!-- sow-topdata-wiki:page=skills:replacement -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Replacement</h1>
<p>Generated replacement page</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "replacement.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:stale": {Hash: "old-hash", Title: "Replacement", Namespace: "skills", TID: 7, PID: 42, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
var calls []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/3/pages":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"pages": []map[string]any{
{"tid": 7, "title": "Old Replacement", "titleLeaf": "Old Replacement", "slug": "7/replacement", "wikiPath": "/wiki/skills/replacement"},
{"tid": 8, "title": "Manual Note", "titleLeaf": "Manual Note", "slug": "8/manual-note", "wikiPath": "/wiki/skills/manual-note"},
},
"hasMore": false,
},
})
case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/topics/7":
calls = append(calls, "purge:7")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/topics/8":
calls = append(calls, "purge:8")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics":
calls = append(calls, "create")
if strings.Join(calls, ",") != "purge:7,purge:8,create" {
t.Fatalf("expected namespace reset purges before create, got %#v", calls)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"tid": 11,
"mainPost": map[string]any{
"pid": 99,
"tid": 11,
},
},
})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
CategoryIDs: map[string]int{"skills": 3},
AllowCreates: true,
ResetManagedNamespaces: true,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions reset-managed-namespaces failed: %v", err)
}
if result.Created != 1 || result.Purged != 2 || result.Stale != 0 {
t.Fatalf("expected one create and two namespace purges, got %#v", result)
}
if strings.Join(calls, ",") != "purge:7,purge:8,create" {
t.Fatalf("expected reset purges before create, got %#v", calls)
}
manifest := loadDeployManifest(manifestPath)
if _, ok := manifest.Pages["skills:stale"]; ok {
t.Fatalf("expected reset to remove previous manifest mapping")
}
entry := manifest.Pages["skills:replacement"]
if entry.TID != 11 || entry.PID != 99 || entry.CID != 3 {
t.Fatalf("expected fresh created mapping in manifest, got %#v", entry)
}
}
func TestDeployWikiResetManagedNamespacesRequiresCreateForLocalPages(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 := `<!-- sow-topdata-wiki:page=skills:replacement -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Replacement</h1>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "replacement.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("reset without --create must fail before remote calls, got %s %s", r.Method, r.URL.String())
}))
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
CategoryIDs: map[string]int{"skills": 3},
ResetManagedNamespaces: true,
}, nil)
if err == nil || !strings.Contains(err.Error(), "requires --create") {
t.Fatalf("expected reset without create to fail clearly, got %v", err)
}
}
func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T) { func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T) {
root := t.TempDir() root := t.TempDir()
sourceDir := filepath.Join(root, "pages") sourceDir := filepath.Join(root, "pages")