Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

pi/agent/skillz/ims-process-review/scripts/ims.mjs

Raw
#!/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(/<br\s*\/?\s*>/gi, " ").replace(/<[^>]*>/g, " ")));
}

function normalizeWhitespace(value) {
	return String(value).replace(/\s+/g, " ").trim();
}

function cellsFromRow(rowHtml) {
	return [...rowHtml.matchAll(/<t[dh]\b[^>]*>([\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(/<tr\b[^>]*>([\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(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)].map((match) => match[1]);
	for (const table of tables) {
		const rows = [...table.matchAll(/<tr\b[^>]*>([\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(/<ac:inline-comment-marker\b[^>]*\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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}

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: `<p>${escapeHtml(operation.text)}</p>` },
		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);
	});
}