#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const SKILL_DIR = path.dirname(SCRIPT_DIR); const BASELINE_FILE = path.join(SKILL_DIR, "references", "policy-baseline.md"); const REQUEST_HELPER = path.join(SCRIPT_DIR, "atlassian-request.mjs"); const OFFICIAL_AREAS = ["RD", "isp", "GA", "CS", "SM"]; const DEFAULT_PERSON = "Oliver Krylow"; const DEFAULT_ACCOUNT_ID = "619fbba4744c4d0069c8a49c"; const HEADER_FIELDS = new Map([ ["document owner", "Document Owner"], ["risk owner", "Risk Owner"], ["asset owner", "Asset Owner"], ["klassifizierung", "Klassifizierung"], ["status", "Status"], ["geprüft von", "Geprüft von"], ["geprüft am", "Geprüft am"], ["freigegeben von", "Freigegeben von"], ["freigegeben am", "Freigegeben am"], ]); export function parseCli(argv) { const args = [...argv]; const command = args.shift(); const options = { command, positionals: [], profile: undefined, mode: undefined, person: "Oliver Krylow", accountId: undefined, area: "all", today: undefined, expectedVersion: undefined, planSha256: undefined, allowMutation: false, help: command === "--help" || command === "-h", }; while (args.length) { const arg = args.shift(); switch (arg) { case "--profile": options.profile = requiredValue(arg, args.shift()); break; case "--mode": options.mode = requiredValue(arg, args.shift()); break; case "--person": options.person = requiredValue(arg, args.shift()); break; case "--account-id": options.accountId = requiredValue(arg, args.shift()); break; case "--area": options.area = normalizeArea(requiredValue(arg, args.shift())); break; case "--today": options.today = requiredValue(arg, args.shift()); break; case "--expected-version": { const value = Number(requiredValue(arg, args.shift())); if (!Number.isSafeInteger(value) || value < 1) throw new Error("--expected-version must be a positive integer"); options.expectedVersion = value; break; } case "--plan-sha256": options.planSha256 = requiredValue(arg, args.shift()).toLowerCase(); break; case "--allow-mutation": options.allowMutation = true; break; case "--help": case "-h": options.help = true; break; default: if (arg.startsWith("--")) throw new Error(`Unknown option: ${arg}`); options.positionals.push(arg); } } if (options.help) return options; if (!command) throw new Error("Command is required"); if (!new Set(["policy", "worklist", "snapshot", "check-plan", "apply"]).has(command)) { throw new Error(`Unknown command: ${command}`); } if (command === "worklist" && !new Set(["owner", "reviewer"]).has(options.mode)) { throw new Error("worklist requires --mode owner|reviewer"); } if (command === "snapshot" && options.positionals.length !== 1) throw new Error("snapshot requires PAGE_ID"); if (new Set(["check-plan", "apply"]).has(command) && options.positionals.length !== 1) { throw new Error(`${command} requires PLAN.json`); } if (command === "apply") { if (!options.allowMutation) throw new Error("apply requires --allow-mutation"); if (!options.expectedVersion) throw new Error("apply requires --expected-version"); if (!/^[0-9a-f]{64}$/.test(options.planSha256 ?? "")) throw new Error("apply requires --plan-sha256"); } else if (options.allowMutation) { throw new Error("--allow-mutation is valid only with apply"); } return options; } function requiredValue(option, value) { if (!value || value.startsWith("--")) throw new Error(`${option} requires a value`); return value; } export function normalizeArea(value) { if (value.toLowerCase() === "all") return "all"; if (value.toLowerCase() === "isp") return "isp"; if (value.toUpperCase() === "R&D") return "RD"; const upper = value.toUpperCase(); if (!OFFICIAL_AREAS.includes(upper)) throw new Error(`Unknown IMS area: ${value}`); return upper; } export function parseBaseline(markdown) { const source = (label, kind) => { const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = markdown.match(new RegExp(`- ${escaped}: ` + "`(\\d+)`(?:, version `(\\d+)`)?[^\\n]*\\((https://[^)]+)\\)")); if (!match) throw new Error(`Policy baseline lacks ${label}`); return { kind, id: match[1], version: match[2] ? Number(match[2]) : undefined, url: match[3] }; }; const instruction = source("Instruction page", "page"); const announcement = source("Announcement", "blogpost"); const report = source("Worklist report", "page"); return { instruction, announcement, report, baseUrl: new URL(instruction.url).origin, }; } export async function checkPolicy(client, baseline) { const checked = []; for (const [name, source] of [["instruction", baseline.instruction], ["announcement", baseline.announcement]]) { const current = await client.get(`wiki/api/v2/${source.kind === "blogpost" ? "blogposts" : "pages"}/${source.id}`); checked.push({ name, id: source.id, url: source.url, baselineVersion: source.version, currentVersion: current.version?.number, changed: current.version?.number !== source.version, }); } return { changed: checked.some((source) => source.changed), sources: checked }; } const ENTITY_MAP = new Map([ ["amp", "&"], ["lt", "<"], ["gt", ">"], ["quot", '"'], ["apos", "'"], ["nbsp", " "], ["auml", "ä"], ["ouml", "ö"], ["uuml", "ü"], ["Auml", "Ä"], ["Ouml", "Ö"], ["Uuml", "Ü"], ["szlig", "ß"], ]); export function decodeEntities(value) { return value.replace(/&(#x[0-9a-f]+|#\d+|[a-zA-Z]+);/g, (entity, key) => { if (key.startsWith("#x")) return String.fromCodePoint(Number.parseInt(key.slice(2), 16)); if (key.startsWith("#")) return String.fromCodePoint(Number(key.slice(1))); return ENTITY_MAP.get(key) ?? entity; }); } export function plainTextFromHtml(html) { return normalizeWhitespace(decodeEntities(String(html).replace(//gi, " ").replace(/<[^>]*>/g, " "))); } function normalizeWhitespace(value) { return String(value).replace(/\s+/g, " ").trim(); } function cellsFromRow(rowHtml) { return [...rowHtml.matchAll(/]*>([\s\S]*?)<\/t[dh]>/gi)].map((match) => match[1]); } function attrValues(html, name) { const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return [...String(html).matchAll(new RegExp(`${escaped}\\s*=\\s*(["'])(.*?)\\1`, "gi"))].map((match) => decodeEntities(match[2])); } function accountIds(html) { return sortedUnique([...attrValues(html, "data-account-id"), ...attrValues(html, "ri:account-id")]); } function firstHref(html) { return attrValues(html, "href")[0]; } function normalizeReportArea(href) { const value = href?.match(/\/spaces\/([^/?#]+)/i)?.[1]; if (!value) return undefined; try { return normalizeArea(decodeURIComponent(value)); } catch { return value; } } export function parseReportHtml(html) { const rows = []; for (const match of String(html).matchAll(/]*>([\s\S]*?)<\/tr>/gi)) { const cells = cellsFromRow(match[1]); if (cells.length < 10) continue; const href = firstHref(cells[0]); const id = attrValues(cells[0], "data-content-id")[0] ?? href?.match(/\/pages\/(\d+)/)?.[1] ?? href?.match(/[?&]pageId=(\d+)/)?.[1]; if (!id) continue; rows.push({ id, title: plainTextFromHtml(cells[0]), href, area: normalizeReportArea(href), documentOwner: plainTextFromHtml(cells[1]), documentOwnerIds: accountIds(cells[1]), riskOwner: plainTextFromHtml(cells[2]), riskOwnerIds: accountIds(cells[2]), assetOwner: plainTextFromHtml(cells[3]), assetOwnerIds: accountIds(cells[3]), classification: plainTextFromHtml(cells[4]), status: plainTextFromHtml(cells[5]), reviewer: plainTextFromHtml(cells[6]), reviewerIds: accountIds(cells[6]), reviewedAt: plainTextFromHtml(cells[7]), releasedBy: plainTextFromHtml(cells[8]), releasedAt: plainTextFromHtml(cells[9]), }); } return rows; } function matchesPerson(text, ids, person, accountId) { return accountId ? ids.includes(accountId) : normalizeWhitespace(text) === normalizeWhitespace(person); } function malformedFields(row) { return [ ["documentOwner", row.documentOwner], ["riskOwner", row.riskOwner], ["assetOwner", row.assetOwner], ["classification", row.classification], ["status", row.status], ].filter(([, value]) => !value).map(([field]) => field); } export function parseDate(value) { if (!value) return undefined; const german = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/); const iso = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); const parts = german ? { day: Number(german[1]), month: Number(german[2]) - 1, year: Number(german[3]) } : iso ? { day: Number(iso[3]), month: Number(iso[2]) - 1, year: Number(iso[1]) } : undefined; if (parts) { const date = new Date(Date.UTC(parts.year, parts.month, parts.day)); return date.getUTCFullYear() === parts.year && date.getUTCMonth() === parts.month && date.getUTCDate() === parts.day ? date : undefined; } const timestamp = Date.parse(value); return Number.isNaN(timestamp) ? undefined : new Date(timestamp); } function annualReviewDue(row, today) { const date = parseDate(row.releasedAt || row.reviewedAt); if (!date) return false; const threshold = new Date(today); threshold.setUTCFullYear(threshold.getUTCFullYear() - 1); return date <= threshold; } function areaRank(area) { const index = OFFICIAL_AREAS.indexOf(area); return index < 0 ? OFFICIAL_AREAS.length : index; } export function selectWorklist(rows, options) { const mode = options.mode; const person = options.person ?? DEFAULT_PERSON; const accountId = options.accountId ?? (person === DEFAULT_PERSON ? DEFAULT_ACCOUNT_ID : undefined); const area = options.area ?? "all"; const today = options.today ? new Date(`${options.today}T00:00:00Z`) : new Date(); if (Number.isNaN(today.valueOf())) throw new Error(`Invalid --today: ${options.today}`); const candidates = []; let roleMatchCount = 0; for (const row of rows) { if (area !== "all" && row.area !== area) continue; const malformed = malformedFields(row); const reasons = []; if (mode === "reviewer") { if (!matchesPerson(row.reviewer, row.reviewerIds, person, accountId)) continue; roleMatchCount += 1; if (row.status !== "Prüfung") continue; reasons.push("awaiting-review"); } else { if (!matchesPerson(row.documentOwner, row.documentOwnerIds, person, accountId)) continue; roleMatchCount += 1; if (row.status === "Entwurf") reasons.push("draft"); if (!row.reviewer) reasons.push("missing-reviewer"); if (matchesPerson(row.reviewer, row.reviewerIds, person, accountId)) reasons.push("self-reviewer"); if (annualReviewDue(row, today)) reasons.push("annual-review-due"); if (row.status === "Freigegeben" && !parseDate(row.releasedAt || row.reviewedAt)) reasons.push("missing-review-date"); if (malformed.length) reasons.push("malformed-header"); if (!reasons.length) continue; } candidates.push({ ...row, reasons, malformedFields: malformed }); } candidates.sort((left, right) => areaRank(left.area) - areaRank(right.area) || left.title.localeCompare(right.title, "de")); return { mode, person, area, roleMatchCount, candidates, malformed: candidates.filter((row) => row.malformedFields.length), }; } export function parseHeaderHtml(html) { const header = {}; const keyFor = (cell) => HEADER_FIELDS.get(plainTextFromHtml(cell).toLowerCase().replace(/:$/, "")); const tables = [...String(html).matchAll(/]*>([\s\S]*?)<\/table>/gi)].map((match) => match[1]); for (const table of tables) { const rows = [...table.matchAll(/]*>([\s\S]*?)<\/tr>/gi)].map((match) => cellsFromRow(match[1])); for (let index = 0; index + 1 < rows.length; index++) { const keys = rows[index].map(keyFor); const values = rows[index + 1]; if (keys.length >= 2 && keys.every(Boolean) && values.length >= keys.length) { keys.forEach((key, cell) => { if (header[key] === undefined) header[key] = plainTextFromHtml(values[cell]); }); index += 1; } } for (const row of rows) { if (row.length !== 2) continue; const key = keyFor(row[0]); if (key && !keyFor(row[1]) && header[key] === undefined) header[key] = plainTextFromHtml(row[1]); } } return header; } function pageBody(page, representation) { const value = page.body?.[representation]?.value; if (typeof value !== "string") throw new Error(`Page ${page.id ?? "?"} lacks ${representation} body`); return value; } function flattenResults(response) { const pages = Array.isArray(response) ? response : [response]; return pages.flatMap((page) => page?.results ?? []); } function pageUrl(page, baseUrl) { const webui = page._links?.webui; if (webui && /^https?:/i.test(webui)) return webui; const base = page._links?.base ?? baseUrl; if (webui && base) return new URL(webui, `${base.replace(/\/$/, "")}/`).href; return `${baseUrl.replace(/\/$/, "")}/wiki/pages/viewpage.action?pageId=${page.id}`; } export async function createSnapshot(client, pageId, baseUrl) { const storagePage = await client.get(`wiki/api/v2/pages/${pageId}?body-format=storage`); const capturedVersion = storagePage.version?.number; const viewPage = await client.get(`wiki/api/v2/pages/${pageId}?body-format=view`); if (viewPage.version?.number !== capturedVersion) throw new Error(`Page version changed during snapshot: ${capturedVersion} -> ${viewPage.version?.number}`); const labels = flattenResults(await client.get(`wiki/api/v2/pages/${pageId}/labels?limit=100`, { paginate: true })); const inlineComments = flattenResults(await client.get(`wiki/api/v2/pages/${pageId}/inline-comments?resolution-status=open&body-format=storage&limit=100`, { paginate: true })); const footerComments = flattenResults(await client.get(`wiki/api/v2/pages/${pageId}/footer-comments?body-format=storage&limit=100`, { paginate: true })); const attachments = flattenResults(await client.get(`wiki/api/v2/pages/${pageId}/attachments?limit=100`, { paginate: true })); const storage = pageBody(storagePage, "storage"); const view = pageBody(viewPage, "view"); const confirmedPage = await client.get(`wiki/api/v2/pages/${pageId}?body-format=storage`); const confirmedStorage = pageBody(confirmedPage, "storage"); if (confirmedPage.version?.number !== capturedVersion || hashText(confirmedStorage) !== hashText(storage)) { throw new Error(`Page changed during snapshot after version ${capturedVersion}`); } return { pageId: String(storagePage.id), title: storagePage.title, url: pageUrl(storagePage, baseUrl), version: storagePage.version, header: parseHeaderHtml(view), labels, inlineComments, footerComments, attachments, body: { storage, view, storageSha256: hashText(storage), viewSha256: hashText(view), }, }; } export function extractMarkerRefs(storage) { return sortedUnique([...String(storage).matchAll(/]*\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(``); 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); }); }