]*\bac:ref\s*=\s*(["'])(.*?)\1/gi)].map((match) => match[2]));
}
const SERVER_STORAGE_ATTRIBUTES = new Set(["local-id", "data-local-id", "ac:local-id", "data-macro-id", "ac:macro-id"]);
function normalizeStorage(storage) {
const tokens = [];
for (const match of String(storage).matchAll(/<[^>]*>|[^<]+/g)) {
const token = match[0];
if (!token.startsWith("<")) {
const text = decodeEntities(token).replaceAll("\r\n", "\n");
if (text.trim()) tokens.push(`#${text}`);
continue;
}
const closing = token.match(/^<\s*\/\s*([^\s>]+)/);
if (closing) {
tokens.push(`${closing[1].toLowerCase()}>`);
continue;
}
const opening = token.match(/^<\s*([^\s/>]+)([\s\S]*?)(\/?)\s*>$/);
if (!opening) {
tokens.push(token);
continue;
}
const attributes = [...opening[2].matchAll(/([^\s=/>]+)\s*=\s*(["'])(.*?)\2/g)]
.map((attribute) => [attribute[1].toLowerCase(), decodeEntities(attribute[3])])
.filter(([name]) => !SERVER_STORAGE_ATTRIBUTES.has(name))
.sort(([leftName, leftValue], [rightName, rightValue]) => leftName.localeCompare(rightName) || leftValue.localeCompare(rightValue));
tokens.push(`<${opening[1].toLowerCase()} ${JSON.stringify(attributes)}${opening[3] ? "/" : ""}>`);
}
return tokens;
}
export function assertEquivalentStorage(expected, actual) {
const expectedNormalized = normalizeStorage(expected);
const actualNormalized = normalizeStorage(actual);
if (JSON.stringify(expectedNormalized) !== JSON.stringify(actualNormalized)) {
const error = new Error("Stored page differs from the planned semantic structure");
error.expectedNormalized = expectedNormalized;
error.actualNormalized = actualNormalized;
throw error;
}
}
export function applyReplacements(storage, replacements) {
let result = storage;
const markersBefore = extractMarkerRefs(storage);
for (const replacement of replacements) {
const expectedCount = replacement.count ?? 1;
if (!replacement.from) throw new Error("Replacement from must be non-empty");
if (!Number.isSafeInteger(expectedCount) || expectedCount < 1) throw new Error("Replacement count must be a positive integer");
const actualCount = result.split(replacement.from).length - 1;
if (actualCount !== expectedCount) {
throw new Error(`Replacement expected ${expectedCount} occurrence(s), found ${actualCount}: ${replacement.from}`);
}
result = result.split(replacement.from).join(replacement.to);
}
const markersAfter = extractMarkerRefs(result);
if (JSON.stringify(markersBefore) !== JSON.stringify(markersAfter)) throw new Error("Page replacement would remove or change inline-comment markers");
return result;
}
export function validatePlan(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Plan must be an object");
const pageId = String(value.pageId ?? "");
if (!/^\d+$/.test(pageId)) throw new Error("Plan pageId must be numeric");
if (!Number.isSafeInteger(value.expectedVersion) || value.expectedVersion < 1) throw new Error("Plan expectedVersion must be a positive integer");
if (!Array.isArray(value.operations) || !value.operations.length) throw new Error("Plan operations must be non-empty");
let pageOperationSeen = false;
const operations = value.operations.map((operation, index) => {
if (operation?.kind === "page") {
pageOperationSeen = true;
if (!operation.versionComment) throw new Error(`Page operation ${index} lacks versionComment`);
if (!Array.isArray(operation.replacements) || !operation.replacements.length) throw new Error(`Page operation ${index} lacks replacements`);
for (const replacement of operation.replacements) {
if (typeof replacement.from !== "string" || typeof replacement.to !== "string") throw new Error(`Page operation ${index} has an invalid replacement`);
if (replacement.count !== undefined && (!Number.isSafeInteger(replacement.count) || replacement.count < 1)) throw new Error(`Page operation ${index} has an invalid replacement count`);
}
const contains = operation.expect?.contains ?? [];
const notContains = operation.expect?.notContains ?? [];
if (!Array.isArray(contains) || contains.some((entry) => typeof entry !== "string")) throw new Error(`Page operation ${index} has invalid expect.contains`);
if (!Array.isArray(notContains) || notContains.some((entry) => typeof entry !== "string")) throw new Error(`Page operation ${index} has invalid expect.notContains`);
if (!contains.length && !notContains.length) throw new Error(`Page operation ${index} requires at least one expectation`);
return {
kind: "page",
versionComment: operation.versionComment,
replacements: operation.replacements.map((replacement) => ({ ...replacement })),
expect: { contains, notContains },
};
}
if (operation?.kind === "inline-comment") {
if (pageOperationSeen) throw new Error("Inline-comment operations must precede every page operation");
if (typeof operation.target !== "string" || !operation.target || typeof operation.text !== "string" || !operation.text) throw new Error(`Inline-comment operation ${index} requires target and text`);
const matchCount = operation.matchCount ?? 1;
const matchIndex = operation.matchIndex ?? 0;
if (!Number.isSafeInteger(matchCount) || matchCount < 1) throw new Error(`Inline-comment operation ${index} has invalid matchCount`);
if (!Number.isSafeInteger(matchIndex) || matchIndex < 0 || matchIndex >= matchCount) throw new Error(`Inline-comment operation ${index} has invalid matchIndex`);
return { kind: "inline-comment", target: operation.target, text: operation.text, matchCount, matchIndex };
}
throw new Error(`Operation ${index} has unknown kind`);
});
return { pageId, expectedVersion: value.expectedVersion, operations };
}
export function readPlan(planFile) {
const source = readFileSync(planFile, "utf8");
return { source, sha256: hashText(source), plan: validatePlan(JSON.parse(source)) };
}
function collectAdfTexts(value, texts = []) {
if (Array.isArray(value)) {
for (const child of value) collectAdfTexts(child, texts);
} else if (value && typeof value === "object") {
if (value.type === "text" && typeof value.text === "string") texts.push(value.text);
for (const child of Object.values(value)) collectAdfTexts(child, texts);
}
return texts;
}
const ADF_TEXT_BLOCKS = new Set(["paragraph", "heading", "codeBlock"]);
function collectAdfBlocks(value, blocks = []) {
if (Array.isArray(value)) {
for (const child of value) collectAdfBlocks(child, blocks);
} else if (value && typeof value === "object") {
if (ADF_TEXT_BLOCKS.has(value.type)) blocks.push(collectAdfTexts(value).join(""));
else for (const child of value.content ?? []) collectAdfBlocks(child, blocks);
}
return blocks;
}
function textSelectionCount(adf, target) {
const blocks = collectAdfBlocks(adf);
const renderedBlocks = blocks.length ? blocks : [collectAdfTexts(adf).join("")];
return renderedBlocks.reduce((count, text) => count + text.split(target).length - 1, 0);
}
function escapeHtml(value) {
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
}
function assertExpectation(storage, expect) {
const text = plainTextFromHtml(storage);
for (const expected of expect.contains) {
if (!text.includes(normalizeWhitespace(expected))) throw new Error(`Validated page lacks expected text: ${expected}`);
}
for (const forbidden of expect.notContains) {
if (text.includes(normalizeWhitespace(forbidden))) throw new Error(`Validated page still contains forbidden text: ${forbidden}`);
}
}
async function requireVersion(client, pageId, expectedVersion, representation = "storage") {
const page = await client.get(`wiki/api/v2/pages/${pageId}?body-format=${representation}`);
if (page.version?.number !== expectedVersion) {
throw new Error(`Page version drift: expected ${expectedVersion}, found ${page.version?.number}`);
}
return page;
}
async function createInlineComment(client, pageId, expectedVersion, operation) {
const adfPage = await requireVersion(client, pageId, expectedVersion, "atlas_doc_format");
const adf = JSON.parse(pageBody(adfPage, "atlas_doc_format"));
const count = textSelectionCount(adf, operation.target);
if (count !== operation.matchCount) {
throw new Error(`Inline-comment target expected ${operation.matchCount} occurrence(s), found ${count}: ${operation.target}`);
}
const created = await client.mutate("POST", "wiki/api/v2/inline-comments", {
pageId,
body: { representation: "storage", value: `${escapeHtml(operation.text)}
` },
inlineCommentProperties: {
textSelection: operation.target,
textSelectionMatchCount: operation.matchCount,
textSelectionMatchIndex: operation.matchIndex,
},
});
const verified = await client.get(`wiki/api/v2/inline-comments/${created.id}?body-format=storage&include-properties=true&include-version=true`);
const selection = verified.properties?.inlineOriginalSelection ?? verified.properties?.["inline-original-selection"];
const markerRef = verified.properties?.inlineMarkerRef ?? verified.properties?.["inline-marker-ref"];
if (String(verified.pageId) !== pageId || verified.resolutionStatus !== "open") throw new Error(`Inline comment ${created.id} has unexpected page or status`);
if (plainTextFromHtml(verified.body?.storage?.value ?? "") !== normalizeWhitespace(operation.text)) throw new Error(`Inline comment ${created.id} body validation failed`);
if (selection !== operation.target || !markerRef) throw new Error(`Inline comment ${created.id} selection validation failed`);
const storagePage = await requireVersion(client, pageId, expectedVersion, "storage");
if (!extractMarkerRefs(pageBody(storagePage, "storage")).includes(markerRef)) throw new Error(`Inline comment ${created.id} marker is not visible in page storage`);
const open = flattenResults(await client.get(`wiki/api/v2/pages/${pageId}/inline-comments?resolution-status=open&body-format=storage&limit=100`, { paginate: true }));
if (!open.some((comment) => String(comment.id) === String(created.id))) throw new Error(`Inline comment ${created.id} is absent from the open-comment list`);
return { id: String(created.id), target: operation.target, markerRef, validation: "passed" };
}
export async function applyPlan(plan, client, baseUrl) {
let version = plan.expectedVersion;
const receipt = {
pageId: plan.pageId,
initialVersion: version,
operations: [],
inlineComments: [],
validation: "pending",
};
try {
let current = await requireVersion(client, plan.pageId, version, "storage");
receipt.url = pageUrl(current, baseUrl);
for (const operation of plan.operations) {
current = await requireVersion(client, plan.pageId, version, "storage");
if (operation.kind === "inline-comment") {
const comment = await createInlineComment(client, plan.pageId, version, operation);
receipt.inlineComments.push(comment);
receipt.operations.push({ kind: operation.kind, id: comment.id, validation: "passed" });
continue;
}
if (!current.spaceId) throw new Error(`Page ${plan.pageId} lacks spaceId required for update`);
const before = pageBody(current, "storage");
const intended = applyReplacements(before, operation.replacements);
await client.mutate("PUT", `wiki/api/v2/pages/${plan.pageId}`, {
id: plan.pageId,
status: current.status ?? "current",
title: current.title,
spaceId: current.spaceId,
body: { representation: "storage", value: intended },
version: { number: version + 1, message: operation.versionComment },
});
const verified = await requireVersion(client, plan.pageId, version + 1, "storage");
const actual = pageBody(verified, "storage");
if (verified.version?.message !== operation.versionComment) throw new Error(`Version ${version + 1} has unexpected version comment`);
assertEquivalentStorage(intended, actual);
assertExpectation(actual, operation.expect);
version += 1;
receipt.operations.push({ kind: operation.kind, version, versionComment: operation.versionComment, validation: "passed" });
}
const finalPage = await requireVersion(client, plan.pageId, version, "storage");
const finalStorage = pageBody(finalPage, "storage");
receipt.finalVersion = version;
receipt.versionComment = finalPage.version?.message ?? "";
receipt.header = parseHeaderHtml(finalStorage);
receipt.validation = "passed";
return receipt;
} catch (error) {
receipt.finalVersion = version;
receipt.validation = "failed";
error.receipt = receipt;
throw error;
}
}
export function createAtlassianClient({ profile, helper = REQUEST_HELPER, spawn = spawnSync } = {}) {
const run = (requestArgs, body) => {
let dir;
try {
const args = [helper, ...requestArgs];
if (body !== undefined) {
dir = mkdtempSync(path.join(tmpdir(), "ims-request-"));
const bodyFile = path.join(dir, "body.json");
writeFileSync(bodyFile, JSON.stringify(body));
args.push("--body", bodyFile, "--allow-mutation");
}
if (profile) args.push("--profile", profile);
const result = spawn(process.execPath, args, { encoding: "utf8", windowsHide: true, maxBuffer: 50 * 1024 * 1024 });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(result.stderr.trim() || `Atlassian request exited ${result.status}`);
return result.stdout.trim() ? JSON.parse(result.stdout) : null;
} finally {
if (dir) rmSync(dir, { recursive: true, force: true });
}
};
return {
get(requestPath, options = {}) {
return run([requestPath, ...(options.paginate ? ["--paginate"] : [])]);
},
mutate(method, requestPath, body) {
return run([method, requestPath], body);
},
};
}
function hashText(value) {
return createHash("sha256").update(value).digest("hex");
}
function sortedUnique(values) {
return [...new Set(values.filter((value) => value !== undefined && value !== ""))].sort();
}
function usage() {
return `Usage: node scripts/ims.mjs COMMAND [options]
Commands:
policy [--profile NAME]
worklist --mode owner|reviewer [--person NAME] [--account-id ID] [--area all|RD|isp|GA|CS|SM] [--today YYYY-MM-DD] [--profile NAME]
snapshot PAGE_ID [--profile NAME]
check-plan PLAN.json
apply PLAN.json --expected-version N --plan-sha256 HASH --allow-mutation [--profile NAME]
check-plan and every command except apply are read-only. apply accepts this plan shape:
{
"pageId": "123",
"expectedVersion": 7,
"operations": [
{
"kind": "inline-comment",
"target": "exact rendered selection",
"text": "Comment text",
"matchCount": 1,
"matchIndex": 0
},
{
"kind": "page",
"versionComment": "zurück in Entwurf, da Korrektur notwendig",
"replacements": [{ "from": "exact storage fragment", "to": "replacement", "count": 1 }],
"expect": { "contains": ["expected rendered text"], "notContains": ["removed rendered text"] }
}
]
}
Run check-plan after plan approval. Run apply only after a separate explicit go and pass the unchanged hash.`;
}
async function main() {
const options = parseCli(process.argv.slice(2));
if (options.help) {
console.log(usage());
return;
}
const baseline = parseBaseline(readFileSync(BASELINE_FILE, "utf8"));
if (options.command === "check-plan") {
const loaded = readPlan(options.positionals[0]);
console.log(JSON.stringify({
pageId: loaded.plan.pageId,
expectedVersion: loaded.plan.expectedVersion,
operations: loaded.plan.operations.length,
planSha256: loaded.sha256,
mutation: false,
}, null, 2));
return;
}
if (options.command === "apply") {
const loaded = readPlan(options.positionals[0]);
if (loaded.plan.expectedVersion !== options.expectedVersion) throw new Error(`Plan expectedVersion ${loaded.plan.expectedVersion} differs from --expected-version ${options.expectedVersion}`);
if (loaded.sha256 !== options.planSha256) throw new Error(`Plan hash mismatch: expected ${options.planSha256}, found ${loaded.sha256}`);
const receipt = await applyPlan(loaded.plan, createAtlassianClient(options), baseline.baseUrl);
console.log(JSON.stringify(receipt, null, 2));
return;
}
const client = createAtlassianClient(options);
if (options.command === "policy") {
const result = await checkPolicy(client, baseline);
console.log(JSON.stringify(result, null, 2));
if (result.changed) process.exitCode = 2;
return;
}
if (options.command === "worklist") {
const report = await client.get(`wiki/rest/api/content/${baseline.report.id}?expand=body.export_view%2Cversion`);
const rows = parseReportHtml(report.body?.export_view?.value ?? "");
const result = selectWorklist(rows, options);
console.log(JSON.stringify({ reportId: baseline.report.id, reportVersion: report.version?.number, rowCount: rows.length, ...result }, null, 2));
return;
}
if (options.command === "snapshot") {
if (!/^\d+$/.test(options.positionals[0])) throw new Error("PAGE_ID must be numeric");
console.log(JSON.stringify(await createSnapshot(client, options.positionals[0], baseline.baseUrl), null, 2));
}
}
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
main().catch((error) => {
if (error.receipt) console.error(JSON.stringify({ error: error.message, receipt: error.receipt }, null, 2));
else console.error(error.message);
process.exit(1);
});
}