/** * GitLab CI/CD lexicon plugin. * * Provides serializer, template detection, and code generation * for GitLab CI/CD pipelines. */ import type { LexiconPlugin, IntrinsicDef, InitTemplateSet } from "@intentius/chant/lexicon"; import type { LintRule } from "@intentius/chant/lint/rule"; import { postSynthChecks as postSynthCheckList } from "./lint/post-synth"; import { gitlabAuditCatalog } from "./lint/audit-catalog"; import { createSkillsLoader, createDiffTool, createCatalogResource } from "@intentius/chant/lexicon-plugin-helpers"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { gitlabSerializer } from "./serializer"; import { gitlabContextTools } from "./mcp/context-tools"; import { deprecatedOnlyExceptRule } from "./lint/rules/deprecated-only-except"; import { missingScriptRule } from "./lint/rules/missing-script"; import { missingStageRule } from "./lint/rules/missing-stage"; import { artifactNoExpiryRule } from "./lint/rules/artifact-no-expiry"; import { gitlabCompletions } from "./lsp/completions"; import { gitlabHover } from "./lsp/hover"; import { GitLabParser } from "./import/parser"; import { GitLabGenerator } from "./import/generator"; import { generateGitlabPipeline } from "./components/generate-pipeline"; export const gitlabPlugin: LexiconPlugin = { name: "gitlab", auditCatalog: () => gitlabAuditCatalog, // Generate mode (#688): synthesize a .gitlab-ci.yml from the component graph. generateComponentPipeline: (components, options) => generateGitlabPipeline(components, options), // Self-upgrade: where the pinned GitLab schema version lives + its upstream (#685). upstreamPin: { file: "src/codegen/fetch.ts", pattern: /export const GITLAB_SCHEMA_VERSION\s*=\s*"([^"]+)"/, replace: (v, line) => line.replace(/export const GITLAB_SCHEMA_VERSION\s*=\s*"[^"]+"/, `export const GITLAB_SCHEMA_VERSION = "${v}"`), upstream: { owner: "gitlab-org", repo: "gitlab", kind: "tags", tagSuffix: "-ee" }, }, serializer: gitlabSerializer, lintRules(): LintRule[] { return [deprecatedOnlyExceptRule, missingScriptRule, missingStageRule, artifactNoExpiryRule]; }, postSynthChecks() { return postSynthCheckList; }, intrinsics(): IntrinsicDef[] { return [ { name: "reference", // Authored as a plain call — `reference("job", "script")` — not a JS // tagged template, so `isTag` (chant #1039: "authored as a tagged // template", consumed by the static folder to recognize `Tag`...``) // must be false. The `!reference` YAML tag it produces is rendered // via `ReferenceIntrinsic.toYAML()` (see ../serializer.ts's generic // `toYAML` duck-typing) — independent of this flag. description: "!reference tag — reference another job's properties", outputKey: "!reference", isTag: false, }, ]; }, initTemplates(template?: string): InitTemplateSet { if (template === "node-pipeline") { return { src: { "pipeline.ts": `import { NodePipeline } from "@intentius/chant-lexicon-gitlab"; export const app = NodePipeline({ nodeVersion: "22", installCommand: "npm install", buildScript: "build", testScript: "test", }); `, }, }; } if (template === "python-pipeline") { return { src: { "pipeline.ts": `import { PythonPipeline } from "@intentius/chant-lexicon-gitlab"; export const app = PythonPipeline({ pythonVersion: "3.12", lintCommand: null, }); `, }, }; } if (template === "docker-build") { return { src: { "pipeline.ts": `import { DockerBuild, Job, Image } from "@intentius/chant-lexicon-gitlab"; export const docker = DockerBuild({ dockerfile: "Dockerfile", tagLatest: true, }); export const test = new Job({ stage: "test", image: new Image({ name: "node:22-alpine" }), script: ["node test.js"], }); `, }, }; } if (template === "review-app") { return { src: { "pipeline.ts": `import { ReviewApp, Job, Image } from "@intentius/chant-lexicon-gitlab"; export const review = ReviewApp({ name: "review", deployScript: "echo deploy", }); export const test = new Job({ stage: "test", image: new Image({ name: "node:22-alpine" }), script: ["node test.js"], }); `, }, }; } // Default template — basic pipeline with shared config return { src: { "config.ts": `/** * Shared pipeline configuration */ import { Image, Cache } from "@intentius/chant-lexicon-gitlab"; // Default image for all jobs export const defaultImage = new Image({ name: "node:20-alpine", }); // Standard cache configuration export const npmCache = new Cache({ key: "$CI_COMMIT_REF_SLUG", paths: ["node_modules/"], policy: "pull-push", }); `, "pipeline.ts": `import { Job, Artifacts } from "@intentius/chant-lexicon-gitlab"; import { defaultImage, npmCache } from "./config"; export const junitReports = { junit: "coverage/junit.xml" }; export const testArtifacts = new Artifacts({ reports: junitReports, paths: ["coverage/"], expire_in: "1 week", }); export const build = new Job({ stage: "build", image: defaultImage, cache: npmCache, script: ["npm install", "npm run build"], }); export const test = new Job({ stage: "test", image: defaultImage, cache: npmCache, script: ["npm install", "npm test"], artifacts: testArtifacts, }); `, }, }; }, detectTemplate(data: unknown): boolean { if (typeof data !== "object" || data === null) return false; const obj = data as Record; // GitLab CI files typically have stages, or job-like top-level keys if (Array.isArray(obj.stages)) return true; if (obj.image !== undefined && obj.script !== undefined) return true; // Check for job-like entries (objects with "stage" or "script" properties) for (const value of Object.values(obj)) { if (typeof value === "object" && value !== null) { const entry = value as Record; if (entry.stage !== undefined || entry.script !== undefined) { return true; } } } return false; }, completionProvider(ctx: import("@intentius/chant/lsp/types").CompletionContext) { return gitlabCompletions(ctx); }, hoverProvider(ctx: import("@intentius/chant/lsp/types").HoverContext) { return gitlabHover(ctx); }, templateParser() { return new GitLabParser(); }, templateGenerator() { return new GitLabGenerator(); }, async generate(options?: { verbose?: boolean }): Promise { const { generate, writeGeneratedFiles } = await import("./codegen/generate"); const { dirname } = await import("path"); const { fileURLToPath } = await import("url"); const result = await generate({ verbose: options?.verbose ?? true }); const pkgDir = dirname(dirname(fileURLToPath(import.meta.url))); writeGeneratedFiles(result, pkgDir); console.error( `Generated ${result.resources} entities, ${result.properties} property types, ${result.enums} enums`, ); if (result.warnings.length > 0) { console.error(`${result.warnings.length} warnings`); } }, async validate(options?: { verbose?: boolean }): Promise { const { validate } = await import("./validate"); const { printValidationResult } = await import("@intentius/chant/codegen/validate"); const result = await validate(); printValidationResult(result); }, async coverage(options?: { verbose?: boolean; minOverall?: number }): Promise { const { analyzeGitLabCoverage } = await import("./coverage"); await analyzeGitLabCoverage({ verbose: options?.verbose, minOverall: options?.minOverall, }); }, async package(options?: { verbose?: boolean; force?: boolean }): Promise { const { packageLexicon } = await import("./codegen/package"); const { writeBundleSpec } = await import("@intentius/chant/codegen/package"); const { join, dirname } = await import("path"); const { fileURLToPath } = await import("url"); const { spec, stats } = await packageLexicon({ verbose: options?.verbose, force: options?.force }); const pkgDir = dirname(dirname(fileURLToPath(import.meta.url))); const distDir = join(pkgDir, "dist"); writeBundleSpec(spec, distDir); console.error(`Packaged ${stats.resources} entities, ${stats.ruleCount} rules, ${stats.skillCount} skills`); }, mcpTools() { return [ createDiffTool(gitlabSerializer, "Compare current build output against previous output for GitLab CI", "gitlab"), // Read-only context tools: what the pipeline does, what it pulls in, and // its security findings — before anything runs (#327/#328). ...gitlabContextTools(), { name: "gitlab:migrate", description: "Translate a GitHub Actions workflow YAML into a GitLab CI/CD pipeline. Returns the rendered output plus diagnostic + provenance arrays.", inputSchema: { type: "object" as const, properties: { content: { type: "string", description: "Raw .github/workflows/*.yml content" }, emit: { type: "string", enum: ["yaml", "ts"], description: "Output format (default: yaml)" }, useComposites: { type: "boolean", description: "Recognise composite patterns and emit NodePipeline/NodeCI calls" }, strict: { type: "boolean", description: "Escalate needs-review diagnostics to errors" }, }, required: ["content"], }, async handler(params: Record): Promise { const { transform } = await import("./migrate/from-github/index"); const result = await transform(params.content as string, { emit: (params.emit as "yaml" | "ts" | undefined) ?? "yaml", useComposites: !!params.useComposites, strict: !!params.strict, sourceFile: "", }); return { output: result.output, diagnostics: result.diagnostics, provenance: result.provenance, stages: result.stages, }; }, }, ]; }, migrationSource(from: string) { if (from !== "github") return undefined; return { detect(content: string): boolean { // Avoid bringing the migrate code into the import graph until needed if (!/^\s*jobs\s*:/m.test(content)) return false; return /^\s*on\s*:/m.test(content) || /^\s*runs-on\s*:/m.test(content); }, async transform(content: string, opts) { const { transform } = await import("./migrate/from-github/index"); const result = await transform(content, { emit: opts.emit, useComposites: opts.useComposites, sourceFile: opts.sourceFile, strict: opts.strict, }); // The composites rewriter (when enabled) replaces several Job // resources with a single Composite resource — stages: in the // top-level YAML output is now stale for that path. The yaml // emitter reads metadata.stages directly; no special handling // needed at the call site. return { output: result.output, provenance: result.provenance as unknown as Array>, diagnostics: result.diagnostics as unknown as Array>, }; }, }; }, mcpResources() { return [ createCatalogResource(import.meta.url, "GitLab CI Entity Catalog", "JSON list of all supported GitLab CI entity types", "lexicon-gitlab.json", "gitlab"), { uri: "examples/basic-pipeline", name: "Basic Pipeline Example", description: "A basic GitLab CI pipeline with build, test, and deploy stages", mimeType: "text/typescript", async handler(): Promise { return `import { Job, Image, Cache, Artifacts, CI } from "@intentius/chant-lexicon-gitlab"; export const build = new Job({ stage: "build", image: new Image({ name: "node:20" }), cache: new Cache({ key: CI.CommitRef, paths: ["node_modules/"] }), script: ["npm ci", "npm run build"], artifacts: new Artifacts({ paths: ["dist/"], expireIn: "1 day" }), }); export const test = new Job({ stage: "test", image: new Image({ name: "node:20" }), cache: new Cache({ key: CI.CommitRef, paths: ["node_modules/"], policy: "pull" }), script: ["npm ci", "npm test"], artifacts: new Artifacts({ reports: { junit: "coverage/junit.xml" }, expireIn: "1 week", }), }); export const deploy = new Job({ stage: "deploy", script: ["./deploy.sh"], rules: [{ if: "$CI_COMMIT_BRANCH == \\"main\\"", when: "manual" }], }); `; }, }, ]; }, async docs(options?: { verbose?: boolean }): Promise { const { generateDocs } = await import("./codegen/docs"); await generateDocs(options); }, skills: createSkillsLoader(import.meta.url, [ { file: "chant-gitlab.md", name: "chant-gitlab", description: "GitLab CI/CD pipeline lifecycle — build, validate, deploy, monitor, rollback, and troubleshoot", triggers: [ { type: "file-pattern", value: "**/*.gitlab.ts" }, { type: "file-pattern", value: "**/.gitlab-ci.yml" }, { type: "context", value: "gitlab" }, { type: "context", value: "pipeline" }, { type: "context", value: "deploy" }, ], preConditions: [ "chant CLI is installed (chant --version succeeds)", "git is configured and can push to the remote", "Project has chant source files in src/", ], postConditions: [ "Pipeline is in a stable state (success/manual/scheduled)", "No failed jobs in the pipeline", ], parameters: [], examples: [ { title: "Basic test job", description: "Create a test job with caching and artifacts", input: "Create a test job", output: `new Job({ stage: "test", image: new Image({ name: "node:20" }), script: ["npm ci", "npm test"], cache: new Cache({ key: "$CI_COMMIT_REF_SLUG", paths: ["node_modules/"], }), artifacts: new Artifacts({ reports: { junit: "coverage/junit.xml" }, }), })`, }, { title: "Deploy pipeline update", description: "Build, validate, and deploy a pipeline change via MR workflow", input: "Deploy my pipeline changes to production", output: `chant lint src/ chant build src/ --output .gitlab-ci.yml git checkout -b feature/pipeline-update git add .gitlab-ci.yml git commit -m "Update pipeline" git push -u origin feature/pipeline-update # Open MR in GitLab, review pipeline diff, then merge`, }, { title: "Preview pipeline changes", description: "Validate pipeline configuration via lint and CI Lint API before deploying", input: "Check if my pipeline changes are valid before pushing", output: `chant lint src/ chant build src/ --output .gitlab-ci.yml # Validate via GitLab CI Lint API curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \\ --header "Content-Type: application/json" \\ "https://gitlab.com/api/v4/projects/$PROJECT_ID/ci/lint" \\ --data-binary '{"content": "'$(cat .gitlab-ci.yml | jq -Rs .)'", "dry_run": true}'`, }, { title: "Scaffold and deploy a Node.js pipeline", description: "Use --template to scaffold a Node.js project, build YAML, and push to GitLab", input: "Create a Node.js CI pipeline and deploy it to GitLab", output: `# Scaffold the project chant init --lexicon gitlab --template node-pipeline my-node-app cd my-node-app # Build the YAML chant build src/ --output .gitlab-ci.yml # The GitLab repo needs app files — create them echo '{"scripts":{"build":"echo build","test":"node test.js"}}' > package.json echo 'console.log("ok")' > test.js # Push to GitLab git init -b main git add .gitlab-ci.yml package.json test.js git commit -m "Initial pipeline" git remote add origin git@gitlab.com:YOUR_GROUP/YOUR_PROJECT.git git push -u origin main`, }, { title: "Retry a failed pipeline", description: "Retry a failed pipeline and monitor its progress", input: "Pipeline 12345 failed, retry it", output: `# Retry the pipeline curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \\ "https://gitlab.com/api/v4/projects/$PROJECT_ID/pipelines/12345/retry" # Monitor status curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \\ "https://gitlab.com/api/v4/projects/$PROJECT_ID/pipelines/12345"`, }, ], }, { file: "chant-gitlab-migrate.md", name: "chant-gitlab-migrate", description: "Translate GitHub Actions workflows into GitLab CI/CD pipelines via chant migrate", triggers: [ { type: "file-pattern", value: "**/.github/workflows/*.yml" }, { type: "file-pattern", value: "**/.github/workflows/*.yaml" }, { type: "context", value: "migrate from github actions" }, { type: "context", value: "github actions to gitlab" }, { type: "context", value: "convert workflow" }, ], preConditions: [ "chant CLI is installed (chant --version succeeds)", "@intentius/chant-lexicon-gitlab is installed", ], postConditions: [ "Translated .gitlab-ci.yml or .ts source on disk", "Migration report visible to the user (Markdown + SARIF if --report)", ], parameters: [], examples: [ { title: "Translate a single workflow file", description: "Migrate .github/workflows/ci.yml into .gitlab-ci.yml with a SARIF report", input: "Migrate this GitHub workflow to GitLab CI", output: `npx chant migrate .github/workflows/ci.yml \\ --output .gitlab-ci.yml \\ --report migration.sarif`, }, { title: "Translate to chant TypeScript", description: "Produce typed chant source instead of YAML so the user can maintain the pipeline in chant going forward", input: "I want to maintain this in chant — produce TypeScript", output: `npx chant migrate .github/workflows/ci.yml --emit ts --output src/pipeline.ts`, }, { title: "Recognise and emit composites", description: "Collapse a 2-job NodePipeline-shaped workflow into a single NodePipeline() call", input: "Use composites for the upgrade", output: `npx chant migrate .github/workflows/ci.yml --emit ts --use-composites --output src/pipeline.ts`, }, ], }, { file: "chant-gitlab-patterns.md", name: "chant-gitlab-patterns", description: "GitLab CI/CD pipeline stages, caching, artifacts, includes, and advanced patterns", triggers: [ { type: "context", value: "gitlab pipeline" }, { type: "context", value: "gitlab cache" }, { type: "context", value: "gitlab artifacts" }, { type: "context", value: "gitlab include" }, { type: "context", value: "gitlab stages" }, { type: "context", value: "review app" }, ], parameters: [], examples: [ { title: "Pipeline with caching", input: "Set up a Node.js pipeline with proper caching", output: "import { Job, Cache } from \"@intentius/chant-lexicon-gitlab\";\n\nconst cache = new Cache({ key: { files: [\"package-lock.json\"] }, paths: [\"node_modules/\"] });", }, ], }, ]), };