Skip to content

Commit 0b619b6

Browse files
authored
Merge pull request #10 from shobman/feature/confluence-duplicate-title-handling
fix(confluence): resilient duplicate-title handling with suffix retry (v1.0.4)
2 parents c7540b3 + 4ab77a9 commit 0b619b6

6 files changed

Lines changed: 320 additions & 13 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.0.3
1+
1.0.4

src/connectors/confluence/README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,14 @@ If your Confluence space has existing pages with common names, add a `titleTempl
171171
| `(CW) %title%` | (CW) Problem |
172172
| `CW.Core - %title%` | CW.Core - Problem |
173173

174+
### Duplicate titles
175+
176+
Confluence requires page titles to be **unique within a space**. If an AIDOS artifact's derived title collides with an existing page elsewhere in the target space (not under the AIDOS root), the connector renames the new page by appending a numeric suffix: `Title`, then `Title (1)`, then `Title (2)`.
177+
178+
The connector caps this at **3 total attempts** and hard-fails when all attempts are exhausted. Each rename logs two `WARNING:` lines in the publish output — one naming the collision, one explaining how to resolve it. If you see these warnings, rename the source file (or the conflicting Confluence pages) so titles are unique — the cap exists specifically to force the issue to be resolved rather than silently accumulating duplicates.
179+
180+
On re-publish the connector checks the same suffix ladder, so previously-renamed pages are updated in place rather than creating a new copy each run.
181+
174182
### Multiple publish targets
175183

176184
To add more targets later, add another key under `publish`:
@@ -222,7 +230,7 @@ cd aidos/src/connectors/confluence
222230
npm install
223231
```
224232

