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{
+79 -15
View File
@@ -33,21 +33,22 @@ const (
) )
type DeployWikiOptions struct { type DeployWikiOptions struct {
SourceDir string SourceDir string
Endpoint string Endpoint string
Token string Token string
Version string Version string
Namespaces []string Namespaces []string
CategoryIDs map[string]int CategoryIDs map[string]int
ManifestPath string ManifestPath string
DryRun bool DryRun bool
Force bool Force bool
AllowCreates bool AllowCreates bool
StalePolicy string ResetManagedNamespaces bool
Username string StalePolicy string
Password string Username string
NotesDelimiter string Password string
TitlePrefixMinLength int NotesDelimiter string
TitlePrefixMinLength int
} }
type deployResult struct { type deployResult struct {
@@ -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")