Compare commits

..
1 Commits
Author SHA1 Message Date
archvillainetteandClaude Opus 5 27005622e4 docs(agents): record that tea 0.14 cannot apply org-level labels
ci / ci (pull_request) Successful in 3m38s
`tea issues edit --add-labels` and `--remove-labels` resolve names against
the repo's own label set only, so `Kind/*`, `Priority/*` and the rest of
the org set match nothing. The command exits 0, prints the issue and
changes nothing — no error to notice. Repo-local labels work, which makes
the failure look intermittent rather than categorical.

Upstream fixed this in v0.15 (`modules/task/labels.go` also queries
`ListOrgLabels`); nixpkgs pins 0.14.0, so the note expires on a bump.

Also states the general rule the miss came from: re-read a label change
before believing it. Exit 0 is not evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:58:01 +02:00
7 changed files with 156 additions and 525 deletions
+22 -26
View File
@@ -57,32 +57,28 @@ preference — move it.
so any non-interactive shell (every agent) hangs forever without
`</dev/null`. Same trap on `tea issues create --description` and
`tea pr create`.
- **Apply / remove labels**: `tea issues edit <number> --add-labels "Kind/Bug"`
(and `--remove-labels`). This handles org-level labels (`Kind/*`,
`Priority/*`, `Reviewed/*`, `Status/*`) from tea 0.15 onwards. On 0.14 it did
not: name resolution searched only this repo's own label set, so an org label
matched nothing and the command exited 0, printed the issue, and changed
nothing. Upstream fixed it in v0.15 (`modules/task/labels.go` also queries
`ListOrgLabels`). Note `tea labels` lists repo labels only and will not show
you the org set — `tea api orgs/ShadowsOverWestgate/labels` does.
- **`tea api` needs a token in the login; SSH auth is not enough.** It sends
only the login's `token:` field and does not sign requests with your SSH key,
so an SSH-key-only login gets `{"message":"token is required"}` on every call
that needs auth. Reads against these public repos still succeed anonymously,
which hides the gap until the first write. Add a token to the login in
`~/.config/tea/config.yml` (Settings > Applications; `write:issue` covers
labels, comments and dependencies) and `tea api` works for reads and writes
alike. The `tea issues` / `tea pr` subcommands authenticate either way, so
they keep working with no token at all — that asymmetry is what makes this
confusing to diagnose.
- **Verify every label change by re-reading it.** A label command exiting 0 is
not evidence it applied — that is exactly how the 0.14 silent no-op above hid
for so long, and assuming otherwise has already cost one investigation
several wrong turns. Read the resulting set back with
`tea api repos/ShadowsOverWestgate/sow-tools/issues/<number>` and check its
`labels` field, or `tea issues ls -o json`. The read-back reflects the write
immediately; if it comes back empty, the write genuinely failed. Do not
explain an empty read-back away as replication lag.
- **Apply / remove labels**: `tea api --method PATCH` on the issue, or
`tea api repos/ShadowsOverWestgate/sow-tools/issues/<number>/labels` endpoints.
- **Org-level labels (`Kind/*`, `Priority/*`, `Reviewed/*`, `Status/*`)**: `tea
issues edit --add-labels` / `--remove-labels` do **not** apply these on tea
0.14.0, the version nixpkgs pins. Name resolution searches only this repo's
own label set, so an org label matches nothing and the command exits 0,
prints the issue, and changes nothing — no error, no warning. Repo-local
labels (`wayfinder:*`, `ready-for-agent`) are unaffected. PRs no-op the same
way. Upstream fixed it in v0.15 (`modules/task/labels.go` now also queries
`ListOrgLabels`), so this expires when tea is bumped; until then apply org
labels in the web UI. Note `tea labels` lists repo labels only, so it will
not show you the org set either — `tea api orgs/ShadowsOverWestgate/labels`
does.
- **Verify every label change by re-reading it.** `tea issues ls -o json` has a
`labels` field; use it. A label command exiting 0 is not evidence it applied,
and assuming otherwise has already cost one investigation several wrong turns.
`tea issues edit --add-labels` works too, org-level labels (`Kind/*`,
`Priority/*`) included, but a read-back straight afterwards can still show
the old set — `tea issues ls` has returned stale labels for several seconds
after an edit that succeeded. Never conclude an edit failed from a single
immediate list; re-read after a pause, or use the `labels` endpoints above,
whose response *is* the resulting label set and needs no second call.
- **Close**: `tea issues close <number>`
`tea` infers the repo from the git remote when run inside the clone.
+3 -11
View File
@@ -24,19 +24,11 @@ this.
tea issues create --title "..." --description "..." --labels "Kind/Bug" </dev/null
tea pr create --title "..." --description "..." --labels "Kind/Feature" </dev/null
# add a label to something that already exists (needs tea >= 0.15 for org labels)
tea issues edit <n> --add-labels "Kind/Bug,Priority/High"
# read the result back — exit 0 is not evidence the label applied
tea api "repos/ShadowsOverWestgate/<repo>/issues/<n>"
# add a label to something that already exists
tea api --method POST "repos/ShadowsOverWestgate/<repo>/issues/<n>/labels" \
--data '{"labels":["Kind/Bug","Priority/High"]}'
```
`tea api` sends only the login's `token:` and never your SSH key, so on an
SSH-only login every authenticated call fails with
`{"message":"token is required"}` while anonymous reads still work. Add a token
to `~/.config/tea/config.yml` if you want the raw endpoints. `tea issues edit`
needs no token.
## Kind — what this is (pick exactly one)
| Label | Use it when |
+11 -20
View File
@@ -927,28 +927,19 @@ func (c *topdataConsole) emitWikiBuildResult(outputDir string, pageCount int, st
fmt.Fprintf(c.stdout, "wiki status: %s\n", status)
}
func (c *topdataConsole) emitWikiDeployResult(result topdata.DeployResult) {
func (c *topdataConsole) emitWikiDeployResult(localPages, created, updated, skipped, stale, archived, purged, drifted int, manifest string) {
spin.linebreak()
fmt.Fprintln(c.stdout, "Deploy Wiki ----------")
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
fmt.Fprintf(c.stdout, "local pages: %d\n", result.LocalPages)
fmt.Fprintf(c.stdout, "created: %d\n", result.Created)
fmt.Fprintf(c.stdout, "updated: %d\n", result.Updated)
fmt.Fprintf(c.stdout, "skipped: %d\n", result.Skipped)
fmt.Fprintf(c.stdout, "stale: %d\n", result.Stale)
fmt.Fprintf(c.stdout, "archived: %d\n", result.Archived)
fmt.Fprintf(c.stdout, "purged: %d\n", result.Purged)
fmt.Fprintf(c.stdout, "drifted: %d\n", result.Drifted)
if result.ResetPurged > 0 || result.ResetSkipped > 0 {
fmt.Fprintf(c.stdout, "namespace reset: %d (unrecognized: %d)\n", result.ResetPurged, result.ResetUnrecognized)
}
if result.ResetUnrecognized > 0 {
fmt.Fprintln(c.stdout, " unrecognized pages were not written by this deployer; recreating them is not possible")
}
if result.ResetSkipped > 0 {
fmt.Fprintf(c.stdout, " reset skipped: %d (NodeBB refused deletion, e.g. the wiki home page)\n", result.ResetSkipped)
}
fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(result.Manifest))
fmt.Fprintf(c.stdout, "local pages: %d\n", localPages)
fmt.Fprintf(c.stdout, "created: %d\n", created)
fmt.Fprintf(c.stdout, "updated: %d\n", updated)
fmt.Fprintf(c.stdout, "skipped: %d\n", skipped)
fmt.Fprintf(c.stdout, "stale: %d\n", stale)
fmt.Fprintf(c.stdout, "archived: %d\n", archived)
fmt.Fprintf(c.stdout, "purged: %d\n", purged)
fmt.Fprintf(c.stdout, "drifted: %d\n", drifted)
fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(manifest))
}
func (c *topdataConsole) relPath(path string) string {
@@ -1588,7 +1579,7 @@ func runDeployWiki(ctx context) error {
return err
}
console.emitWikiDeployResult(result)
console.emitWikiDeployResult(result.LocalPages, result.Created, result.Updated, result.Skipped, result.Stale, result.Archived, result.Purged, result.Drifted, result.Manifest)
return nil
}
+1 -58
View File
@@ -10,7 +10,6 @@ import (
"time"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata"
)
func TestParseBuildHAKArgsContentAddressedRoot(t *testing.T) {
@@ -219,16 +218,7 @@ func TestTopdataConsoleDebugProgressAndRelativePaths(t *testing.T) {
}
console.progress("NodeBB wiki plan: create 1, update 2, skip 3, stale 4, archive 5, purge 6, drift 0")
console.emitWikiDeployResult(topdata.DeployResult{
LocalPages: 10,
Created: 1,
Updated: 2,
Skipped: 3,
Stale: 4,
Archived: 5,
Purged: 6,
Manifest: "/workspace/project/build/wiki/deploy-manifest.json",
})
console.emitWikiDeployResult(10, 1, 2, 3, 4, 5, 6, 0, "/workspace/project/build/wiki/deploy-manifest.json")
output := stdout.String()
if !strings.Contains(output, "NodeBB wiki plan") {
@@ -239,53 +229,6 @@ func TestTopdataConsoleDebugProgressAndRelativePaths(t *testing.T) {
}
}
func TestTopdataConsoleReportsManagedNamespaceReset(t *testing.T) {
var stdout bytes.Buffer
console := &topdataConsole{
stdout: &stdout,
projectRoot: "/workspace/project",
projectName: "Test Module",
commandName: "deploy-wiki",
commandLabel: "Deploy Wiki",
}
console.emitWikiDeployResult(topdata.DeployResult{
LocalPages: 1200,
Created: 1200,
Stale: 1213,
Purged: 1213,
ResetPurged: 1213,
ResetUnrecognized: 13,
ResetSkipped: 1,
Manifest: "/workspace/project/build/wiki/deploy-manifest.json",
})
// The counts are what an operator reads to decide whether a destructive run
// is safe, so each has to reach the output on a line that names what it
// counts; the wording around them is free to change.
output := stdout.String()
var staleLine, resetLine, skippedLine string
for _, line := range strings.Split(output, "\n") {
switch {
case strings.Contains(line, "stale"):
staleLine = line
case strings.Contains(line, "skipped"):
skippedLine = line
case strings.Contains(line, "reset"):
resetLine = line
}
}
if !strings.Contains(staleLine, "1213") {
t.Fatalf("expected reset deletions counted as stale, got %q", output)
}
if !strings.Contains(resetLine, "1213") || !strings.Contains(resetLine, "13") {
t.Fatalf("expected the reset line to carry both the total and the unrecognized count, got %q", output)
}
if !strings.Contains(skippedLine, "1") {
t.Fatalf("expected refused reset deletions to be reported, got %q", output)
}
}
func TestParseDeployWikiHelpListsPurgeStalePolicy(t *testing.T) {
_, err := parseDeployWikiArgs("deploy-wiki", []string{"--help"})
if err == nil || !strings.Contains(err.Error(), "--stale-policy <report|archive|purge>") {
+1 -2
View File
@@ -289,8 +289,7 @@ var Registry = []Builder{
"--dry-run report changes without writing",
"--create allow missing pages to be created",
"--force update unchanged pages",
"--reset-managed-namespaces delete every page in the managed categories, including",
" pages this deployer never wrote, then recreate from source",
"--reset-managed-namespaces reset managed namespace state",
},
Aliases: []CommandAlias{{Name: "deploy-wiki"}},
},
+28 -127
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"html"
"io"
"maps"
"net/http"
"net/url"
"os"
@@ -53,7 +52,7 @@ type DeployWikiOptions struct {
TitlePrefixMinLength int
}
type DeployResult struct {
type deployResult struct {
LocalPages int
Created int
Updated int
@@ -64,19 +63,6 @@ type DeployResult struct {
Drifted int
Renamed int
Manifest string
// ResetPurged counts the deletions queued by --reset-managed-namespaces.
// They are also included in Stale and Purged, because callers warn about
// destructive policies in terms of the stale count: sow-topdata's
// deploy-wiki wrapper prints "of the pages counted as 'stale' below, they
// will be DELETED from NodeBB" directly above this block.
ResetPurged int
// ResetUnrecognized counts the subset of ResetPurged that the deploy
// manifest has no record of writing. Those deletions are the ones a
// re-seed cannot undo.
ResetUnrecognized int
// ResetSkipped counts reset targets NodeBB refused to delete, such as the
// wiki home topic, which the plugin excludes from tombstone and purge.
ResetSkipped int
}
type wikiDeployPage struct {
@@ -114,12 +100,6 @@ type wikiDeployPlan struct {
Content string
RemoteHash string
Title string
// Reset marks a purge queued by --reset-managed-namespaces rather than by
// stale computation over the manifest.
Reset bool
// Unrecognized marks a reset purge whose topic the manifest has no record
// of writing.
Unrecognized bool
}
type wikiNamespacesDocument struct {
@@ -136,7 +116,7 @@ type wikiNamespaceDeclaration struct {
EditPolicy string `json:"edit_policy" yaml:"edit_policy"`
}
func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress func(string)) (DeployResult, error) {
func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress func(string)) (deployResult, error) {
if progress == nil {
progress = func(string) {}
}
@@ -152,19 +132,19 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
}
}
if _, err := os.Stat(opts.SourceDir); err != nil {
return DeployResult{}, fmt.Errorf("wiki source directory not found: %w", err)
return deployResult{}, fmt.Errorf("wiki source directory not found: %w", err)
}
if opts.Endpoint == "" {
return DeployResult{}, errors.New("NODEBB_API_ENDPOINT is required")
return deployResult{}, errors.New("NODEBB_API_ENDPOINT is required")
}
if opts.Token == "" {
return DeployResult{}, errors.New("NODEBB_API_TOKEN is required")
return deployResult{}, errors.New("NODEBB_API_TOKEN is required")
}
if opts.Username != "" || opts.Password != "" || opts.NotesDelimiter != "" {
return DeployResult{}, errors.New("DokuWiki deployment options are no longer supported; use NodeBB endpoint, token, and category mappings")
return deployResult{}, errors.New("DokuWiki deployment options are no longer supported; use NodeBB endpoint, token, and category mappings")
}
if opts.StalePolicy != "" && opts.StalePolicy != "report" && opts.StalePolicy != "archive" && opts.StalePolicy != "purge" {
return DeployResult{}, fmt.Errorf("wiki stale policy %q is not supported", opts.StalePolicy)
return deployResult{}, fmt.Errorf("wiki stale policy %q is not supported", opts.StalePolicy)
}
if opts.TitlePrefixMinLength <= 0 {
opts.TitlePrefixMinLength = p.EffectiveConfig().TopData.Wiki.TitlePrefixMinLength
@@ -174,7 +154,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
if len(namespaces) == 0 {
declarations, err := loadWikiNamespaceDeclarations(p)
if err != nil {
return DeployResult{}, err
return deployResult{}, err
}
for _, declaration := range declarations {
namespaces = append(namespaces, declaration.ID)
@@ -184,7 +164,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
}
envCategories, err := categoryIDsFromNamespaceEnv(declarations)
if err != nil {
return DeployResult{}, err
return deployResult{}, err
}
if len(envCategories) > 0 {
merged := envCategories
@@ -203,7 +183,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
progress(fmt.Sprintf("Collecting local wiki pages from %s", opts.SourceDir))
pages, err := collectLocalPages(opts.SourceDir, opts.PageIndexPath, namespaces)
if err != nil {
return DeployResult{}, err
return deployResult{}, err
}
progress(fmt.Sprintf("Loaded %d local wiki page(s)", len(pages)))
manifest := loadDeployManifest(manifestPath)
@@ -213,7 +193,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
progress(fmt.Sprintf("Planning NodeBB wiki deploy for %d local page(s)", len(pages)))
plans, result, nextManifest, err := planNodeBBDeploy(pages, manifest, opts, client, progress)
if err != nil {
return DeployResult{}, err
return deployResult{}, err
}
result.LocalPages = len(pages)
result.Manifest = manifestPath
@@ -232,8 +212,6 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
}
orderedPlans := orderNodeBBDeployPlans(plans)
progress(fmt.Sprintf("Executing NodeBB wiki actions: total %d, create %d, update %d, rename %d, archive %d, purge %d", len(orderedPlans), result.Created, result.Updated, result.Renamed, result.Archived, result.Purged))
resetPurgedByCID := map[int]int{}
resetSkippedByCID := map[int]int{}
for i, plan := range orderedPlans {
if shouldReportNodeBBDeployActionProgress(i, len(orderedPlans)) {
progress(fmt.Sprintf("Executing NodeBB wiki action %d/%d: %s %s", i+1, len(orderedPlans), plan.Action, plan.Page.PageID))
@@ -264,52 +242,19 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
return result, fmt.Errorf("deploy wiki page %q: update NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err)
}
case "archive":
// Archiving rewrites the page rather than removing it, so it goes
// through the ordinary post edit the wiki plugin allows; only
// delete, restore, and purge are reserved to the page actions.
if err := client.updatePost(plan.Entry.TID, plan.Entry.PID, plan.Content, summary); err != nil {
return result, fmt.Errorf("deploy wiki page %q: archive NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err)
}
case "purge":
if err := client.purgeWikiPage(plan.Entry.TID); err != nil {
// A namespace reset sweeps every topic in the category, so it
// can reach pages NodeBB will not delete at all — the wiki home
// topic above all. Skip those rather than abandoning the reset.
if plan.Reset && isNodeBBWikiPageUndeletable(err) {
result.ResetSkipped++
result.ResetPurged--
result.Purged--
result.Stale--
if plan.Unrecognized {
result.ResetUnrecognized--
}
resetSkippedByCID[plan.Entry.CID]++
progress(fmt.Sprintf("NodeBB refused to delete wiki topic %d during managed namespace reset; skipping it", plan.Entry.TID))
continue
}
if err := client.purgeTopic(plan.Entry.TID); err != nil {
return result, fmt.Errorf("deploy wiki page %q: purge NodeBB topic %d: %w", plan.Page.PageID, plan.Entry.TID, err)
}
if plan.Reset {
resetPurgedByCID[plan.Entry.CID]++
}
case "rename":
if err := client.renameWikiPage(plan.Entry.TID, plan.Entry.CID, plan.Title); err != nil {
return result, fmt.Errorf("deploy wiki page %q: rename NodeBB topic %d to %q: %w", plan.Page.PageID, plan.Entry.TID, plan.Title, err)
}
}
}
// NodeBB answers 403 both for the pages it will never delete — the wiki
// home topic — and for a token without purge privileges, and the two are
// not distinguishable from the response. What tells them apart is scope: a
// category where nothing at all could be deleted is a privilege problem,
// not a home page. Reporting that as a completed reset would leave the
// manifest claiming a fresh start over pages that are all still there, so
// the next deploy would recreate every one of them as a duplicate.
for _, cid := range slices.Sorted(maps.Keys(resetSkippedByCID)) {
if resetPurgedByCID[cid] == 0 {
return result, fmt.Errorf("NodeBB refused every managed namespace reset deletion in category %d (%d topic(s)); check that the deploy token has wiki purge privileges there", cid, resetSkippedByCID[cid])
}
}
if err := saveDeployManifest(manifestPath, nextManifest); err != nil {
return result, err
}
@@ -541,7 +486,7 @@ func isDir(path string) bool {
return err == nil && info.IsDir()
}
func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManifest, opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, DeployResult, wikiDeployManifest, error) {
func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManifest, opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, deployResult, wikiDeployManifest, error) {
if progress == nil {
progress = func(string) {}
}
@@ -549,23 +494,17 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
next := wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
remotePagesByCID := map[int][]nodeBBWikiPage{}
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, unrecognized, err := planManagedNamespaceReset(opts, manifest, client, progress)
resetPlans, purged, err := planManagedNamespaceReset(opts, client, progress)
if err != nil {
return nil, result, next, err
}
plans = append(plans, resetPlans...)
result.Purged += purged
// A namespace reset deletes remote pages the same way stale purge does,
// so it is counted as stale: that is the number callers word their
// destructive-policy warning around.
result.Stale += purged
result.ResetPurged = purged
result.ResetUnrecognized = unrecognized
manifest = wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
for _, cid := range opts.CategoryIDs {
if cid != 0 {
@@ -743,11 +682,7 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
return plans, result, next, nil
}
// planManagedNamespaceReset queues a purge for every topic in each managed
// category, not only the ones the manifest says we wrote. The second return is
// the plan count; the third is how many of those topics the manifest has no
// record of, which is the subset a re-seed cannot put back.
func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManifest, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, int, int, error) {
func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, int, error) {
namespaces := slices.Clone(opts.Namespaces)
if len(namespaces) == 0 {
for namespace := range opts.CategoryIDs {
@@ -756,15 +691,7 @@ func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManife
}
slices.Sort(namespaces)
manifestTIDs := map[int]struct{}{}
for _, entry := range manifest.Pages {
if entry.TID != 0 {
manifestTIDs[entry.TID] = struct{}{}
}
}
seenTIDs := map[int]struct{}{}
unrecognized := 0
var plans []wikiDeployPlan
for _, namespace := range namespaces {
cid := opts.CategoryIDs[namespace]
@@ -774,7 +701,7 @@ func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManife
progress(fmt.Sprintf("Listing NodeBB wiki namespace category %d for managed reset", cid))
remotePages, err := client.listNamespacePages(cid)
if err != nil {
return nil, 0, 0, err
return nil, 0, err
}
slices.SortFunc(remotePages, func(a, b nodeBBWikiPage) int {
return a.TID - b.TID
@@ -787,25 +714,19 @@ func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManife
continue
}
seenTIDs[remotePage.TID] = struct{}{}
_, known := manifestTIDs[remotePage.TID]
if !known {
unrecognized++
}
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",
Reset: true,
Unrecognized: !known,
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), unrecognized, nil
return plans, len(plans), nil
}
func findMappedRemoteTopic(entry wikiDeployManifestPage, client *nodeBBClient) (nodeBBPost, bool, error) {
@@ -1690,36 +1611,16 @@ func (c *nodeBBClient) renameWikiPage(tid, cid int, title string) error {
return c.request("PUT", "/api/v3/plugins/westgate-wiki/page/move", body, nil)
}
// purgeWikiPage deletes a wiki topic through the wiki plugin's own page
// actions. The core DELETE /api/v3/topics/{tid} route is refused for topics in
// wiki categories, because revision history is plugin-owned, so a purge has to
// tombstone the page first and then hard-purge it.
func (c *nodeBBClient) purgeWikiPage(tid int) error {
func (c *nodeBBClient) purgeTopic(tid int) error {
if tid == 0 {
return fmt.Errorf("NodeBB wiki page purge requires topic id")
return fmt.Errorf("NodeBB topic purge requires topic id")
}
body := map[string]any{"tid": tid}
if err := c.request(http.MethodPut, "/api/v3/plugins/westgate-wiki/page/tombstone", body, nil); err != nil {
if isNodeBBMissingResource(err) {
return nil
}
return err
}
if err := c.request(http.MethodDelete, "/api/v3/plugins/westgate-wiki/page/hard-purge", body, nil); err != nil {
if isNodeBBMissingResource(err) {
return nil
}
return err
}
return nil
}
// isNodeBBWikiPageUndeletable reports whether NodeBB refused to delete the page
// outright rather than failing transiently. The wiki home topic answers this
// way: the plugin excludes it from tombstone, restore, and purge alike.
func isNodeBBWikiPageUndeletable(err error) bool {
err := c.request(http.MethodDelete, fmt.Sprintf("/api/v3/topics/%d", tid), nil, nil)
var httpErr nodeBBHTTPError
return errors.As(err, &httpErr) && httpErr.Status == http.StatusForbidden
if errors.As(err, &httpErr) && (httpErr.Status == http.StatusNotFound || httpErr.Status == http.StatusGone) {
return nil
}
return err
}
func (c *nodeBBClient) acquireEditLock(tid int) (nodeBBEditLock, error) {
+90 -281
View File
@@ -2,7 +2,6 @@ package topdata
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -36,7 +35,7 @@ func TestDeployWikiDryRunDoesNotWriteRemoteOrManifest(t *testing.T) {
}
updateCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" {
t.Fatalf("unexpected authorization header %q", got)
}
@@ -51,7 +50,7 @@ func TestDeployWikiDryRunDoesNotWriteRemoteOrManifest(t *testing.T) {
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -93,7 +92,7 @@ func TestDeployWikiReportsPlanningProgressBeforeRemoteWork(t *testing.T) {
}
progress := []string{}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
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")
@@ -101,7 +100,7 @@ func TestDeployWikiReportsPlanningProgressBeforeRemoteWork(t *testing.T) {
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
}))
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -175,7 +174,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) {
}
progress := []string{}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json")
@@ -195,7 +194,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) {
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -229,7 +228,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) {
func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *testing.T) {
requests := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
@@ -241,7 +240,7 @@ func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *te
"nextCursor": "same-cursor",
},
})
})
}))
defer server.Close()
client := newNodeBBClient(server.URL, "nodebb-token")
@@ -259,7 +258,7 @@ func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *te
func TestNodeBBNamespacePaginationRejectsRepeatedCursorWithNewPages(t *testing.T) {
requests := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.Header().Set("Content-Type", "application/json")
pages := []map[string]any{
@@ -277,7 +276,7 @@ func TestNodeBBNamespacePaginationRejectsRepeatedCursorWithNewPages(t *testing.T
"nextCursor": "same-cursor",
},
})
})
}))
defer server.Close()
client := newNodeBBClient(server.URL, "nodebb-token")
@@ -481,7 +480,7 @@ func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) {
t.Fatalf("write deploy manifest: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
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")
@@ -509,7 +508,7 @@ func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) {
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -732,7 +731,7 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
}
createCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" {
t.Fatalf("unexpected authorization header %q", got)
}
@@ -770,7 +769,7 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
},
},
})
})
}))
defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json")
@@ -849,7 +848,7 @@ func TestDeployWikiRepairsManifestedPageMissingSourceContentSync(t *testing.T) {
}
updateCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
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")
@@ -877,7 +876,7 @@ func TestDeployWikiRepairsManifestedPageMissingSourceContentSync(t *testing.T) {
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -952,7 +951,7 @@ func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitl
t.Fatalf("write source page: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
@@ -984,7 +983,7 @@ func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitl
},
},
})
})
}))
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1011,7 +1010,7 @@ func TestDeployWikiCreatesNodeBBTopicWithFallbackForTitleShorterThanConfiguredMi
t.Fatalf("write source page: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
@@ -1039,7 +1038,7 @@ func TestDeployWikiCreatesNodeBBTopicWithFallbackForTitleShorterThanConfiguredMi
},
},
})
})
}))
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1081,7 +1080,7 @@ func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(t *testing.
}
renameCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json")
@@ -1110,7 +1109,7 @@ func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(t *testing.
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1167,7 +1166,7 @@ func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) {
}
renameCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json")
@@ -1196,7 +1195,7 @@ func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) {
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1259,9 +1258,9 @@ func TestDeployWikiDoesNotRenameHeadinglessPageToPageIDFallback(t *testing.T) {
t.Fatalf("write deploy manifest: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("headingless unchanged page must not call NodeBB, got %s %s", r.Method, r.URL.String())
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1321,7 +1320,7 @@ func TestDeployWikiRenamesBrokenHeadinglessFallbackTitleBackToPageIndexTitle(t *
}
renameCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json")
@@ -1346,7 +1345,7 @@ func TestDeployWikiRenamesBrokenHeadinglessFallbackTitleBackToPageIndexTitle(t *
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1397,7 +1396,7 @@ func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t
createCalls := 0
var updated string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
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":
w.Header().Set("Content-Type", "application/json")
@@ -1443,7 +1442,7 @@ func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
}))
defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json")
@@ -1510,7 +1509,7 @@ func TestDeployWikiMergesHTMLManagedAndManualRegions(t *testing.T) {
}
var updated string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
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")
@@ -1534,7 +1533,7 @@ func TestDeployWikiMergesHTMLManagedAndManualRegions(t *testing.T) {
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1590,7 +1589,7 @@ func TestDeployWikiUpdateAcquiresWestgateWikiEditLock(t *testing.T) {
lockAcquired := false
var updateToken string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
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")
@@ -1622,7 +1621,7 @@ func TestDeployWikiUpdateAcquiresWestgateWikiEditLock(t *testing.T) {
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1669,7 +1668,7 @@ func TestDeployWikiCreateCollisionAdoptsExistingNodeBBPage(t *testing.T) {
createCalls := 0
var updated string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
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")
@@ -1720,7 +1719,7 @@ func TestDeployWikiCreateCollisionAdoptsExistingNodeBBPage(t *testing.T) {
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
}))
defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json")
@@ -1772,7 +1771,7 @@ func TestDeployWikiCreateCollisionSearchesCanonicalTitleSegment(t *testing.T) {
createCalls := 0
var updated string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
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/58/pages" && r.URL.Query().Get("q") == "":
w.Header().Set("Content-Type", "application/json")
@@ -1829,7 +1828,7 @@ func TestDeployWikiCreateCollisionSearchesCanonicalTitleSegment(t *testing.T) {
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
}))
defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json")
@@ -1869,7 +1868,7 @@ func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) {
}
var archived string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
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
@@ -1890,7 +1889,7 @@ func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) {
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}})
})
}))
defer server.Close()
report, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1946,9 +1945,9 @@ func TestDeployWikiDryRunPlansTrackedStalePagePurge(t *testing.T) {
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run purge must not call NodeBB, got %s %s", r.Method, r.URL.Path)
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1985,9 +1984,9 @@ func TestDeployWikiPurgeRefusesStaleManifestEntryWithoutTopicID(t *testing.T) {
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("missing-topic-id purge must not call NodeBB, got %s %s", r.Method, r.URL.Path)
})
}))
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2018,12 +2017,15 @@ func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) {
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
var calls []string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if !answerWikiPagePurge(t, w, r, &calls) {
purgeCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/api/v3/topics/7" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
})
purgeCalls++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2039,8 +2041,8 @@ func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) {
if result.Stale != 1 || result.Purged != 1 {
t.Fatalf("expected one stale purge, got %#v", result)
}
if strings.Join(calls, ",") != "tombstone:7,hard-purge:7" {
t.Fatalf("expected the wiki plugin page actions to purge topic 7, got %#v", calls)
if purgeCalls != 1 {
t.Fatalf("expected one NodeBB topic purge call, got %d", purgeCalls)
}
if _, ok := loadDeployManifest(manifestPath).Pages["skills:retired"]; ok {
t.Fatalf("expected purged stale manifest entry to be removed")
@@ -2073,17 +2075,18 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes
}
var calls []string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if answerWikiPagePurge(t, w, r, &calls) {
return
}
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": []any{}, "hasMore": false}})
case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/topics/7":
calls = append(calls, "purge")
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, ",") != "tombstone:7,hard-purge:7,create" {
if len(calls) != 2 || calls[0] != "purge" {
t.Fatalf("expected stale purge before create, got calls %#v", calls)
}
w.Header().Set("Content-Type", "application/json")
@@ -2099,7 +2102,7 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2117,7 +2120,7 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes
if result.Created != 1 || result.Purged != 1 {
t.Fatalf("expected one create and one purge, got %#v", result)
}
if strings.Join(calls, ",") != "tombstone:7,hard-purge:7,create" {
if strings.Join(calls, ",") != "purge,create" {
t.Fatalf("expected purge before create, got %#v", calls)
}
}
@@ -2147,12 +2150,8 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
t.Fatalf("write deploy manifest: %v", err)
}
const resetCalls = "tombstone:7,hard-purge:7,tombstone:8,hard-purge:8"
var calls []string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if answerWikiPagePurge(t, w, r, &calls) {
return
}
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")
@@ -2165,9 +2164,17 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
"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, ",") != resetCalls+",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")
@@ -2183,7 +2190,7 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2198,21 +2205,10 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
if err != nil {
t.Fatalf("DeployWikiWithOptions reset-managed-namespaces failed: %v", err)
}
if result.Created != 1 || result.Purged != 2 {
if result.Created != 1 || result.Purged != 2 || result.Stale != 0 {
t.Fatalf("expected one create and two namespace purges, got %#v", result)
}
// Callers word their destructive-policy warning around the stale count, so
// reset deletions have to show up there rather than on a separate path the
// preview never mentions.
if result.Stale != 2 || result.ResetPurged != 2 {
t.Fatalf("expected reset deletions counted as stale, got %#v", result)
}
// tid 7 is the manifest's own page; tid 8 is a topic the deployer never
// wrote, and re-seeding cannot bring it back.
if result.ResetUnrecognized != 1 {
t.Fatalf("expected one unrecognized reset deletion, got %#v", result)
}
if strings.Join(calls, ",") != resetCalls+",create" {
if strings.Join(calls, ",") != "purge:7,purge:8,create" {
t.Fatalf("expected reset purges before create, got %#v", calls)
}
manifest := loadDeployManifest(manifestPath)
@@ -2240,9 +2236,9 @@ func TestDeployWikiResetManagedNamespacesRequiresCreateForLocalPages(t *testing.
t.Fatalf("write source page: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
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{
@@ -2274,21 +2270,21 @@ func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T)
t.Fatalf("write deploy manifest: %v", err)
}
seen := map[string]int{}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
call, ok := wikiPagePurgeCall(t, r)
if !ok {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
seen[call]++
seen[r.URL.Path]++
w.Header().Set("Content-Type", "application/json")
// Topic 7 is already gone, so the plugin cannot find the page to
// tombstone; topic 8 still exists and purges normally.
if call == "tombstone:7" {
switch r.URL.Path {
case "/api/v3/topics/7":
http.Error(w, `{"status":{"message":"topic not found"}}`, http.StatusNotFound)
return
case "/api/v3/topics/8":
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
default:
t.Fatalf("unexpected purge path %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2304,11 +2300,8 @@ func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T)
if result.Stale != 2 || result.Purged != 2 {
t.Fatalf("expected two stale purges, got %#v", result)
}
if seen["tombstone:7"] != 1 || seen["hard-purge:7"] != 0 {
t.Fatalf("expected the already-missing topic to stop after tombstone, got %#v", seen)
}
if seen["tombstone:8"] != 1 || seen["hard-purge:8"] != 1 {
t.Fatalf("expected the surviving topic to be tombstoned then purged, got %#v", seen)
if seen["/api/v3/topics/7"] != 1 || seen["/api/v3/topics/8"] != 1 {
t.Fatalf("expected one purge call for each tracked topic, got %#v", seen)
}
manifest := loadDeployManifest(manifestPath)
if _, ok := manifest.Pages["skills:retired"]; ok {
@@ -2346,9 +2339,9 @@ func TestDeployWikiPurgeDoesNotTargetCurrentGeneratedPages(t *testing.T) {
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("current generated page must not call NodeBB during matching-hash deploy, got %s %s", r.Method, r.URL.Path)
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2392,187 +2385,3 @@ func respondWikiEditLock(t *testing.T, w http.ResponseWriter, r *http.Request, e
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"status": "ok", "tid": expectedTID, "token": token}})
}
// newFakeNodeBB stands up a fake NodeBB that behaves like one running
// nodebb-plugin-westgate-wiki: native topic mutation on wiki categories is
// refused, because wiki revision history is plugin-owned. Every deploy test
// goes through here, so a deployer that reaches for the core topic API fails in
// CI the way it fails in production rather than passing against a fake that is
// more permissive than the real thing.
func newFakeNodeBB(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/v3/topics/") && (r.Method == http.MethodDelete || r.Method == http.MethodPut) {
http.Error(
w,
`{"status":{"code":"bad-request","message":"Use the wiki page actions to delete, restore, or purge wiki pages."}}`,
http.StatusBadRequest,
)
return
}
handler(w, r)
}))
}
// wikiPagePurgeCall recognizes the plugin page actions a purge goes through and
// reports them as "<action>:<tid>" so tests can assert the order.
func wikiPagePurgeCall(t *testing.T, r *http.Request) (string, bool) {
t.Helper()
var action string
switch {
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/tombstone":
action = "tombstone"
case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/hard-purge":
action = "hard-purge"
default:
return "", false
}
var req struct {
TID int `json:"tid"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode wiki %s request: %v", action, err)
}
if req.TID == 0 {
t.Fatalf("expected wiki %s request to carry a topic id", action)
}
return fmt.Sprintf("%s:%d", action, req.TID), true
}
// answerWikiPagePurge records and acknowledges a plugin purge action, which is
// what most fakes want to do with one.
func answerWikiPagePurge(t *testing.T, w http.ResponseWriter, r *http.Request, calls *[]string) bool {
t.Helper()
call, ok := wikiPagePurgeCall(t, r)
if !ok {
return false
}
*calls = append(*calls, call)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
return true
}
// resetPurgeFixture builds a source tree, a deploy manifest, and the reset
// options the namespace-reset tests share.
func resetPurgeFixture(t *testing.T, namespaces map[string]int, tidsByCID map[int][]int) (DeployWikiOptions, func(w http.ResponseWriter, r *http.Request) bool) {
t.Helper()
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)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
listPages := func(w http.ResponseWriter, r *http.Request) bool {
if r.Method != http.MethodGet {
return false
}
var cid int
if _, err := fmt.Sscanf(r.URL.Path, "/api/v3/plugins/westgate-wiki/namespace/%d/pages", &cid); err != nil {
return false
}
tids, ok := tidsByCID[cid]
if !ok {
t.Fatalf("unexpected namespace listing for category %d", cid)
}
pages := make([]map[string]any, 0, len(tids))
for _, tid := range tids {
pages = append(pages, map[string]any{"tid": tid, "title": fmt.Sprintf("Page %d", tid)})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": pages, "hasMore": false}})
return true
}
return DeployWikiOptions{
SourceDir: sourceDir,
ManifestPath: manifestPath,
Token: "nodebb-token",
CategoryIDs: namespaces,
AllowCreates: true,
ResetManagedNamespaces: true,
}, listPages
}
func TestDeployWikiResetSkipsTopicsNodeBBRefusesToDelete(t *testing.T) {
opts, listPages := resetPurgeFixture(t, map[string]int{"skills": 3}, map[int][]int{3: {5, 6}})
var calls []string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if listPages(w, r) {
return
}
call, ok := wikiPagePurgeCall(t, r)
if !ok {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
calls = append(calls, call)
// Topic 5 stands in for the wiki home page, which the plugin excludes
// from tombstone, restore, and purge alike.
if call == "tombstone:5" {
http.Error(w, `{"status":{"message":"[[error:no-privileges]]"}}`, http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
})
defer server.Close()
opts.Endpoint = server.URL
result, err := DeployWikiWithOptions(&project.Project{}, opts, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions reset with an undeletable topic failed: %v", err)
}
if result.ResetSkipped != 1 || result.ResetPurged != 1 {
t.Fatalf("expected one skipped and one completed reset deletion, got %#v", result)
}
if result.Purged != 1 || result.Stale != 1 {
t.Fatalf("expected the skipped topic to be dropped from the counts, got %#v", result)
}
if result.ResetUnrecognized != 1 {
t.Fatalf("expected only the purged unrecognized topic to be counted, got %#v", result)
}
if strings.Join(calls, ",") != "tombstone:5,tombstone:6,hard-purge:6" {
t.Fatalf("expected the reset to continue past the refused topic, got %#v", calls)
}
}
// NodeBB answers 403 both for the wiki home page and for a token without purge
// privileges. A category where nothing could be deleted at all is the second
// case, and reporting it as a completed reset would leave the manifest claiming
// a fresh start over pages that are all still there.
func TestDeployWikiResetFailsWhenACategoryRefusesEveryDeletion(t *testing.T) {
opts, listPages := resetPurgeFixture(t,
map[string]int{"skills": 3, "feats": 4},
map[int][]int{3: {5, 6}, 4: {7, 8}},
)
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if listPages(w, r) {
return
}
call, ok := wikiPagePurgeCall(t, r)
if !ok {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
// Category 4 holds topics 7 and 8, and the token can purge neither.
if strings.HasSuffix(call, ":7") || strings.HasSuffix(call, ":8") {
http.Error(w, `{"status":{"message":"[[error:no-privileges]]"}}`, http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
})
defer server.Close()
opts.Endpoint = server.URL
_, err := DeployWikiWithOptions(&project.Project{}, opts, nil)
if err == nil || !strings.Contains(err.Error(), "wiki purge privileges") {
t.Fatalf("expected a category that deleted nothing to fail loudly, got %v", err)
}
if !strings.Contains(err.Error(), "4") {
t.Fatalf("expected the failure to name the refusing category, got %v", err)
}
}