225-
Only one dependency (`marked` for markdown parsing). No test suite — the connector has been production-tested by running against real Confluence spaces.
233+
Only one dependency (`marked` for markdown parsing). Run `npm test` from this directory to execute the unit test suite (Node's built-in `node --test` runner, no external deps).
226234

227235
### Local dry-run
228236

@@ -258,12 +266,14 @@ Use this only when you need to debug publish behaviour outside CI — the canoni
258266
```
259267
src/connectors/confluence/
260268
├── publish.js ← Single entry point — walks .aidos/, converts markdown, publishes
269+
├── title-conflict.js ← Pure helpers for duplicate-title detection and suffix resolution
270+
├── title-conflict.test.js ← Unit tests for title-conflict helpers (node --test)
261271
├── manifest.schema.json ← JSON Schema for .aidos/manifest.json
262272
├── package.json ← { "type": "module", dependencies: { "marked": "..." } }
263273
└── README.md ← This file
264274
```
265275
266-
The script is ~700 lines, self-contained, ESM, uses Node 20+ built-in fetch. Follow the structure (constants → auth → API helpers → markdown transforms → publish logic → main).
276+
The connector is ESM, uses Node 20+ built-in fetch, and has two modules: `publish.js` (the orchestration script) and `title-conflict.js` (pure helpers for duplicate-title handling). Follow the structure (constants → auth → API helpers → markdown transforms → publish logic → main).
267277
268278
### Versioning
269279

src/connectors/confluence/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
{
22
"name": "aidos-confluence-connector",
3-
"version": "1.0.3",
3+
"version": "1.0.4",
44
"private": true,
55
"type": "module",
6+
"scripts": {
7+
"test": "node --test"
8+
},
69
"dependencies": {
710
"marked": "^15.0.0"
811
}

src/connectors/confluence/publish.js

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { createHash } from "node:crypto";
44
import { readFile, readdir } from "node:fs/promises";
55
import { dirname, join, resolve } from "node:path";
66
import { marked } from "marked";
7+
import {
8+
createWithRetryOnDuplicate,
9+
findOwnedPageByTitle,
10+
} from "./title-conflict.js";
711

812
// ---------------------------------------------------------------------------
913
// Constants
@@ -506,38 +510,66 @@ async function publishPage(ctx, parentId, childPages, title, body, labels) {
506510
return existing ?? `dry-run-${title}`;
507511
}
508512

509-
// Check children of parent first, then search descendants of root as fallback
513+
// Check direct children first (fast path), then search descendants of root
514+
// with suffix tolerance so re-publishes find previously-renamed pages.
510515
let pageId = childPages.get(title);
516+
let actualTitle = title;
511517
if (!pageId) {
512-
const found = await findPageByTitle(baseUrl, ctx.rootPageId, title);
513-
if (found) pageId = found.id;
518+
const found = await findOwnedPageByTitle(title, (candidate) =>
519+
findPageByTitle(baseUrl, ctx.rootPageId, candidate),
520+
);
521+
if (found) {
522+
pageId = found.id;
523+
actualTitle = found.title;
524+
}
514525
}
515526

516527
if (pageId) {
517528
// Check if content has changed via stored hash
518529
const storedHash = await getStoredHash(baseUrl, pageId);
519530
if (storedHash === hash) {
520-
console.log(" Unchanged: %s (page %s)", title, pageId);
531+
console.log(" Unchanged: %s (page %s)", actualTitle, pageId);
521532
ctx.stats.unchanged++;
522533
return pageId;
523534
}
524535

525536
const page = await getPage(baseUrl, pageId);
526-
await updatePage(baseUrl, pageId, title, body, page.version);
537+
await updatePage(baseUrl, pageId, actualTitle, body, page.version);
527538
await addLabels(baseUrl, pageId, labels);
528539
await setStoredHash(baseUrl, pageId, hash);
529-
console.log(" Updated: %s (page %s, v%d → v%d)", title, pageId, page.version, page.version + 1);
540+
console.log(
541+
" Updated: %s (page %s, v%d → v%d)",
542+
actualTitle,
543+
pageId,
544+
page.version,
545+
page.version + 1,
546+
);
530547
ctx.stats.updated++;
531548
return pageId;
532549
}
533550

534551
// Two-step create: minimal body first (avoids Fabric editor macro
535552
// restrictions), then immediately update with full storage format.
536-
const created = await createPage(baseUrl, spaceKey, parentId, title, "<p></p>");
537-
await updatePage(baseUrl, created.id, title, body, 1);
553+
// Confluence requires per-space title uniqueness — retry with numeric
554+
// suffixes on duplicate-title 400s (capped in createWithRetryOnDuplicate).
555+
const { result: created, title: createdTitle, renamed } =
556+
await createWithRetryOnDuplicate(title, (candidate) =>
557+
createPage(baseUrl, spaceKey, parentId, candidate, "<p></p>"),
558+
);
559+
if (renamed) {
560+
console.log(
561+
'WARNING: A page titled "%s" already exists elsewhere in this Confluence space. Created as "%s" instead.',
562+
title,
563+
createdTitle,
564+
);
565+
console.log(
566+
"WARNING: Rename the source file to give it a unique Confluence page title — Confluence requires page titles to be unique within a space.",
567+
);
568+
}
569+
await updatePage(baseUrl, created.id, createdTitle, body, 1);
538570
await addLabels(baseUrl, created.id, labels);
539571
await setStoredHash(baseUrl, created.id, hash);
540-
console.log(" Created: %s (page %s)", title, created.id);
572+
console.log(" Created: %s (page %s)", createdTitle, created.id);
541573
ctx.stats.created++;
542574
return created.id;
543575
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Helpers for handling Confluence per-space title uniqueness.
2+
//
3+
// Confluence requires page titles to be unique within a space. When an AIDOS
4+
// artifact's derived title collides with an existing page elsewhere in the
5+
// space, we rename the new page with a numeric suffix — e.g. "Issues Log (1)".
6+
// The cap below hard-fails after this many attempts to force duplicates to be
7+
// resolved at the source rather than accumulating silently.
8+
9+
export const MAX_TITLE_ATTEMPTS = 3;
10+
11+
/**
12+
* Build the title to try on attempt N of a create loop.
13+
* Attempt 0 is the plain title; attempts 1+ append " (N)".
14+
*/
15+
export function suffixedTitle(baseTitle, attempt) {
16+
return attempt === 0 ? baseTitle : `${baseTitle} (${attempt})`;
17+
}
18+
19+
/**
20+
* Detect the Confluence "duplicate title in space" 400 error by matching the
21+
* two known server phrasings. Safe on non-Error inputs.
22+
*/
23+
export function isDuplicateTitleError(err) {
24+
if (!err || typeof err !== "object" || typeof err.message !== "string") {
25+
return false;
26+
}
27+
const msg = err.message.toLowerCase();
28+
return (
29+
msg.includes("a page with this title already exists") ||
30+
msg.includes("a page already exists with the same title")
31+
);
32+
}
33+
34+
/**
35+
* Attempt to create a page, retrying with numeric suffixes when Confluence
36+
* rejects the title as a duplicate within the space. Caps at MAX_TITLE_ATTEMPTS
37+
* total attempts — the last failure becomes a hard error that tells the user
38+
* to rename the source file, so duplicates are resolved at the source rather
39+
* than accumulating.
40+
*
41+
* @param {string} baseTitle The desired title.
42+
* @param {(title: string) => Promise<object>} doCreate Caller's create impl.
43+
* @returns {Promise<{ result: object, title: string, renamed: boolean }>}
44+
*/
45+
export async function createWithRetryOnDuplicate(baseTitle, doCreate) {
46+
for (let attempt = 0; attempt < MAX_TITLE_ATTEMPTS; attempt++) {
47+
const title = suffixedTitle(baseTitle, attempt);
48+
try {
49+
const result = await doCreate(title);
50+
return { result, title, renamed: attempt > 0 };
51+
} catch (err) {
52+
if (!isDuplicateTitleError(err)) throw err;
53+
if (attempt === MAX_TITLE_ATTEMPTS - 1) {
54+
throw new Error(
55+
`Cannot publish "${baseTitle}" to Confluence: the titles "${baseTitle}", "${baseTitle} (1)", and "${baseTitle} (2)" are all taken in the target space. ` +
56+
`Confluence requires page titles to be unique within a space. ` +
57+
`Rename the source file (or the conflicting Confluence pages) and re-run the publish.`,
58+
);
59+
}
60+
}
61+
}
62+
}
63+
64+
/**
65+
* Try to find a page the connector previously created, tolerant of numeric
66+
* suffix renames. Walks the same ladder as createWithRetryOnDuplicate — plain,
67+
* (1), (2), up to MAX_TITLE_ATTEMPTS — and returns the first hit.
68+
*
69+
* @param {string} baseTitle
70+
* @param {(title: string) => Promise<{id: string, title: string} | null>} doFind
71+
* @returns {Promise<{id: string, title: string} | null>}
72+
*/
73+
export async function findOwnedPageByTitle(baseTitle, doFind) {
74+
for (let attempt = 0; attempt < MAX_TITLE_ATTEMPTS; attempt++) {
75+
const title = suffixedTitle(baseTitle, attempt);
76+
const page = await doFind(title);
77+
if (page) return page;
78+
}
79+
return null;
80+
}

0 commit comments

Comments
 (0)