Skip to content

Commit bba5051

Browse files
shobmanclaude
andcommitted
Skip unchanged pages using content hash stored as page property
Compute SHA-256 of the final storage format body (prefixed with a connector version constant). Store the hash as a Confluence page property (aidos-content-hash). On subsequent runs, compare hashes before updating — skip if identical. Bump CONNECTOR_VERSION to force-republish all pages after connector changes that alter output without changing source content. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3d0bce1 commit bba5051

1 file changed

Lines changed: 87 additions & 2 deletions

File tree

src/connectors/confluence/publish.js

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env node
22

3+
import { createHash } from "node:crypto";
34
import { readFile, readdir } from "node:fs/promises";
45
import { dirname, join, resolve } from "node:path";
56
import { marked } from "marked";
@@ -42,6 +43,19 @@ const STORIES_SECTION = `<h2>Stories</h2>
4243

4344
const CHILDREN_MACRO = `\n<ac:structured-macro ac:name="children" />`;
4445

46+
// Bump this to force republish of all pages (e.g. after a connector change
47+
// that alters output but not source content — label changes, macro format, etc.)
48+
const CONNECTOR_VERSION = "1";
49+
50+
const HASH_PROPERTY_KEY = "aidos-content-hash";
51+
52+
function contentHash(body) {
53+
return createHash("sha256")
54+
.update(CONNECTOR_VERSION + body)
55+
.digest("hex")
56+
.slice(0, 16);
57+
}
58+
4559
// Scale labels: epic (root files), feature (folder pages), story (files inside features)
4660
// Passed explicitly through the recursion — not derived from depth.
4761

@@ -177,6 +191,65 @@ async function addLabels(baseUrl, pageId, labels) {
177191
);
178192
}
179193

194+
/** Read the stored content hash from a page property. Returns null if not set. */
195+
async function getStoredHash(baseUrl, pageId) {
196+
try {
197+
const data = await confluenceFetch(
198+
`${baseUrl}/wiki/rest/api/content/${pageId}/property/${HASH_PROPERTY_KEY}`,
199+
{},
200+
`getHash ${pageId}`,
201+
);
202+
return data.value?.hash ?? null;
203+
} catch {
204+
return null;
205+
}
206+
}
207+
208+
/** Write the content hash as a page property. Creates or updates. */
209+
async function setStoredHash(baseUrl, pageId, hash) {
210+
// Try to read existing property for its version number
211+
let version = null;
212+
try {
213+
const data = await confluenceFetch(
214+
`${baseUrl}/wiki/rest/api/content/${pageId}/property/${HASH_PROPERTY_KEY}`,
215+
{},
216+
`getHash ${pageId}`,
217+
);
218+
version = data.version?.number;
219+
} catch {
220+
// Property doesn't exist yet
221+
}
222+
223+
if (version) {
224+
// Update existing property
225+
await confluenceFetch(
226+
`${baseUrl}/wiki/rest/api/content/${pageId}/property/${HASH_PROPERTY_KEY}`,
227+
{
228+
method: "PUT",
229+
body: JSON.stringify({
230+
key: HASH_PROPERTY_KEY,
231+
value: { hash },
232+
version: { number: version + 1 },
233+
}),
234+
},
235+
`setHash ${pageId}`,
236+
);
237+
} else {
238+
// Create new property
239+
await confluenceFetch(
240+
`${baseUrl}/wiki/rest/api/content/${pageId}/property`,
241+
{
242+
method: "POST",
243+
body: JSON.stringify({
244+
key: HASH_PROPERTY_KEY,
245+
value: { hash },
246+
}),
247+
},
248+
`setHash ${pageId}`,
249+
);
250+
}
251+
}
252+
180253
// ---------------------------------------------------------------------------
181254
// Markdown parsing
182255
// ---------------------------------------------------------------------------
@@ -357,6 +430,7 @@ function buildFeaturePageBody(markdown, meta) {
357430

358431
async function publishPage(ctx, parentId, childPages, title, body, labels) {
359432
const { baseUrl, spaceKey, dryRun } = ctx;
433+
const hash = contentHash(body);
360434

361435
if (dryRun) {
362436
const existing = childPages.get(title);
@@ -371,9 +445,18 @@ async function publishPage(ctx, parentId, childPages, title, body, labels) {
371445
}
372446

373447
if (pageId) {
448+
// Check if content has changed via stored hash
449+
const storedHash = await getStoredHash(baseUrl, pageId);
450+
if (storedHash === hash) {
451+
console.log(" Unchanged: %s (page %s)", title, pageId);
452+
ctx.stats.unchanged++;
453+
return pageId;
454+
}
455+
374456
const page = await getPage(baseUrl, pageId);
375457
await updatePage(baseUrl, pageId, title, body, page.version);
376458
await addLabels(baseUrl, pageId, labels);
459+
await setStoredHash(baseUrl, pageId, hash);
377460
console.log(" Updated: %s (page %s, v%d → v%d)", title, pageId, page.version, page.version + 1);
378461
ctx.stats.updated++;
379462
return pageId;
@@ -384,6 +467,7 @@ async function publishPage(ctx, parentId, childPages, title, body, labels) {
384467
const created = await createPage(baseUrl, spaceKey, parentId, title, "<p></p>");
385468
await updatePage(baseUrl, created.id, title, body, 1);
386469
await addLabels(baseUrl, created.id, labels);
470+
await setStoredHash(baseUrl, created.id, hash);
387471
console.log(" Created: %s (page %s)", title, created.id);
388472
ctx.stats.created++;
389473
return created.id;
@@ -605,7 +689,7 @@ async function main() {
605689
spaceKey: null,
606690
rootPageId,
607691
dryRun,
608-
stats: { created: 0, updated: 0 },
692+
stats: { created: 0, updated: 0, unchanged: 0 },
609693
};
610694

611695
// Ensure dashboard and derive space key
@@ -615,9 +699,10 @@ async function main() {
615699
await publishDirectory(ctx, rootPageId, aidosDir, 0, null, "epic");
616700

617701
console.log(
618-
"\nDone — created: %d, updated: %d",
702+
"\nDone — created: %d, updated: %d, unchanged: %d",
619703
ctx.stats.created,
620704
ctx.stats.updated,
705+
ctx.stats.unchanged,
621706
);
622707
}
623708

0 commit comments

Comments
 (0)