/** * Direct GitLab TemplateIR → `.gitlab-ci.yml` text emission. * * We deliberately bypass the existing `gitlabSerializer.serialize()` because * that path consumes `Map` (entity instances built by * user TS code), not a raw IR. Going through entity instances would require * runtime construction of typed objects we don't have. Direct emission is * ~60 LOC. */ import type { TemplateIR } from "@intentius/chant/import/parser"; import { emitYAML } from "@intentius/chant/yaml"; const RESERVED_TOP_LEVEL_ORDER = [ "spec", "include", "default", "stages", "variables", "workflow", "image", "services", "before_script", "after_script", "cache", "pages", ]; function isReservedTopLevelKey(key: string): boolean { return RESERVED_TOP_LEVEL_ORDER.includes(key); } function emitKeyValue(key: string, value: unknown, indent: number): string { const prefix = " ".repeat(indent); if (value === null || value === undefined) return `${prefix}${key}: null`; if (typeof value === "boolean" || typeof value === "number") return `${prefix}${key}: ${value}`; if (typeof value === "string") return `${prefix}${key}: ${emitYAML(value, indent + 1)}`; if (Array.isArray(value)) { if (value.length === 0) return `${prefix}${key}: []`; return `${prefix}${key}:${emitYAML(value, indent + 1)}`; } if (typeof value === "object") { if (Object.keys(value).length === 0) return `${prefix}${key}: {}`; return `${prefix}${key}:${emitYAML(value, indent + 1)}`; } return `${prefix}${key}: ${String(value)}`; } function emitBlock(obj: Record, indent: number): string { return Object.entries(obj) .filter(([, v]) => v !== undefined) .map(([k, v]) => emitKeyValue(k, v, indent)) .join("\n"); } /** * Convert a GitLab `TemplateIR` (produced by the GH→GL transformer) into * `.gitlab-ci.yml` text. */ export function emitGitlabYaml(ir: TemplateIR): string { const top: Record = {}; // Stages from metadata const meta = (ir.metadata ?? {}) as Record; if (Array.isArray(meta.stages) && meta.stages.length > 0) { top.stages = meta.stages; } // Top-level variables from metadata.variables if (meta.variables && typeof meta.variables === "object" && Object.keys(meta.variables).length > 0) { top.variables = meta.variables; } // Workflow / Default / Include resources go into reserved top-level keys for (const r of ir.resources) { if (r.type === "GitLab::CI::Workflow") { top.workflow = r.properties; } else if (r.type === "GitLab::CI::Default") { top.default = r.properties; } else if (r.type === "GitLab::CI::Include") { top.include = r.properties; } } // Job resources go at the top level keyed by logicalId (kebab-cased) const jobs: Record = {}; for (const r of ir.resources) { if (r.type === "GitLab::CI::Job") { const key = (r.metadata?.originalName as string) ?? r.logicalId; if (isReservedTopLevelKey(key)) { // Avoid colliding with reserved keywords; prefix jobs[`job-${key}`] = r.properties; } else { jobs[key] = r.properties; } } } // Emit ordered top-level keys (reserved first, then jobs) const lines: string[] = []; for (const key of RESERVED_TOP_LEVEL_ORDER) { if (top[key] !== undefined) { lines.push(emitKeyValue(key, top[key], 0)); } } // Migration banner comment if (meta.migration && typeof meta.migration === "object") { const m = meta.migration as Record; if (lines.length === 0) { lines.push(`# Generated by chant migrate from ${m.sourceFile ?? "(unknown)"}`); lines.push(""); } else { lines.unshift(""); lines.unshift(`# Generated by chant migrate from ${m.sourceFile ?? "(unknown)"}`); } } // Jobs (sorted by logicalId for determinism) const jobKeys = Object.keys(jobs).sort(); for (const key of jobKeys) { lines.push(""); lines.push(emitKeyValue(key, jobs[key], 0)); } return lines.join("\n") + "\n"; }