From 73c9c97377b431d370a3b9eed49c0c9dea1ecbdf Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Mon, 24 Aug 2026 22:03:51 +0200 Subject: [PATCH 1/2] feat(plan): add plain-language markdown reports --- README.md | 5 +- docs/ci.md | 73 ++ docs/handbuch/dynamic-groups.md | 2 +- docs/handbuch/group-member-fields.md | 2 +- src/commands/plan.ts | 165 ++++- src/engine/build.ts | 32 +- src/engine/markdown.ts | 951 +++++++++++++++++++++++++++ src/engine/plan.ts | 25 +- src/engine/synthetic.ts | 21 +- src/engine/types.ts | 11 + tests/cli.test.ts | 6 + tests/plan-env-command.test.ts | 51 +- tests/plan-markdown.test.ts | 230 +++++++ tests/synthetic-dynamic.test.ts | 4 +- 14 files changed, 1519 insertions(+), 59 deletions(-) create mode 100644 src/engine/markdown.ts create mode 100644 tests/plan-markdown.test.ts diff --git a/README.md b/README.md index 62833af..718462b 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ ct coverage # what the instance has that the config does not m ct state list # what is managed ct state rm campus mainz # un-adopt: drop it from state. Never touches ChurchTools. ct plan # diff the config against ChurchTools (read-only) +ct plan --format markdown # plain-language review report (German by default) ct apply # create + update in dependency order (confirm + backup first) ct refresh --group # make ChurchTools re-evaluate one auto-group now ``` @@ -287,8 +288,8 @@ ct apply --env prod # protected env: type the env name to confirm - [**Environments**](docs/environments.md) — `ct.envs.json`, per-env state files, protected environments, the dev → prod promotion workflow. - [**CI usage**](docs/ci.md) — the auth model and token-from-secret setup, - `--detailed-exitcode`, the `--json` plan shape, drift-vs-config attribution, - and a copy-pasteable job that posts the plan as a PR comment. + `--detailed-exitcode`, Markdown/JSON plan projections, deterministic sidecar + names, drift-vs-config attribution, and copy-pasteable PR artifacts. ## Guardrails (by design) diff --git a/docs/ci.md b/docs/ci.md index 7f0583e..197d046 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -67,6 +67,79 @@ An INCOMPLETE plan is always exit `1`, even with `--detailed-exitcode` and even if the (partial) plan has changes: an incomplete diff can't be trusted enough to report "changes pending" instead of "this run failed". +## Plain-language review report: Markdown + +`ct plan --format markdown` renders the same resource and permission plan as a +self-contained Markdown document for people who do not need to understand the +terminal diff or raw JSON: + +```bash +ct plan --env prod --format markdown > plan-prod.md +``` + +The report starts with the target environment, host, ChurchTools version, +configuration and state host. It distinguishes creates, updates, drift, +delete candidates, permission grants/revocations, preserved grants and an +incomplete plan. A delete candidate is explicitly described as something +`ct apply` will **not** delete. Automatic groups receive a semantic summary; +all current and future resource types remain visible through a generic +technical appendix. + +German is the deterministic default. Select English explicitly when needed: + +```bash +ct plan --env prod --format markdown --locale en > plan-prod.md +``` + +The command computes the live plan only once even when several projections are +needed. Repeat `--format` and give one base name; `ct` adds a stable extension: + +```bash +ct plan --env prod \ + --format text \ + --format json \ + --format markdown \ + --output-base reports/plan-prod + +# reports/plan-prod.txt +# reports/plan-prod.json +# reports/plan-prod.md +``` + +For a single projection, `--output-base` follows the same convention. If the +base already ends in `.txt`, `.json`, `.md` or `.markdown`, that known extension +is replaced with the selected format's extension. Multiple formats without +`--output-base` are rejected so two documents can never be concatenated +ambiguously on stdout. + +`--json` remains the backward-compatible stdout alias for `--format json`. +Combining the alias with `--format` is rejected rather than guessing which +request wins. + +The Markdown renderer performs no additional ChurchTools request and does not +recalculate actions or safety policy. It consumes the same structured plan as +terminal and JSON output. Fields whose names look like credentials, tokens, +passwords or secrets are redacted in both the readable body and technical +fallback. + +To publish the report as a GitHub Actions artifact: + +```yaml +- name: Create review plans + env: + CT_LOGINTOKEN: ${{ secrets.CT_LOGINTOKEN }} + run: | + mkdir -p reports + ct plan --env prod --format json --format markdown --output-base reports/plan-prod + +- name: Upload plan reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: churchtools-plan + path: reports/plan-prod.* +``` + ## Machine-readable output: `--json` `ct plan --json` prints **only** the plan JSON to stdout — the env/host diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index b85b585..366746b 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -6,7 +6,7 @@ sources: - src/engine/dynamic.ts - src/engine/synthetic.ts - src/commands/adopt-group.ts -sources_hash: 02bcb67b91c897de +sources_hash: 437e71c4f4f8bf36 reviewed: 2026-08-17 --- diff --git a/docs/handbuch/group-member-fields.md b/docs/handbuch/group-member-fields.md index e4d8a11..f133122 100644 --- a/docs/handbuch/group-member-fields.md +++ b/docs/handbuch/group-member-fields.md @@ -1,5 +1,5 @@ --- -sources_hash: 00dca3c03dacc9ff +sources_hash: a5335e766218b514 title: Group member fields sources: - src/engine/member-fields.ts diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 4f13434..53d454f 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -1,3 +1,5 @@ +import { writeFile } from "node:fs/promises"; +import { format as formatPath, parse as parsePath } from "node:path"; import { Command } from "commander"; import { authedSession } from "../api/session.js"; import { resolveConfig } from "../config.js"; @@ -7,20 +9,90 @@ import { loadConfig, resolveConfigPath } from "../config/load.js"; import { buildPlan } from "../engine/build.js"; import { Resolver } from "../resolve/resolver.js"; import { renderPlan } from "../engine/render.js"; +import { PLAN_MARKDOWN_LOCALES, renderPlanMarkdown, type PlanMarkdownLocale } from "../engine/markdown.js"; import { summarize } from "../engine/types.js"; import { buildPermissionPlan } from "../permissions/plan.js"; import { loadHostCatalog } from "../permissions/catalog-store.js"; import { renderPermissionPlan } from "../permissions/render.js"; -import { info, warn, out } from "../ui.js"; +import { info, warn } from "../ui.js"; + +export type PlanFormat = "text" | "json" | "markdown"; + +export interface PlanOutputTarget { + format: PlanFormat; + path?: string; +} interface PlanOptions { config?: string; state?: string; env?: string; json?: boolean; + format?: string[]; + outputBase?: string; + locale?: string; detailedExitcode?: boolean; } +function collectFormat(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +function parseFormat(value: string): PlanFormat { + if (value === "text" || value === "json" || value === "markdown") return value; + throw new Error(`Unknown plan format "${value}". Use text, json, or markdown.`); +} + +export function parsePlanLocale(value: string | undefined): PlanMarkdownLocale { + const locale = value ?? "de-DE"; + if ((PLAN_MARKDOWN_LOCALES as readonly string[]).includes(locale)) return locale as PlanMarkdownLocale; + throw new Error(`Unknown plan locale "${locale}". Available locales: ${PLAN_MARKDOWN_LOCALES.join(", ")}.`); +} + +function planOutputPath(base: string, planFormat: PlanFormat): string { + const extension: Record = { text: ".txt", json: ".json", markdown: ".md" }; + const parsed = parsePath(base); + const knownExtension = [".txt", ".json", ".md", ".markdown"].includes(parsed.ext.toLowerCase()); + return formatPath({ + dir: parsed.dir, + name: parsed.name, + ext: knownExtension ? extension[planFormat] : `${parsed.ext}${extension[planFormat]}`, + }); +} + +/** Resolve output files before any ChurchTools request, so invalid combinations fail cheaply. */ +export function planOutputTargets( + opts: Pick, +): PlanOutputTarget[] { + const explicit = opts.format ?? []; + if (opts.json && explicit.length > 0) { + throw new Error("--json is an alias for --format json and cannot be combined with --format."); + } + if (opts.json && opts.outputBase) { + throw new Error( + "Use --format json with --output-base; the backward-compatible --json alias writes to stdout.", + ); + } + const selected = opts.json + ? ["json" as const] + : explicit.length > 0 + ? explicit.map(parseFormat) + : ["text" as const]; + const formats = [...new Set(selected)]; + if (opts.outputBase && explicit.length === 0) { + throw new Error("--output-base requires at least one explicit --format."); + } + if (formats.length > 1 && !opts.outputBase) { + throw new Error( + "Multiple --format values require --output-base so every projection has a distinct file.", + ); + } + return formats.map((planFormat) => ({ + format: planFormat, + path: opts.outputBase ? planOutputPath(opts.outputBase, planFormat) : undefined, + })); +} + export function planCommand(): Command { return new Command("plan") .description("Show the diff between the desired-state config and ChurchTools (read-only)") @@ -28,11 +100,21 @@ export function planCommand(): Command { .option("-s, --state ", "state file (or set CT_STATE)") .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") .option("--json", "emit the raw plan as JSON instead of the rendered diff") + .option( + "--format ", + "output format; repeat for multiple projections: text, json, markdown", + collectFormat, + [], + ) + .option("--output-base ", "write selected formats as .txt/.json/.md") + .option("--locale ", "Markdown language: de-DE or en", "de-DE") .option( "--detailed-exitcode", "Terraform-style exit code: 0 = no changes, 1 = error, 2 = changes pending (resource or permission)", ) .action(async (opts: PlanOptions) => { + const outputTargets = planOutputTargets(opts); + const locale = parsePlanLocale(opts.locale); // Resolve the env FIRST — it wires the target host/token into the process env before resolveConfig. const cmdEnv = await prepareEnv(opts); const config = await resolveConfig(); @@ -53,7 +135,7 @@ export function planCommand(): Command { const resolver = new Resolver({ client, state, desired, host: config.host }); // Independent fetches run concurrently (see commands/apply.ts). const [ - { plan, fetchErrors }, + { plan, fetchErrors, warnings: resourceWarnings = [] }, { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }, ] = await Promise.all([ buildPlan(client, state, desired, { configDir, resolver }), @@ -70,41 +152,54 @@ export function planCommand(): Command { ); const hasChanges = hasResourceChanges || hasPermissionChanges; - if (opts.json) { - // Additive on top of the raw plan/permissions (#24) — existing consumers of `plan`/`permissions` - // are unaffected. See README "CI usage" for exactly what each summary field means. - out({ - plan, - permissions: permItems, - summary: { - resources: summarize(plan), - drifted: plan.items.filter((i) => i.drift && i.drift.length > 0).length, - // Non-zero means the plan is PARTIAL: these resources could not be read, so their diff - // is missing rather than empty. A machine consumer must not treat the plan as complete - // while this is > 0 (#126) — `ct plan` also exits 1 in that case. - unreadable: plan.items.filter((i) => i.note === "fetch-failed").length, - permissions: { - toPut: permItems.reduce((n, i) => n + i.diff.toPut.length, 0), - toDelete: permItems.reduce((n, i) => n + i.diff.toDelete.length, 0), - preserved: permItems.reduce((n, i) => n + i.diff.preserved.length, 0), - }, - hasChanges, + // Additive on top of the raw plan/permissions (#24) — existing consumers of `plan`/`permissions` + // are unaffected. Every projection below consumes this one computation. + const payload = { + plan, + permissions: permItems, + summary: { + resources: summarize(plan), + drifted: plan.items.filter((i) => i.drift && i.drift.length > 0).length, + // Non-zero means the plan is PARTIAL: these resources could not be read, so their diff + // is missing rather than empty. A machine consumer must not treat the plan as complete + // while this is > 0 (#126) — `ct plan` also exits 1 in that case. + unreadable: plan.items.filter((i) => i.note === "fetch-failed").length, + permissions: { + toPut: permItems.reduce((n, i) => n + i.diff.toPut.length, 0), + toDelete: permItems.reduce((n, i) => n + i.diff.toDelete.length, 0), + preserved: permItems.reduce((n, i) => n + i.diff.preserved.length, 0), }, - }); - } else { - // Under --env, surface the target env name + its CT version (per-env version gate, #22) so a - // dev/prod version skew is visible before applying. No --env keeps the original header byte-identical. - if (cmdEnv.name) { - info( - `env: ${cmdEnv.name} · host: ${config.host} · ChurchTools ${client.version ?? "unknown"} · ` + - `config: ${configPath} · state host: ${state.host}`, - ); + hasChanges, + }, + }; + + const textHeader = cmdEnv.name + ? `env: ${cmdEnv.name} · host: ${config.host} · ChurchTools ${client.version ?? "unknown"} · ` + + `config: ${configPath} · state host: ${state.host}` + : `config: ${configPath} · state host: ${state.host}`; + const textBody = `${renderPlan(plan)}${permItems.length > 0 ? `\n\n${renderPermissionPlan(permItems)}` : ""}\n`; + for (const target of outputTargets) { + const content = + target.format === "json" + ? `${JSON.stringify(payload, null, 2)}\n` + : target.format === "markdown" + ? renderPlanMarkdown(plan, permItems, { + environment: cmdEnv.name, + host: config.host, + churchToolsVersion: client.version, + configPath, + stateHost: state.host, + locale, + warnings: [...resourceWarnings, ...permWarnings], + fetchErrors: [...fetchErrors, ...permFetchErrors], + }) + : textBody; + if (target.path) { + await writeFile(target.path, content, "utf8"); + info(`plan ${target.format}: ${target.path}`); } else { - info(`config: ${configPath} · state host: ${state.host}`); - } - process.stdout.write(`${renderPlan(plan)}\n`); - if (permItems.length > 0) { - process.stdout.write(`\n${renderPermissionPlan(permItems)}\n`); + if (target.format === "text") info(textHeader); + process.stdout.write(content); } } diff --git a/src/engine/build.ts b/src/engine/build.ts index f53bc30..91fd191 100644 --- a/src/engine/build.ts +++ b/src/engine/build.ts @@ -24,6 +24,8 @@ export interface BuildResult { plan: Plan; actual: Map>; fetchErrors: string[]; + /** Informational registry/portability warnings already printed to stderr. */ + warnings?: string[]; } export interface FetchActualResult { @@ -35,6 +37,7 @@ export interface FetchActualResult { fetchFailed: Map; /** Human-readable fetch-error lines (non-404), one per failed key. */ fetchErrors: string[]; + warnings?: string[]; } /** @@ -54,14 +57,15 @@ export async function fetchActual( const unresolved = new Set(); const fetchFailed = new Map(); const fetchErrors: string[] = []; + const warnings: string[] = []; await mapConcurrent(resources, FETCH_CONCURRENCY, async (managed) => { const spec = RESOURCES[managed.type]; if (!spec) { unresolved.add(managed.key); - warn( - `No registry entry for managed type "${managed.type}" (${managed.type}.${managed.key} #${managed.id}) — cannot diff; leaving untouched.`, - ); + const warning = `No registry entry for managed type "${managed.type}" (${managed.type}.${managed.key} #${managed.id}) — cannot diff; leaving untouched.`; + warnings.push(warning); + warn(warning); return; } try { @@ -88,7 +92,13 @@ export async function fetchActual( } }); - return { actual, unresolved, fetchFailed, fetchErrors }; + return { + actual, + unresolved, + fetchFailed, + fetchErrors, + ...(warnings.length > 0 ? { warnings } : {}), + }; } export interface BuildOptions { @@ -110,10 +120,13 @@ export async function buildPlan( opts: BuildOptions = {}, ): Promise { // Keyed by logical key (globally unique), not CT id (unique only within a type — the Mainz campus is id 0). - const { actual, unresolved, fetchFailed, fetchErrors } = await fetchActual( - client, - Object.values(state.resources), - ); + const { + actual, + unresolved, + fetchFailed, + fetchErrors, + warnings: fetchWarnings = [], + } = await fetchActual(client, Object.values(state.resources)); // Synthetic sub-resource fields (parents, dynamic, …) fold into the diff on both sides. const folded = await foldSynthetic({ client, state, desired, actual, configDir: opts.configDir }); @@ -149,5 +162,6 @@ export async function buildPlan( }); const plan = computePlan(ordered, state, actual, { unresolved, fetchFailed }); - return { plan, actual, fetchErrors }; + const warnings = [...fetchWarnings, ...(folded.warnings ?? [])]; + return { plan, actual, fetchErrors, ...(warnings.length > 0 ? { warnings } : {}) }; } diff --git a/src/engine/markdown.ts b/src/engine/markdown.ts new file mode 100644 index 0000000..8d0e7df --- /dev/null +++ b/src/engine/markdown.ts @@ -0,0 +1,951 @@ +/** + * Plain-language Markdown projection of the same structured resource and + * permission plans used by terminal and JSON output. This module is deliberately + * pure: it performs no IO, fetches no ChurchTools data and never recomputes a + * plan action. + */ +import type { FieldChange, FieldChangeSource, Plan, PlanAction, PlanItem } from "./types.js"; +import type { PermissionPlanItem } from "../permissions/plan.js"; +import type { GrantTuple } from "../permissions/grants.js"; +import { CATALOG } from "../permissions/catalog.js"; +import { refLabel } from "../resolve/refs.js"; + +export const PLAN_MARKDOWN_LOCALES = ["de-DE", "en"] as const; +export type PlanMarkdownLocale = (typeof PLAN_MARKDOWN_LOCALES)[number]; + +export interface MarkdownPlanContext { + environment?: string | null; + host: string; + churchToolsVersion?: string | null; + configPath: string; + stateHost: string; + /** Injected by tests/automation when byte-identical output is required. */ + generatedAt?: Date; + locale?: PlanMarkdownLocale; + /** Informational catalog/portability warnings already produced by the shared planning pipeline. */ + warnings?: string[]; + /** Fetch failures make the report incomplete and block apply. */ + fetchErrors?: string[]; +} + +interface Copy { + title: string; + planOnly: string; + context: string; + environment: string; + host: string; + churchToolsVersion: string; + config: string; + stateHost: string; + generatedAt: string; + summary: string; + result: string; + count: string; + create: string; + update: string; + delete: string; + noop: string; + resourceTypes: string; + resourceType: string; + permissions: string; + grants: string; + revocations: string; + preserved: string; + changes: string; + creates: string; + updates: string; + deleteCandidates: string; + field: string; + before: string; + after: string; + reason: string; + configReason: string; + driftReason: string; + bothReason: string; + drift: string; + noChanges: string; + deleteSafety: string; + incompleteTitle: string; + incompleteText: string; + warnings: string; + review: string; + checklist: string[]; + appendix: string; + logicalKey: string; + id: string; + action: string; + note: string; + details: string; + unknown: string; + none: string; + pending: string; + domain: string; + right: string; + scope: string; + effect: string; + permissionPreserved: string; + automaticGroup: string; + status: string; + rules: string; +} + +const COPY: Record = { + "de-DE": { + title: "ChurchTools-Änderungsplan", + planOnly: + "Dieser Bericht beschreibt nur den Plan. Es wurde noch nichts in ChurchTools angelegt oder geändert.", + context: "Kontext", + environment: "Umgebung", + host: "ChurchTools-Instanz", + churchToolsVersion: "ChurchTools-Version", + config: "Konfiguration", + stateHost: "Instanz des State", + generatedAt: "Erstellt am", + summary: "Zusammenfassung", + result: "Ergebnis", + count: "Anzahl", + create: "Neu anlegen", + update: "Ändern", + delete: "Löschkandidaten", + noop: "Unverändert", + resourceTypes: "Ressourcen nach Typ", + resourceType: "Ressourcentyp", + permissions: "Berechtigungen", + grants: "Zu erteilen", + revocations: "Zu entziehen", + preserved: "Bewusst unangetastet", + changes: "Geplante Änderungen", + creates: "Neue Ressourcen", + updates: "Geänderte Ressourcen", + deleteCandidates: "Aus der Konfiguration entfernte Ressourcen", + field: "Feld", + before: "Bisher", + after: "Geplant", + reason: "Grund", + configReason: "Konfiguration wurde geändert", + driftReason: "Manuelle Änderung in ChurchTools wird zurückgeführt", + bothReason: "Konfiguration und ChurchTools wurden unabhängig geändert", + drift: "Erkannte manuelle Änderungen", + noChanges: + "Es sind keine Änderungen erforderlich. Der gewünschte Zustand stimmt mit ChurchTools überein.", + deleteSafety: + "`ct apply` löscht diese Ressourcen **nicht**. Eine tatsächliche Löschung ist nur über den separaten Befehl `ct destroy` möglich.", + incompleteTitle: "Unvollständiger Plan", + incompleteText: + "Mindestens eine Ressource konnte nicht gelesen werden. Der Bericht ist deshalb nicht vollständig und darf nicht als Freigabe für `apply` verwendet werden.", + warnings: "Warnungen", + review: "Prüfung vor dem Anwenden", + checklist: [ + "Stimmen Zielumgebung, Host und State-Datei überein?", + "Sind alle neuen und geänderten Ressourcen fachlich gewollt?", + "Sind Berechtigungsentzüge und Löschkandidaten ausdrücklich geprüft?", + "Ist der Plan vollständig und sind alle Warnungen geklärt?", + "Erst danach `ct apply -e ` ausführen.", + ], + appendix: "Technischer Anhang", + logicalKey: "Logischer Schlüssel", + id: "ChurchTools-ID", + action: "Aktion", + note: "Hinweis", + details: "Technische Details", + unknown: "unbekannt", + none: "keine", + pending: "wird beim Anwenden erzeugt", + domain: "Bereich", + right: "Recht", + scope: "Geltungsbereich", + effect: "Auswirkung", + permissionPreserved: "wird nicht verwaltet und bleibt erhalten", + automaticGroup: "Automatische Gruppe", + status: "Status", + rules: "Regeln", + }, + en: { + title: "ChurchTools change plan", + planOnly: "This report describes the plan only. Nothing has been created or changed in ChurchTools.", + context: "Context", + environment: "Environment", + host: "ChurchTools instance", + churchToolsVersion: "ChurchTools version", + config: "Configuration", + stateHost: "State instance", + generatedAt: "Generated at", + summary: "Summary", + result: "Result", + count: "Count", + create: "Create", + update: "Update", + delete: "Delete candidates", + noop: "Unchanged", + resourceTypes: "Resources by type", + resourceType: "Resource type", + permissions: "Permissions", + grants: "To grant", + revocations: "To revoke", + preserved: "Deliberately preserved", + changes: "Planned changes", + creates: "New resources", + updates: "Updated resources", + deleteCandidates: "Resources removed from configuration", + field: "Field", + before: "Before", + after: "Planned", + reason: "Reason", + configReason: "Configuration changed", + driftReason: "Manual ChurchTools change will be reverted", + bothReason: "Configuration and ChurchTools changed independently", + drift: "Detected manual changes", + noChanges: "No changes are required. Desired state matches ChurchTools.", + deleteSafety: + "`ct apply` does **not** delete these resources. Actual deletion is only available through the separate `ct destroy` command.", + incompleteTitle: "Incomplete plan", + incompleteText: + "At least one resource could not be read. This report is incomplete and must not be used to approve `apply`.", + warnings: "Warnings", + review: "Review before applying", + checklist: [ + "Do environment, host and state file identify the same target?", + "Are all new and updated resources intended?", + "Were permission revocations and delete candidates explicitly reviewed?", + "Is the plan complete and have all warnings been resolved?", + "Only then run `ct apply -e `.", + ], + appendix: "Technical appendix", + logicalKey: "Logical key", + id: "ChurchTools ID", + action: "Action", + note: "Note", + details: "Technical details", + unknown: "unknown", + none: "none", + pending: "created during apply", + domain: "Domain", + right: "Right", + scope: "Scope", + effect: "Effect", + permissionPreserved: "not managed and left untouched", + automaticGroup: "Automatic group", + status: "Status", + rules: "Rules", + }, +}; + +const TYPE_LABELS: Record> = { + "de-DE": { + campus: "Campus", + group: "Gruppe", + "group-type": "Gruppentyp", + "age-group": "Altersgruppe", + "target-group": "Zielgruppe", + "relationship-type": "Beziehungstyp", + "person-status": "Personenstatus", + department: "Bereich", + "security-level": "Sicherheitsstufe", + "group-role": "Gruppenrolle", + }, + en: { + campus: "Campus", + group: "Group", + "group-type": "Group type", + "age-group": "Age group", + "target-group": "Target group", + "relationship-type": "Relationship type", + "person-status": "Person status", + department: "Department", + "security-level": "Security level", + "group-role": "Group role", + }, +}; + +const FIELD_LABELS: Record> = { + "de-DE": { + name: "Name", + nameTranslated: "Übersetzter Name", + shorty: "Kurzname", + sortKey: "Sortierung", + campusId: "Campus", + groupTypeId: "Gruppentyp", + groupStatusId: "Gruppenstatus", + securityLevelId: "Sicherheitsstufe", + parents: "Übergeordnete Gruppen", + dynamic: "Automatische Gruppe", + type: "Typ", + isMember: "Mitgliedsstatus", + isSearchable: "Suchbar", + }, + en: { + name: "Name", + nameTranslated: "Translated name", + shorty: "Short name", + sortKey: "Sort order", + campusId: "Campus", + groupTypeId: "Group type", + groupStatusId: "Group status", + securityLevelId: "Security level", + parents: "Parent groups", + dynamic: "Automatic group", + type: "Type", + isMember: "Member status", + isSearchable: "Searchable", + }, +}; + +const ACTION_LABELS: Record> = { + "de-DE": { create: "anlegen", update: "ändern", delete: "Löschkandidat", "no-op": "unverändert" }, + en: { create: "create", update: "update", delete: "delete candidate", "no-op": "unchanged" }, +}; + +const DOMAIN_LABELS: Record> = { + "de-DE": { group_role: "Gruppenrolle", group_type_role: "Gruppentyp-Rolle", status: "Status" }, + en: { group_role: "Group role", group_type_role: "Group-type role", status: "Status" }, +}; + +const DYNAMIC_STATUS: Record> = { + "de-DE": { + active: "automatisch aktiv", + manual: "manuell auszuführen", + inactive: "pausiert", + none: "keine Automatik", + }, + en: { active: "active automatically", manual: "run manually", inactive: "paused", none: "no automation" }, +}; + +const MEMBER_FIELD_TYPE: Record> = { + "de-DE": { + checkbox: "Ja/Nein", + multiselect: "Mehrfachauswahl", + select: "Auswahl", + text: "Kurzer Text", + textarea: "Langer Text", + }, + en: { + checkbox: "Yes/No", + multiselect: "Multiple choice", + select: "Choice", + text: "Short text", + textarea: "Long text", + }, +}; + +const SECRET_KEY = /(?:token|secret|password|credential|authorization|cookie)/i; + +function sanitize(value: unknown, key = ""): unknown { + if (SECRET_KEY.test(key)) return "[REDACTED]"; + if (Array.isArray(value)) return value.map((entry) => sanitize(entry)); + if (value !== null && typeof value === "object") { + const out: Record = {}; + for (const childKey of Object.keys(value as Record).sort()) { + out[childKey] = sanitize((value as Record)[childKey], childKey); + } + return out; + } + return value; +} + +export function redactPlanReportSecrets(value: string): string { + return value + .replace(/([?&](?:login_)?token=)[^&#\s]+/gi, "$1[REDACTED]") + .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[REDACTED]") + .replace(/(https?:\/\/)[^/@\s]+@/gi, "$1[REDACTED]@"); +} + +function stableJson(value: unknown): string { + if (value === undefined) return "—"; + if (typeof value === "string") return value; + return JSON.stringify(sanitize(value)); +} + +/** Escape user-controlled text for both Markdown tables and prose. */ +export function escapeMarkdown(value: unknown): string { + return redactPlanReportSecrets(String(value ?? "")) + .replace(/\\/g, "\\\\") + .replace(/\|/g, "\\|") + .replace(/([*_<>#])/g, "\\$1") + .replaceAll("[", "\\[") + .replaceAll("]", "\\]") + .replace(/`/g, "`") + .replace(/\r?\n/g, "
"); +} + +function typeLabel(type: string, locale: PlanMarkdownLocale): string { + return TYPE_LABELS[locale][type] ?? type; +} + +function fieldLabel(field: string, locale: PlanMarkdownLocale): string { + return FIELD_LABELS[locale][field] ?? field; +} + +function sourceLabel(source: FieldChangeSource | undefined, copy: Copy): string { + if (source === "drift") return copy.driftReason; + if (source === "config+drift") return copy.bothReason; + return copy.configReason; +} + +function valueText(value: unknown, copy: Copy): string { + if (value === undefined) return copy.none; + if (value !== null && typeof value === "object" && "__pendingRef" in value) { + return `${refLabel((value as { __pendingRef: Parameters[0] }).__pendingRef)} (${copy.pending})`; + } + if (Array.isArray(value)) return value.length > 0 ? value.map((v) => stableJson(v)).join(", ") : copy.none; + return stableJson(value); +} + +function dynamicText(value: unknown, copy: Copy): string | null { + if (value === null || typeof value !== "object") return null; + const dynamic = value as Record; + if (!("status" in dynamic) && !("ruleset" in dynamic)) return null; + const ruleset = dynamic.ruleset; + let rules: string; + if (ruleset && typeof ruleset === "object" && "ref" in ruleset) { + rules = String((ruleset as Record).ref); + } else if (ruleset && typeof ruleset === "object") { + const count = Object.keys(ruleset as Record).length; + rules = `${count} ${copy.rules.toLocaleLowerCase()}`; + } else { + rules = copy.none; + } + return `${copy.automaticGroup}: ${copy.status} ${String(dynamic.status ?? copy.unknown)}, ${copy.rules} ${rules}`; +} + +function renderValue(value: unknown, field: string, copy: Copy): string { + if (SECRET_KEY.test(field)) return escapeMarkdown("[REDACTED]"); + const semantic = field === "dynamic" ? dynamicText(value, copy) : null; + return escapeMarkdown(semantic ?? valueText(value, copy)); +} + +function table(headers: string[], rows: string[][]): string[] { + return [ + `| ${headers.join(" | ")} |`, + `| ${headers.map(() => "---").join(" | ")} |`, + ...rows.map((row) => `| ${row.join(" | ")} |`), + ]; +} + +function resourceHeading(item: PlanItem, locale: PlanMarkdownLocale): string { + const name = item.displayName || item.key; + const technical = `${item.type}.${item.key}${item.id === null ? "" : ` · #${item.id}`}`; + return `### ${escapeMarkdown(name)} — ${escapeMarkdown(typeLabel(item.type, locale))} (${escapeMarkdown(technical)})`; +} + +function changeRows(changes: FieldChange[], locale: PlanMarkdownLocale, copy: Copy): string[][] { + return changes.map((change) => [ + escapeMarkdown(fieldLabel(change.field, locale)), + renderValue(change.from, change.field, copy), + renderValue(change.to, change.field, copy), + escapeMarkdown(sourceLabel(change.source, copy)), + ]); +} + +function changedFields(item: PlanItem): Record { + return Object.fromEntries(item.changes.map((change) => [change.field, change.to])); +} + +function itemNames(plan: Plan): Map { + return new Map(plan.items.map((item) => [item.key, item.displayName || item.key])); +} + +function findRulesetFilters(node: unknown, variable: string, found: unknown[] = []): unknown[] { + if (Array.isArray(node)) { + for (const value of node) findRulesetFilters(value, variable, found); + return found; + } + if (node === null || typeof node !== "object") return found; + for (const [operator, expression] of Object.entries(node as Record)) { + if ( + (operator === "==" || operator === "oneof") && + Array.isArray(expression) && + expression[0] !== null && + typeof expression[0] === "object" && + (expression[0] as Record).var === variable + ) { + found.push(expression[1]); + } + findRulesetFilters(expression, variable, found); + } + return found; +} + +function flattenUnique(values: unknown[]): unknown[] { + return [...new Set(values.flat(Number.POSITIVE_INFINITY).map((value) => stableJson(value)))].map( + (encoded) => { + try { + return JSON.parse(encoded) as unknown; + } catch { + return encoded; + } + }, + ); +} + +function dynamicDetails(dynamic: unknown, plan: Plan, locale: PlanMarkdownLocale): string[] { + if (dynamic === null || typeof dynamic !== "object") return []; + const value = dynamic as Record; + if (value.ruleset === null || typeof value.ruleset !== "object") return []; + const ruleset = value.ruleset as Record; + const names = itemNames(plan); + const groupNamesById = new Map( + plan.items + .filter((item) => item.type === "group" && item.id !== null) + .map((item) => [item.id as number, item.displayName || item.key]), + ); + const sourceIds = flattenUnique(findRulesetFilters(ruleset.query, "ctgroup.id")); + const sourceNames = flattenUnique(findRulesetFilters(ruleset.query, "ctgroup.name")); + const process = ruleset.process as Record | undefined; + const queryOnly = (process?.queryResultOnly as Record | undefined)?.none as + Record | undefined; + const groupAndQuery = (process?.groupAndQueryResult as Record | undefined)?.active as + Record | undefined; + const membership = (queryOnly?.handleMembership ?? groupAndQuery?.handleMembership) as + Record | undefined; + const lines: string[] = []; + if (sourceIds.length > 0) { + const resolved = sourceIds.map((entry) => { + if (typeof entry === "number") return groupNamesById.get(entry) ?? `ChurchTools group #${entry}`; + if (entry && typeof entry === "object") { + const pending = (entry as Record).__pendingRef ?? entry; + if (pending && typeof pending === "object") { + const key = (pending as Record).key; + if (typeof key === "string") return names.get(key) ?? key; + } + } + return stableJson(entry); + }); + lines.push( + locale === "de-DE" + ? `Quelle der Teilnehmenden: ${resolved.join(", ")}` + : `Participant source: ${resolved.join(", ")}`, + ); + } + if (sourceNames.length > 0) { + lines.push( + locale === "de-DE" + ? `Ausgewertete Gruppen: ${sourceNames.map(stableJson).join(", ")}` + : `Evaluated groups: ${sourceNames.map(stableJson).join(", ")}`, + ); + } + if (membership?.groupTypeRoleId !== undefined) { + lines.push( + locale === "de-DE" + ? `Neue Mitgliedschaft verwendet die aufgelöste Rollen-ID ${stableJson(membership.groupTypeRoleId)}.` + : `New membership uses resolved role ID ${stableJson(membership.groupTypeRoleId)}.`, + ); + } + if (membership?.groupMemberFields && typeof membership.groupMemberFields === "object") { + const assignments = Object.entries(membership.groupMemberFields as Record) + .map(([key, entry]) => `${key} = ${stableJson(entry)}`) + .join(", "); + lines.push( + locale === "de-DE" + ? `Automatisch gesetzte Gruppenfelder: ${assignments}` + : `Automatically assigned group fields: ${assignments}`, + ); + } + const groupOnly = (process?.groupOnly as Record | undefined)?.active as + Record | undefined; + const groupOnlyMembership = groupOnly?.handleMembership as Record | undefined; + if (groupOnlyMembership?.groupMemberStatus === "none") { + lines.push( + locale === "de-DE" + ? "Aktive Mitgliedschaften werden beendet, wenn eine Person die Auswahlbedingungen nicht mehr erfüllt." + : "Active memberships end when a person no longer matches the selection criteria.", + ); + } + return lines; +} + +function memberFieldsFor(group: PlanItem, plan: Plan): PlanItem[] { + if (group.type !== "group") return []; + return plan.items.filter( + (item) => + item.action === "create" && + item.type === "group-member-field" && + (item.key === group.key || item.key.startsWith(`${group.key}::`)), + ); +} + +function renderGroupOverview(item: PlanItem, plan: Plan, locale: PlanMarkdownLocale, copy: Copy): string[] { + if (item.type !== "group" || item.action !== "create") return []; + const fields = changedFields(item); + const names = itemNames(plan); + const groupTypeNames = new Map( + plan.items + .filter((entry) => entry.type === "group-type" && entry.id !== null) + .map((entry) => [entry.id as number, entry.displayName || entry.key]), + ); + const parents = Array.isArray(fields.parents) + ? fields.parents.map((key) => names.get(String(key)) ?? String(key)) + : []; + const dynamic = fields.dynamic; + const groupType = groupTypeNames.get(Number(fields.groupTypeId)) ?? copy.unknown; + const ownedFields = memberFieldsFor(item, plan); + const lines = [ + `- ${locale === "de-DE" ? "Technischer Schlüssel" : "Technical key"}: \`${escapeMarkdown(item.key)}\``, + `- ${locale === "de-DE" ? "Gruppentyp" : "Group type"}: ${escapeMarkdown(groupType)}${fields.groupTypeId === undefined ? "" : ` (#${escapeMarkdown(fields.groupTypeId)})`}`, + `- ${locale === "de-DE" ? "Gruppenstatus" : "Group status"}: ${escapeMarkdown(fields.groupStatusId ?? copy.unknown)}`, + `- ${locale === "de-DE" ? "Übergeordnete Gruppe(n)" : "Parent group(s)"}: ${escapeMarkdown(parents.length > 0 ? parents.join(", ") : copy.none)}`, + `- ${locale === "de-DE" ? "Neue Gruppenmitgliedsfelder" : "New group member fields"}: ${ownedFields.length}`, + `- ${copy.automaticGroup}: ${escapeMarkdown(DYNAMIC_STATUS[locale][String((dynamic as Record | undefined)?.status)] ?? copy.none)}`, + "", + ]; + const details = dynamicDetails(dynamic, plan, locale); + if (details.length > 0) { + lines.push(locale === "de-DE" ? "Was die Automatik macht:" : "What the automation does:", ""); + for (const detail of details) lines.push(`- ${escapeMarkdown(detail)}`); + lines.push(""); + } + if (ownedFields.length > 0) { + lines.push( + ...table( + [ + locale === "de-DE" ? "Feld" : "Field", + locale === "de-DE" ? "Technischer Name" : "Technical name", + locale === "de-DE" ? "Art" : "Type", + locale === "de-DE" ? "Standardwert" : "Default", + locale === "de-DE" ? "Auswahlwerte" : "Options", + locale === "de-DE" ? "Anmeldung" : "Registration", + locale === "de-DE" ? "Sicherheitsstufe" : "Security level", + ].map(escapeMarkdown), + ownedFields.map((field) => { + const value = changedFields(field); + const registration = value.useInRegistrationForm + ? value.requiredInRegistrationForm + ? locale === "de-DE" + ? "sichtbar, Pflichtfeld" + : "visible, required" + : locale === "de-DE" + ? "sichtbar, freiwillig" + : "visible, optional" + : locale === "de-DE" + ? "nicht im Formular" + : "not in form"; + const options = Array.isArray(value.options) + ? value.options.map((option) => stableJson(option)).join(", ") + : copy.none; + return [ + value.name, + value.referenceName, + MEMBER_FIELD_TYPE[locale][String(value.fieldTypeCode)] ?? value.fieldTypeCode, + value.defaultValue ?? copy.none, + options, + registration, + value.securityLevel ?? copy.unknown, + ].map(escapeMarkdown); + }), + ), + "", + ); + } + return lines; +} + +function renderResourceSection( + title: string, + items: PlanItem[], + plan: Plan, + locale: PlanMarkdownLocale, + copy: Copy, +): string[] { + if (items.length === 0) return []; + const lines = [`## ${title}`, ""]; + for (const item of items) { + lines.push(resourceHeading(item, locale), ""); + lines.push(...renderGroupOverview(item, plan, locale, copy)); + if (item.note === "recreate") { + lines.push( + locale === "de-DE" + ? "Die Ressource fehlt in ChurchTools und wird mit einer neuen ID wieder angelegt." + : "The resource is missing in ChurchTools and will be recreated with a new ID.", + "", + ); + } + if (item.changes.length > 0 && item.type !== "group") { + lines.push( + ...table( + [copy.field, copy.before, copy.after, copy.reason].map(escapeMarkdown), + changeRows(item.changes, locale, copy), + ), + "", + ); + } + } + return lines; +} + +function catalogRight(authId: number): { technical: string; description: string } | null { + const found = Object.entries(CATALOG).find(([, entry]) => entry.authId === authId); + return found ? { technical: found[0], description: found[1].desc } : null; +} + +function tupleRight(tuple: GrantTuple): string { + const right = catalogRight(tuple.authId); + return right ? `${right.description} (${right.technical}, #${tuple.authId})` : `authId #${tuple.authId}`; +} + +function tupleScope(tuple: GrantTuple, copy: Copy): string { + if (tuple.pending && tuple.scopeKey) return `${tuple.scopeKey} (${copy.pending})`; + if (tuple.scopeKey) return tuple.scopeKey; + return tuple.dataId.length > 0 ? tuple.dataId.map((id) => `#${id}`).join(", ") : copy.none; +} + +function permissionDomain(item: PermissionPlanItem, locale: PlanMarkdownLocale, copy: Copy): string { + const label = DOMAIN_LABELS[locale][item.domainType] ?? item.domainType; + const id = item.pendingDomain ? `${refLabel(item.pendingDomain)} (${copy.pending})` : `#${item.domainId}`; + return `${label} ${item.key} (${id})`; +} + +function permissionCounts(items: PermissionPlanItem[]): { + grants: number; + revocations: number; + preserved: number; +} { + return { + grants: items.reduce((sum, item) => sum + item.diff.toPut.length, 0), + revocations: items.reduce((sum, item) => sum + item.diff.toDelete.length, 0), + preserved: items.reduce( + (sum, item) => sum + item.diff.preserved.length + item.diff.preservedUnknown.length, + 0, + ), + }; +} + +function renderPermissions(items: PermissionPlanItem[], locale: PlanMarkdownLocale, copy: Copy): string[] { + const visible = items.filter( + (item) => + item.diff.toPut.length + + item.diff.toDelete.length + + item.diff.preserved.length + + item.diff.preservedUnknown.length > + 0, + ); + if (visible.length === 0) return []; + const lines = [`## ${copy.permissions}`, ""]; + for (const item of visible) { + lines.push(`### ${escapeMarkdown(permissionDomain(item, locale, copy))}`, ""); + const rows: string[][] = []; + for (const tuple of item.diff.toPut) { + rows.push([ + escapeMarkdown(tupleRight(tuple)), + escapeMarkdown(tupleScope(tuple, copy)), + escapeMarkdown(copy.grants), + ]); + } + for (const tuple of item.diff.toDelete) { + rows.push([ + escapeMarkdown(tupleRight(tuple)), + escapeMarkdown(tupleScope(tuple, copy)), + escapeMarkdown(copy.revocations), + ]); + } + for (const tuple of [...item.diff.preservedUnknown, ...item.diff.preserved]) { + rows.push([ + escapeMarkdown(tupleRight(tuple)), + escapeMarkdown(tupleScope(tuple, copy)), + escapeMarkdown(copy.permissionPreserved), + ]); + } + lines.push(...table([copy.right, copy.scope, copy.effect].map(escapeMarkdown), rows), ""); + } + return lines; +} + +function renderDrift(plan: Plan, locale: PlanMarkdownLocale, copy: Copy): string[] { + const items = plan.items.filter((item) => (item.drift?.length ?? 0) > 0); + if (items.length === 0) return []; + const lines = [`## ${copy.drift}`, ""]; + for (const item of items) { + lines.push(resourceHeading(item, locale), ""); + lines.push( + ...table( + [copy.field, copy.before, copy.after].map(escapeMarkdown), + (item.drift ?? []).map((change) => [ + escapeMarkdown(fieldLabel(change.field, locale)), + renderValue(change.from, change.field, copy), + renderValue(change.to, change.field, copy), + ]), + ), + "", + ); + } + return lines; +} + +function renderAppendix(plan: Plan, locale: PlanMarkdownLocale, copy: Copy): string[] { + const rows = plan.items.map((item) => { + const safeChanges = (changes: FieldChange[] | undefined): FieldChange[] | undefined => + changes?.map((change) => + SECRET_KEY.test(change.field) + ? { ...change, from: change.from === undefined ? undefined : "[REDACTED]", to: "[REDACTED]" } + : change, + ); + const technical = { + changes: safeChanges(item.changes), + drift: safeChanges(item.drift), + preventDestroy: item.preventDestroy, + detail: item.detail, + }; + return [ + escapeMarkdown(typeLabel(item.type, locale)), + escapeMarkdown(item.key), + escapeMarkdown(item.id ?? "—"), + escapeMarkdown(ACTION_LABELS[locale][item.action]), + escapeMarkdown(item.note ?? "—"), + escapeMarkdown(stableJson(technical)), + ]; + }); + return [ + `## ${copy.appendix}`, + "", + ...table( + [copy.resourceType, copy.logicalKey, copy.id, copy.action, copy.note, copy.details].map(escapeMarkdown), + rows, + ), + "", + ]; +} + +export function renderPlanMarkdown( + plan: Plan, + permissions: PermissionPlanItem[], + context: MarkdownPlanContext, +): string { + const locale = context.locale ?? "de-DE"; + const copy = COPY[locale]; + const generatedAt = context.generatedAt ?? new Date(); + const counts = { create: 0, update: 0, delete: 0, "no-op": 0 } satisfies Record; + for (const item of plan.items) counts[item.action]++; + const perms = permissionCounts(permissions); + const incompleteItems = plan.items.filter((item) => item.note === "fetch-failed"); + const fetchErrors = context.fetchErrors ?? []; + const incomplete = incompleteItems.length > 0 || fetchErrors.length > 0; + const warnings = [...new Set((context.warnings ?? []).map(redactPlanReportSecrets))]; + + const lines: string[] = [ + `# ${copy.title}`, + "", + locale === "de-DE" + ? `Erstellt am ${new Intl.DateTimeFormat("de-DE", { dateStyle: "long", timeStyle: "short", timeZone: "UTC" }).format(generatedAt)} UTC.` + : `Generated ${new Intl.DateTimeFormat("en", { dateStyle: "long", timeStyle: "short", timeZone: "UTC" }).format(generatedAt)} UTC.`, + "", + `> ${copy.planOnly}`, + "", + `## ${copy.context}`, + "", + ...table( + [copy.result, copy.details].map(escapeMarkdown), + [ + [copy.environment, context.environment ?? copy.none], + [copy.host, context.host], + [copy.churchToolsVersion, context.churchToolsVersion ?? copy.unknown], + [copy.config, context.configPath], + [copy.stateHost, context.stateHost], + ].map(([key, value]) => [escapeMarkdown(key), escapeMarkdown(value)]), + ), + "", + `## ${copy.summary}`, + "", + ]; + + const hasChanges = counts.create + counts.update + counts.delete + perms.grants + perms.revocations > 0; + lines.push( + `- ${copy.create}: ${counts.create}`, + `- ${copy.update}: ${counts.update}`, + `- ${copy.delete}: ${counts.delete}`, + `- ${copy.noop}: ${counts["no-op"]}`, + `- ${copy.grants}: ${perms.grants}`, + `- ${copy.revocations}: ${perms.revocations}`, + `- ${copy.preserved}: ${perms.preserved}`, + "", + ); + if (!hasChanges && !incomplete) { + lines.push(`**${copy.result}:** ${copy.noChanges}`, ""); + } else if (counts.update === 0 && counts.delete === 0 && perms.revocations === 0 && !incomplete) { + lines.push( + locale === "de-DE" + ? "**Ergebnis:** Der Plan ergänzt ausschließlich neue Ressourcen oder Berechtigungen. Bestehende Ressourcen werden nicht verändert oder gelöscht." + : "**Result:** The plan only adds resources or permissions. Existing resources are not changed or deleted.", + "", + ); + } + + const byType = new Map>(); + for (const item of plan.items) { + const current = byType.get(item.type) ?? { create: 0, update: 0, delete: 0, "no-op": 0 }; + current[item.action]++; + byType.set(item.type, current); + } + if (byType.size > 0) { + lines.push( + `### ${copy.resourceTypes}`, + "", + ...table( + [copy.resourceType, copy.create, copy.update, copy.delete, copy.noop].map(escapeMarkdown), + [...byType.entries()] + .sort(([a], [b]) => typeLabel(a, locale).localeCompare(typeLabel(b, locale), locale)) + .map(([type, value]) => [ + escapeMarkdown(typeLabel(type, locale)), + String(value.create), + String(value.update), + String(value.delete), + String(value["no-op"]), + ]), + ), + "", + ); + } + + lines.push( + ...renderResourceSection( + copy.creates, + plan.items.filter( + (item) => + item.action === "create" && + (item.type !== "group-member-field" || + !plan.items.some((group) => memberFieldsFor(group, plan).includes(item))), + ), + plan, + locale, + copy, + ), + ...renderResourceSection( + copy.updates, + plan.items.filter((item) => item.action === "update"), + plan, + locale, + copy, + ), + ); + + const deleteCandidates = plan.items.filter((item) => item.action === "delete"); + if (deleteCandidates.length > 0) { + lines.push(`## ${copy.deleteCandidates}`, "", copy.deleteSafety, ""); + for (const item of deleteCandidates) lines.push(resourceHeading(item, locale), ""); + } + + lines.push(...renderDrift(plan, locale, copy), ...renderPermissions(permissions, locale, copy)); + + if (incomplete) { + lines.push(`## ⚠️ ${copy.incompleteTitle}`, "", copy.incompleteText, ""); + const entries = [ + ...incompleteItems.map((item) => `${item.type}.${item.key}: ${item.detail ?? copy.unknown}`), + ...fetchErrors, + ]; + for (const entry of [...new Set(entries)]) + lines.push(`- ${escapeMarkdown(redactPlanReportSecrets(entry))}`); + lines.push(""); + } + + if (warnings.length > 0) { + lines.push(`## ${copy.warnings}`, ""); + for (const warning of warnings) lines.push(`- ${escapeMarkdown(warning)}`); + lines.push(""); + } + + lines.push(`## ${copy.review}`, ""); + for (const item of copy.checklist) lines.push(`- [ ] ${item}`); + lines.push("", ...renderAppendix(plan, locale, copy)); + + return `${lines + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd()}\n`; +} diff --git a/src/engine/plan.ts b/src/engine/plan.ts index faeed29..0517a5a 100644 --- a/src/engine/plan.ts +++ b/src/engine/plan.ts @@ -11,7 +11,13 @@ * types. Logical keys are globally unique in the state file. */ import type { State } from "../state/state.js"; -import type { DesiredResource, FieldChange, Plan, PlanItem } from "./types.js"; +import { + resourceDisplayName, + type DesiredResource, + type FieldChange, + type Plan, + type PlanItem, +} from "./types.js"; import { orderKeys, isKnownType } from "./graph.js"; import { RESOURCES } from "../resources/registry.js"; @@ -177,6 +183,7 @@ export function computePlan( creates.push({ type: d.type, key: d.key, + displayName: resourceDisplayName(d.fields, d.key), id: null, action: "create", changes: attributeCreate(diffFields(d.fields, {})), @@ -196,6 +203,7 @@ export function computePlan( updates.push({ type: d.type, key: d.key, + displayName: resourceDisplayName(d.fields, d.key), id: managed.id, action: "no-op", changes: [], @@ -209,6 +217,7 @@ export function computePlan( updates.push({ type: d.type, key: d.key, + displayName: resourceDisplayName(d.fields, d.key), id: managed.id, action: "no-op", changes: [], @@ -223,6 +232,7 @@ export function computePlan( creates.push({ type: d.type, key: d.key, + displayName: resourceDisplayName(d.fields, d.key), id: null, action: "create", changes: attributeCreate(diffFields(d.fields, {})), @@ -239,6 +249,7 @@ export function computePlan( updates.push({ type: d.type, key: d.key, + displayName: resourceDisplayName(d.fields, d.key), id: managed.id, action: changes.length > 0 ? "update" : "no-op", changes: attributeChanges(changes, managed.fields, driftedFields), @@ -257,6 +268,7 @@ export function computePlan( deletes.push({ type: managed.type, key: managed.key, + displayName: resourceDisplayName(managed.fields, managed.key), id: managed.id, action: "no-op", changes: [], @@ -269,6 +281,7 @@ export function computePlan( deletes.push({ type: managed.type, key: managed.key, + displayName: resourceDisplayName(managed.fields, managed.key), id: managed.id, action: "no-op", changes: [], @@ -283,6 +296,7 @@ export function computePlan( deletes.push({ type: managed.type, key: managed.key, + displayName: resourceDisplayName(managed.fields, managed.key), id: managed.id, action: "no-op", changes: [], @@ -290,7 +304,14 @@ export function computePlan( }); continue; } - deletes.push({ type: managed.type, key: managed.key, id: managed.id, action: "delete", changes: [] }); + deletes.push({ + type: managed.type, + key: managed.key, + displayName: resourceDisplayName(managed.fields, managed.key), + id: managed.id, + action: "delete", + changes: [], + }); } const rank = new Map(orderKeys(desired).map((key, i) => [key, i])); diff --git a/src/engine/synthetic.ts b/src/engine/synthetic.ts index c742d5f..6289f90 100644 --- a/src/engine/synthetic.ts +++ b/src/engine/synthetic.ts @@ -95,6 +95,8 @@ export interface SyntheticFoldResult { desired: DesiredResource[]; errors: string[]; unreadable?: string[]; + /** Informational safety/portability warnings to embed in non-terminal projections. */ + warnings?: string[]; } export interface SyntheticField { @@ -471,6 +473,7 @@ const dynamicField: SyntheticField = { const errors = perGroupOutcome.flatMap((o) => o.errors); const unreadable = perGroupOutcome.flatMap((o) => o.unreadable); const unreadableKeys = new Set(unreadable); + const warnings: string[] = []; const augmented = desired.map((d) => { if (d.type !== "group" || d.dynamic === undefined) return d; // Actual side unknown → leave the desired side unfolded so nothing diffs. `buildPlan` turns @@ -490,16 +493,18 @@ const dynamicField: SyntheticField = { // someone notices the membership. Warn, never fail: the numeric form stays a valid escape hatch. const unportable = scanUnportablized(resolvedRuleset); if (unportable.length > 0) { - warn( + const headline = `dynamic group "${d.key}": ruleset carries ${unportable.length} host-specific id(s) — ` + - `not portable to another instance:`, - ); - for (const line of formatPortablizeWarnings(unportable)) info(` ${line}`); + `not portable to another instance:`; + const details = formatPortablizeWarnings(unportable); + warnings.push(headline, ...details); + warn(headline); + for (const line of details) info(` ${line}`); } const dynamic = normalizeDynamic({ status: d.dynamic.status, ruleset: resolvedRuleset }); return { ...d, fields: { ...d.fields, dynamic } }; }); - return { desired: augmented, errors, unreadable }; + return { desired: augmented, errors, unreadable, ...(warnings.length > 0 ? { warnings } : {}) }; }, async apply({ client, id, change }) { const to = change.to as { status: DynamicStatus; ruleset: Record } | undefined; @@ -593,15 +598,17 @@ export async function runPostApplyHooks( /** Run every registered fold in order, threading the (immutably) augmented desired through each. */ export async function foldSynthetic( ctx: SyntheticFoldCtx, -): Promise<{ desired: DesiredResource[]; errors: string[]; unreadable: Set }> { +): Promise<{ desired: DesiredResource[]; errors: string[]; unreadable: Set; warnings?: string[] }> { let desired = ctx.desired; const errors: string[] = []; const unreadable = new Set(); + const warnings: string[] = []; for (const f of SYNTHETIC_FIELDS) { const res = await f.fold({ ...ctx, desired }); desired = res.desired; errors.push(...res.errors); + warnings.push(...(res.warnings ?? [])); for (const key of res.unreadable ?? []) unreadable.add(key); } - return { desired, errors, unreadable }; + return { desired, errors, unreadable, ...(warnings.length > 0 ? { warnings } : {}) }; } diff --git a/src/engine/types.ts b/src/engine/types.ts index 250a14a..9d2361e 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -84,6 +84,8 @@ export interface FieldChange { export interface PlanItem { type: string; key: string; + /** Best available human-facing name, derived once from the desired/state field bag. */ + displayName?: string; /** CT id when known (updates/deletes); null for creates. */ id: number | null; action: PlanAction; @@ -115,6 +117,15 @@ export interface PlanItem { allowDuplicateName?: boolean; } +/** Pick a stable human-facing label without coupling renderers to resource-specific branches. */ +export function resourceDisplayName(fields: Record, fallback: string): string { + for (const field of ["name", "nameTranslated", "shorty", "title", "label"]) { + const value = fields[field]; + if (typeof value === "string" && value.trim() !== "") return value.trim(); + } + return fallback; +} + export interface Plan { /** Items in execution order: creates/updates in dependency order, deletes in reverse. */ items: PlanItem[]; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 310fd44..19c6075 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -49,6 +49,12 @@ describe("ct program", () => { } }); + it("registers the plan format, locale and sidecar output options (#144)", () => { + const cmd = buildProgram().commands.find((candidate) => candidate.name() === "plan")!; + const options = cmd.options.map((option) => option.long); + expect(options).toEqual(expect.arrayContaining(["--format", "--output-base", "--locale", "--json"])); + }); + it("registers --env on auth status and auth logout (#117)", () => { const auth = buildProgram().commands.find((c) => c.name() === "auth")!; for (const name of ["status", "logout"]) { diff --git a/tests/plan-env-command.test.ts b/tests/plan-env-command.test.ts index 24dd76e..64b7d0d 100644 --- a/tests/plan-env-command.test.ts +++ b/tests/plan-env-command.test.ts @@ -8,7 +8,7 @@ * against a DIFFERENT host is rejected by the state host-check. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { writeFile, rm } from "node:fs/promises"; +import { readFile, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Plan } from "../src/engine/types.js"; @@ -44,6 +44,7 @@ vi.mock("../src/permissions/plan.js", () => ({ })); const { planCommand } = await import("../src/commands/plan.js"); +const { buildPlan } = await import("../src/engine/build.js"); const { saveState, emptyState } = await import("../src/state/state.js"); const DEV = "https://mychurch-dev.church.tools"; @@ -56,12 +57,15 @@ const saved = { host: process.env.CT_HOST, envs: process.env.CT_ENVS }; let stderr = ""; let stderrSpy: { mockRestore: () => void }; +let stdout = ""; +let stdoutSpy: { mockRestore: () => void }; async function runPlan(args: string[]): Promise { await planCommand().parseAsync(args, { from: "user" }); } beforeEach(async () => { + vi.mocked(buildPlan).mockClear(); delete process.env.CT_HOST; process.env.CT_ENVS = envsPath; await writeFile( @@ -81,10 +85,16 @@ beforeEach(async () => { stderr += String(chunk); return true; }) as (typeof process.stderr)["write"]); + stdout = ""; + stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => { + stdout += String(chunk); + return true; + }) as (typeof process.stdout)["write"]); }); afterEach(async () => { stderrSpy.mockRestore(); + stdoutSpy.mockRestore(); if (saved.host === undefined) delete process.env.CT_HOST; else process.env.CT_HOST = saved.host; if (saved.envs === undefined) delete process.env.CT_ENVS; @@ -111,6 +121,45 @@ describe("ct plan --env", () => { expect(stderr).toContain(`state host: ${PROD}`); }); + it("renders the same env-bound plan as a self-contained Markdown report (#144)", async () => { + await runPlan(["--env", "dev", "--format", "markdown", "--locale", "de-DE"]); + expect(stdout).toContain("# ChurchTools-Änderungsplan"); + expect(stdout).toContain("> Dieser Bericht beschreibt nur den Plan."); + expect(stdout).toContain(`| ChurchTools-Instanz | ${DEV} |`); + expect(stdout).toContain("| Umgebung | dev |"); + expect(stdout).toContain("ChurchTools-Version | 3.100.0"); + expect(stdout).toContain("Es sind keine Änderungen erforderlich"); + }); + + it("writes text, JSON and Markdown sidecars after one plan computation (#144)", async () => { + const outputBase = join(tmpdir(), `ct-cli-planenv-report-${process.pid}`); + try { + await runPlan([ + "--env", + "dev", + "--format", + "text", + "--format", + "json", + "--format", + "markdown", + "--output-base", + outputBase, + ]); + expect(buildPlan).toHaveBeenCalledTimes(1); + expect(stdout).toBe(""); + expect(await readFile(`${outputBase}.txt`, "utf8")).toContain("No changes"); + expect(JSON.parse(await readFile(`${outputBase}.json`, "utf8"))).toMatchObject({ + summary: { hasChanges: false }, + }); + expect(await readFile(`${outputBase}.md`, "utf8")).toContain("# ChurchTools-Änderungsplan"); + } finally { + await Promise.all( + ["txt", "json", "md"].map((extension) => rm(`${outputBase}.${extension}`, { force: true })), + ); + } + }); + it("refuses when an env's state file was recorded against a different host (no cross-contamination)", async () => { // Point prod's profile at dev's state file: prod host vs a dev-bound state file must be rejected. await writeFile( diff --git a/tests/plan-markdown.test.ts b/tests/plan-markdown.test.ts new file mode 100644 index 0000000..5b2cafe --- /dev/null +++ b/tests/plan-markdown.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; +import { planOutputTargets, parsePlanLocale } from "../src/commands/plan.js"; +import { escapeMarkdown, redactPlanReportSecrets, renderPlanMarkdown } from "../src/engine/markdown.js"; +import type { Plan } from "../src/engine/types.js"; +import type { PermissionPlanItem } from "../src/permissions/plan.js"; + +const context = { + environment: "test", + host: "https://example.church.tools", + churchToolsVersion: "3.135.2", + configPath: "ct.config.ts", + stateHost: "https://example.church.tools", + generatedAt: new Date("2026-08-24T12:34:00.000Z"), + locale: "de-DE" as const, +}; + +const permission = (overrides: Partial = {}): PermissionPlanItem => ({ + key: "team_lead", + domainType: "group_type_role", + domainId: 8, + diff: { + toPut: [{ authId: 1113, dataId: [], type: "grant" }], + toDelete: [{ authId: 1104, dataId: [42], type: "grant" }], + preserved: [{ authId: 100, dataId: [], type: "revoke" }], + preservedUnknown: [{ authId: 113, dataId: [7], type: "grant" }], + }, + ...overrides, +}); + +describe("plan output targets", () => { + it("keeps the default and --json stdout behavior backward compatible", () => { + expect(planOutputTargets({})).toEqual([{ format: "text", path: undefined }]); + expect(planOutputTargets({ json: true })).toEqual([{ format: "json", path: undefined }]); + }); + + it("derives deterministic sidecar names while computing the plan only once", () => { + expect( + planOutputTargets({ + format: ["text", "json", "markdown"], + outputBase: "reports/plan-prod", + }), + ).toEqual([ + { format: "text", path: "reports/plan-prod.txt" }, + { format: "json", path: "reports/plan-prod.json" }, + { format: "markdown", path: "reports/plan-prod.md" }, + ]); + expect(planOutputTargets({ format: ["markdown"], outputBase: "reports/plan.md" })).toEqual([ + { format: "markdown", path: "reports/plan.md" }, + ]); + }); + + it("rejects ambiguous or unsupported output selections before planning", () => { + expect(() => planOutputTargets({ json: true, format: ["markdown"] })).toThrow("cannot be combined"); + expect(() => planOutputTargets({ format: ["text", "markdown"] })).toThrow("require --output-base"); + expect(() => planOutputTargets({ outputBase: "plan" })).toThrow("requires"); + expect(() => planOutputTargets({ format: ["pdf"] })).toThrow("Unknown plan format"); + expect(() => parsePlanLocale("fr")).toThrow("Available locales"); + }); +}); + +describe("plain-language Markdown plan", () => { + it("renders the proven consumer-report structure for an unchanged plan", () => { + const output = renderPlanMarkdown({ items: [] }, [], context); + expect(output).toContain("# ChurchTools-Änderungsplan"); + expect(output).toContain("> Dieser Bericht beschreibt nur den Plan."); + expect(output).toContain("## Zusammenfassung"); + expect(output).toContain("**Ergebnis:** Es sind keine Änderungen erforderlich"); + expect(output).toContain("## Prüfung vor dem Anwenden"); + }); + + it("explains mixed resources, automation, member fields, drift, deletes and permissions", () => { + const plan: Plan = { + items: [ + { + type: "group-type", + key: "team", + displayName: "Team", + id: 2, + action: "no-op", + changes: [], + }, + { + type: "group", + key: "source", + displayName: "Quellgruppe", + id: 42, + action: "no-op", + changes: [], + }, + { + type: "group", + key: "new_team", + displayName: "Neues | Team", + id: null, + action: "create", + changes: [ + { field: "name", from: undefined, to: "Neues | Team", source: "config" }, + { field: "groupTypeId", from: undefined, to: 2, source: "config" }, + { field: "groupStatusId", from: undefined, to: 1, source: "config" }, + { field: "parents", from: undefined, to: ["source"], source: "config" }, + { + field: "dynamic", + from: undefined, + to: { + status: "active", + ruleset: { + query: { "==": [{ var: "ctgroup.id" }, 42] }, + process: { queryResultOnly: { none: { handleMembership: { groupTypeRoleId: 9 } } } }, + }, + }, + source: "config", + }, + ], + }, + { + type: "group-member-field", + key: "new_team::consent", + displayName: "Einwilligung", + id: null, + action: "create", + changes: [ + { field: "name", from: undefined, to: "Einwilligung", source: "config" }, + { field: "referenceName", from: undefined, to: "consent", source: "config" }, + { field: "fieldTypeCode", from: undefined, to: "checkbox", source: "config" }, + { field: "useInRegistrationForm", from: undefined, to: true, source: "config" }, + { field: "requiredInRegistrationForm", from: undefined, to: true, source: "config" }, + { field: "securityLevel", from: undefined, to: 2, source: "config" }, + ], + }, + { + type: "campus", + key: "mainz", + displayName: "Mainz", + id: 1, + action: "update", + changes: [{ field: "name", from: "MZ", to: "Mainz", source: "config+drift" }], + drift: [{ field: "name", from: "Mainz alt", to: "MZ" }], + }, + { + type: "future-type", + key: "old_entry", + displayName: "Alt `intern`", + id: 99, + action: "delete", + changes: [], + }, + ], + }; + + const output = renderPlanMarkdown(plan, [permission()], context); + expect(output).toContain("## Neue Ressourcen"); + expect(output).toContain("### Neues \\| Team"); + expect(output).toContain("Quelle der Teilnehmenden: Quellgruppe"); + expect(output).toContain("Neue Mitgliedschaft verwendet die aufgelöste Rollen-ID 9"); + expect(output).toContain("Einwilligung"); + expect(output).toContain("sichtbar, Pflichtfeld"); + expect(output).toContain("Konfiguration und ChurchTools wurden unabhängig geändert"); + expect(output).toContain("## Erkannte manuelle Änderungen"); + expect(output).toContain("`ct apply` löscht diese Ressourcen **nicht**"); + expect(output).toContain("## Berechtigungen"); + expect(output).toContain("Zu entziehen"); + expect(output).toContain("wird nicht verwaltet und bleibt erhalten"); + expect(output).toContain("future-type"); + expect(output).toContain("Alt `intern`"); + expect(output).not.toContain("[object Object]"); + }); + + it("marks incomplete plans as blocking and includes deduplicated warnings", () => { + const plan: Plan = { + items: [ + { + type: "group", + key: "broken", + displayName: "Nicht lesbar", + id: 7, + action: "no-op", + changes: [], + note: "fetch-failed", + detail: "HTTP 500", + }, + ], + }; + const output = renderPlanMarkdown(plan, [], { + ...context, + warnings: ["Katalog veraltet", "Katalog veraltet"], + fetchErrors: ["group.broken: Serverfehler"], + }); + expect(output).toContain("## ⚠️ Unvollständiger Plan"); + expect(output).toContain("darf nicht als Freigabe"); + expect(output.match(/Katalog veraltet/g)).toHaveLength(1); + }); + + it("escapes Markdown and redacts likely secrets in every fallback", () => { + expect(escapeMarkdown("A|B\n`x` #1")).toBe("A\\|B
`x` \\#1"); + expect(redactPlanReportSecrets("https://user:pass@example.test?a=1&login_token=secret")).toBe( + "https://[REDACTED]@example.test?a=1&login_token=[REDACTED]", + ); + const output = renderPlanMarkdown( + { + items: [ + { + type: "future", + key: "secret", + id: null, + action: "create", + changes: [ + { field: "apiToken", from: undefined, to: "must-not-leak" }, + { field: "payload", from: undefined, to: { password: "hidden", visible: "ok" } }, + ], + }, + ], + }, + [], + context, + ); + expect(output).not.toContain("must-not-leak"); + expect(output).not.toContain("hidden"); + expect(output).toContain("REDACTED"); + }); + + it("is byte-identical for a fixed clock and supports English without changing plan semantics", () => { + const plan: Plan = { + items: [{ type: "campus", key: "mainz", displayName: "Mainz", id: 1, action: "no-op", changes: [] }], + }; + expect(renderPlanMarkdown(plan, [], context)).toBe(renderPlanMarkdown(plan, [], context)); + const english = renderPlanMarkdown(plan, [], { ...context, locale: "en" }); + expect(english).toContain("# ChurchTools change plan"); + expect(english).toContain("No changes are required"); + }); +}); diff --git a/tests/synthetic-dynamic.test.ts b/tests/synthetic-dynamic.test.ts index bb76c2d..267abdb 100644 --- a/tests/synthetic-dynamic.test.ts +++ b/tests/synthetic-dynamic.test.ts @@ -482,14 +482,16 @@ describe("dynamic synthetic field — un-portablized ruleset reporting (#101)", errs.push(String(s)); return true; }); + let result: Awaited["fold"]>> | undefined; try { - await dynamicField().fold({ client: getClient(client), state, desired, actual }); + result = await dynamicField().fold({ client: getClient(client), state, desired, actual }); } finally { spy.mockRestore(); } const out = errs.join(""); expect(out).toContain('dynamic group "g": ruleset carries 1 host-specific id(s)'); expect(out).toMatch(/ctgroup\.id: 1246 left numeric/); + expect(result?.warnings?.join("\n")).toContain("ctgroup.id: 1246 left numeric"); }); it("stays silent for a fully portable ruleset — the warning must mean something", async () => { From 515e994d7a041ae0ead2f9d78359e84cd86a78b5 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Wed, 26 Aug 2026 19:55:21 +0200 Subject: [PATCH 2/2] fix(plan): render group updates, new member fields and pending group types Review of #155 found three content paths where the Markdown report dropped or garbled information it claims to show: - a group *update* rendered a heading and nothing else, because the change table was suppressed for every group while the overview only renders for creates. The table is now suppressed only when an overview was actually rendered. - a created group-member-field on an existing group was filtered out of the creates and never picked up by the create-only overview, so it appeared nowhere but the technical appendix. `memberFieldsFor` now only absorbs fields of a group that is itself being created. - a `groupTypeId` pending ref (the bootstrap case) rendered as `Gruppentyp: unbekannt (#[object Object])`. It now resolves to the pending group type's name. Claude-Session: https://claude.ai/code/session_01RDZeavzUpPyvQp3EYdEDbR --- src/engine/markdown.ts | 40 ++++++++++++++++----- tests/plan-markdown.test.ts | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/engine/markdown.ts b/src/engine/markdown.ts index 8d0e7df..d241b88 100644 --- a/src/engine/markdown.ts +++ b/src/engine/markdown.ts @@ -381,11 +381,17 @@ function sourceLabel(source: FieldChangeSource | undefined, copy: Copy): string return copy.configReason; } -function valueText(value: unknown, copy: Copy): string { - if (value === undefined) return copy.none; +function pendingRefOf(value: unknown): Parameters[0] | null { if (value !== null && typeof value === "object" && "__pendingRef" in value) { - return `${refLabel((value as { __pendingRef: Parameters[0] }).__pendingRef)} (${copy.pending})`; + return (value as { __pendingRef: Parameters[0] }).__pendingRef; } + return null; +} + +function valueText(value: unknown, copy: Copy): string { + if (value === undefined) return copy.none; + const pending = pendingRefOf(value); + if (pending) return `${refLabel(pending)} (${copy.pending})`; if (Array.isArray(value)) return value.length > 0 ? value.map((v) => stableJson(v)).join(", ") : copy.none; return stableJson(value); } @@ -554,7 +560,11 @@ function dynamicDetails(dynamic: unknown, plan: Plan, locale: PlanMarkdownLocale } function memberFieldsFor(group: PlanItem, plan: Plan): PlanItem[] { - if (group.type !== "group") return []; + // Only a group that is itself being created renders its member fields inline (see + // `renderGroupOverview`). For any other group the fields must stay in their own section, + // otherwise a new member field on an existing group is filtered out of the creates and + // rendered nowhere but the technical appendix. + if (group.type !== "group" || group.action !== "create") return []; return plan.items.filter( (item) => item.action === "create" && @@ -576,11 +586,22 @@ function renderGroupOverview(item: PlanItem, plan: Plan, locale: PlanMarkdownLoc ? fields.parents.map((key) => names.get(String(key)) ?? String(key)) : []; const dynamic = fields.dynamic; - const groupType = groupTypeNames.get(Number(fields.groupTypeId)) ?? copy.unknown; + // `groupTypeId` is a pending ref whenever the group type is created in the same run + // (the bootstrap case). Resolving it as a number would render `#[object Object]`. + const groupTypeRef = pendingRefOf(fields.groupTypeId); + const groupTypeRefKey = groupTypeRef && "key" in groupTypeRef ? String(groupTypeRef.key) : null; + const groupType = groupTypeRef + ? ((groupTypeRefKey === null ? undefined : names.get(groupTypeRefKey)) ?? refLabel(groupTypeRef)) + : (groupTypeNames.get(Number(fields.groupTypeId)) ?? copy.unknown); + const groupTypeSuffix = groupTypeRef + ? ` (${copy.pending})` + : fields.groupTypeId === undefined + ? "" + : ` (#${escapeMarkdown(fields.groupTypeId)})`; const ownedFields = memberFieldsFor(item, plan); const lines = [ `- ${locale === "de-DE" ? "Technischer Schlüssel" : "Technical key"}: \`${escapeMarkdown(item.key)}\``, - `- ${locale === "de-DE" ? "Gruppentyp" : "Group type"}: ${escapeMarkdown(groupType)}${fields.groupTypeId === undefined ? "" : ` (#${escapeMarkdown(fields.groupTypeId)})`}`, + `- ${locale === "de-DE" ? "Gruppentyp" : "Group type"}: ${escapeMarkdown(groupType)}${groupTypeSuffix}`, `- ${locale === "de-DE" ? "Gruppenstatus" : "Group status"}: ${escapeMarkdown(fields.groupStatusId ?? copy.unknown)}`, `- ${locale === "de-DE" ? "Übergeordnete Gruppe(n)" : "Parent group(s)"}: ${escapeMarkdown(parents.length > 0 ? parents.join(", ") : copy.none)}`, `- ${locale === "de-DE" ? "Neue Gruppenmitgliedsfelder" : "New group member fields"}: ${ownedFields.length}`, @@ -649,7 +670,8 @@ function renderResourceSection( const lines = [`## ${title}`, ""]; for (const item of items) { lines.push(resourceHeading(item, locale), ""); - lines.push(...renderGroupOverview(item, plan, locale, copy)); + const overview = renderGroupOverview(item, plan, locale, copy); + lines.push(...overview); if (item.note === "recreate") { lines.push( locale === "de-DE" @@ -658,7 +680,9 @@ function renderResourceSection( "", ); } - if (item.changes.length > 0 && item.type !== "group") { + // Groups that rendered an overview already state their fields in prose; every other item — + // including a group *update* — still needs its before/after table (#155 review). + if (item.changes.length > 0 && overview.length === 0) { lines.push( ...table( [copy.field, copy.before, copy.after, copy.reason].map(escapeMarkdown), diff --git a/tests/plan-markdown.test.ts b/tests/plan-markdown.test.ts index 5b2cafe..1f3fdfa 100644 --- a/tests/plan-markdown.test.ts +++ b/tests/plan-markdown.test.ts @@ -218,6 +218,76 @@ describe("plain-language Markdown plan", () => { expect(output).toContain("REDACTED"); }); + it("shows the before/after table for a group update instead of a bare heading", () => { + const plan: Plan = { + items: [ + { + type: "group", + key: "youth", + displayName: "Jugend", + id: 7, + action: "update", + changes: [{ field: "name", from: "Jugendkreis", to: "Jugend", source: "config" }], + }, + ], + }; + const output = renderPlanMarkdown(plan, [], context); + expect(output).toContain("## Geänderte Ressourcen"); + expect(output).toContain("### Jugend"); + expect(output).toContain("Jugendkreis"); + expect(output).toContain("| Feld |"); + }); + + it("renders a new member field on an existing group in its own section", () => { + const plan: Plan = { + items: [ + { type: "group", key: "youth", displayName: "Jugend", id: 7, action: "no-op", changes: [] }, + { + type: "group-member-field", + key: "youth::consent", + displayName: "Einwilligung", + id: null, + action: "create", + changes: [ + { field: "name", from: undefined, to: "Einwilligung", source: "config" }, + { field: "referenceName", from: undefined, to: "consent", source: "config" }, + ], + }, + ], + }; + const output = renderPlanMarkdown(plan, [], context); + expect(output).toContain("## Neue Ressourcen"); + expect(output).toContain("Einwilligung"); + }); + + it("names a group type that is created in the same run instead of printing the raw ref", () => { + const plan: Plan = { + items: [ + { type: "group-type", key: "team", displayName: "Team", id: null, action: "create", changes: [] }, + { + type: "group", + key: "new_team", + displayName: "Neues Team", + id: null, + action: "create", + changes: [ + { field: "name", from: undefined, to: "Neues Team", source: "config" }, + { + field: "groupTypeId", + from: undefined, + to: { __pendingRef: { kind: "group-type", key: "team" } }, + source: "config", + }, + ], + }, + ], + }; + const output = renderPlanMarkdown(plan, [], context); + expect(output).toContain("Gruppentyp: Team (wird beim Anwenden erzeugt)"); + expect(output).not.toContain("[object Object]"); + expect(output).not.toContain("unbekannt (#"); + }); + it("is byte-identical for a fixed clock and supports English without changing plan semantics", () => { const plan: Plan = { items: [{ type: "campus", key: "mainz", displayName: "Mainz", id: 1, action: "no-op", changes: [] }],