@intentius/chant-lexicon-gitlab 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +4 -0
  2. package/dist/integrity.json +3 -2
  3. package/dist/manifest.json +1 -1
  4. package/dist/skills/chant-gitlab-migrate.md +117 -0
  5. package/package.json +11 -4
  6. package/src/import/generator.ts +20 -2
  7. package/src/migrate/from-github/actions/index.ts +27 -0
  8. package/src/migrate/from-github/actions/registry.ts +112 -0
  9. package/src/migrate/from-github/actions/tier-1.test.ts +128 -0
  10. package/src/migrate/from-github/actions/tier-1.ts +325 -0
  11. package/src/migrate/from-github/actions/tier-2-3.test.ts +144 -0
  12. package/src/migrate/from-github/actions/tier-2.ts +296 -0
  13. package/src/migrate/from-github/actions/tier-3.ts +124 -0
  14. package/src/migrate/from-github/composites/patterns.ts +167 -0
  15. package/src/migrate/from-github/composites/rewriter.test.ts +98 -0
  16. package/src/migrate/from-github/composites/rewriter.ts +29 -0
  17. package/src/migrate/from-github/diagnostics.ts +45 -0
  18. package/src/migrate/from-github/emit-ts.test.ts +49 -0
  19. package/src/migrate/from-github/emit-yaml.ts +128 -0
  20. package/src/migrate/from-github/expressions.test.ts +124 -0
  21. package/src/migrate/from-github/expressions.ts +302 -0
  22. package/src/migrate/from-github/fixtures/README.md +27 -0
  23. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-checkout/expected-report.json +15 -0
  24. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-checkout/expected.gitlab-ci.yml +13 -0
  25. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-checkout/input.yml +7 -0
  26. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-node/expected-report.json +20 -0
  27. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-node/expected.gitlab-ci.yml +20 -0
  28. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-node/input.yml +12 -0
  29. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-python/expected-report.json +20 -0
  30. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-python/expected.gitlab-ci.yml +17 -0
  31. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-python/input.yml +12 -0
  32. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/docker-build-push/expected-report.json +24 -0
  33. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/docker-build-push/expected.gitlab-ci.yml +20 -0
  34. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/docker-build-push/input.yml +16 -0
  35. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/upload-download-artifact/expected-report.json +24 -0
  36. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/upload-download-artifact/expected.gitlab-ci.yml +27 -0
  37. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/upload-download-artifact/input.yml +20 -0
  38. package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/codecov-action/expected-report.json +24 -0
  39. package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/codecov-action/expected.gitlab-ci.yml +15 -0
  40. package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/codecov-action/input.yml +13 -0
  41. package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/setup-bun/expected-report.json +20 -0
  42. package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/setup-bun/expected.gitlab-ci.yml +17 -0
  43. package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/setup-bun/input.yml +11 -0
  44. package/src/migrate/from-github/fixtures/marketplace-actions/tier-3/paths-filter/expected-report.json +21 -0
  45. package/src/migrate/from-github/fixtures/marketplace-actions/tier-3/paths-filter/expected.gitlab-ci.yml +15 -0
  46. package/src/migrate/from-github/fixtures/marketplace-actions/tier-3/paths-filter/input.yml +11 -0
  47. package/src/migrate/from-github/fixtures/syntax-mapping/01-triggers/expected-report.json +20 -0
  48. package/src/migrate/from-github/fixtures/syntax-mapping/01-triggers/expected.gitlab-ci.yml +16 -0
  49. package/src/migrate/from-github/fixtures/syntax-mapping/01-triggers/input.yml +12 -0
  50. package/src/migrate/from-github/fixtures/syntax-mapping/02-stages-needs/expected-report.json +13 -0
  51. package/src/migrate/from-github/fixtures/syntax-mapping/02-stages-needs/expected.gitlab-ci.yml +31 -0
  52. package/src/migrate/from-github/fixtures/syntax-mapping/02-stages-needs/input.yml +16 -0
  53. package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/expected-report.json +13 -0
  54. package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/expected.gitlab-ci.yml +20 -0
  55. package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/input.yml +10 -0
  56. package/src/migrate/from-github/fixtures/syntax-mapping/04-env-secrets/expected-report.json +13 -0
  57. package/src/migrate/from-github/fixtures/syntax-mapping/04-env-secrets/expected.gitlab-ci.yml +18 -0
  58. package/src/migrate/from-github/fixtures/syntax-mapping/04-env-secrets/input.yml +11 -0
  59. package/src/migrate/from-github/fixtures/syntax-mapping/05-conditional/expected-report.json +13 -0
  60. package/src/migrate/from-github/fixtures/syntax-mapping/05-conditional/expected.gitlab-ci.yml +24 -0
  61. package/src/migrate/from-github/fixtures/syntax-mapping/05-conditional/input.yml +12 -0
  62. package/src/migrate/from-github/fixtures/syntax-mapping/06-services/expected-report.json +13 -0
  63. package/src/migrate/from-github/fixtures/syntax-mapping/06-services/expected.gitlab-ci.yml +18 -0
  64. package/src/migrate/from-github/fixtures/syntax-mapping/06-services/input.yml +13 -0
  65. package/src/migrate/from-github/fixtures/syntax-mapping/07-job-control/expected-report.json +20 -0
  66. package/src/migrate/from-github/fixtures/syntax-mapping/07-job-control/expected.gitlab-ci.yml +17 -0
  67. package/src/migrate/from-github/fixtures/syntax-mapping/07-job-control/input.yml +13 -0
  68. package/src/migrate/from-github/fixtures/syntax-mapping/08-workflow-name/expected-report.json +13 -0
  69. package/src/migrate/from-github/fixtures/syntax-mapping/08-workflow-name/expected.gitlab-ci.yml +14 -0
  70. package/src/migrate/from-github/fixtures/syntax-mapping/08-workflow-name/input.yml +7 -0
  71. package/src/migrate/from-github/fixtures.test.ts +92 -0
  72. package/src/migrate/from-github/index.ts +128 -0
  73. package/src/migrate/from-github/provenance.ts +68 -0
  74. package/src/migrate/from-github/rules.ts +82 -0
  75. package/src/migrate/from-github/stages.test.ts +99 -0
  76. package/src/migrate/from-github/stages.ts +177 -0
  77. package/src/migrate/from-github/transformer.test.ts +278 -0
  78. package/src/migrate/from-github/transformer.ts +719 -0
  79. package/src/migrate.mcp.test.ts +69 -0
  80. package/src/plugin.test.ts +7 -3
  81. package/src/plugin.ts +105 -1
  82. package/src/skills/chant-gitlab-migrate.md +117 -0
@@ -0,0 +1,719 @@
1
+ /**
2
+ * GitHub Actions → GitLab CI transformer.
3
+ *
4
+ * Takes a GitHub TemplateIR (produced by `GitHubActionsParser.parse`)
5
+ * and rewrites it into a GitLab TemplateIR (consumed by the existing
6
+ * `GitLabGenerator` for TS emit, or by `emit-yaml.ts` for direct YAML).
7
+ *
8
+ * Per-key provenance is collected in a side-channel `ProvenanceAccumulator`
9
+ * so the IR stays clean (re-usable by `chant import`).
10
+ */
11
+
12
+ import type { TemplateIR, ResourceIR } from "@intentius/chant/import/parser";
13
+ import type { ProvenanceAccumulator } from "./provenance";
14
+ import { substituteExpressions, translateIfCondition } from "./expressions";
15
+ import { inferStages, type GhJobSummary } from "./stages";
16
+ import { lookupAction, type ActionMappingRegistry } from "./actions/registry";
17
+
18
+ /**
19
+ * Apply expression substitution to a string that may contain `${{ ... }}`
20
+ * wrappers anywhere in its content. Used for property values like `image:`,
21
+ * `variables.*`, and service hostnames that flow through from GitHub source
22
+ * verbatim and need their embedded expressions translated.
23
+ *
24
+ * Returns the substituted string unchanged when no expressions are present.
25
+ */
26
+ function substituteValueExpressions(
27
+ value: unknown,
28
+ gitlabPath: string,
29
+ sourceKey: string | undefined,
30
+ prov: ProvenanceAccumulator,
31
+ sourceFile?: string,
32
+ ): unknown {
33
+ if (typeof value !== "string") return value;
34
+ if (!value.includes("${{")) return value;
35
+ const r = substituteExpressions(value, { gitlabPath, sourceKey, sourceFile });
36
+ prov.pushAll(r.provenance);
37
+ return r.output;
38
+ }
39
+
40
+ export interface TransformOptions {
41
+ /** Source file path for provenance (best-effort, no parse) */
42
+ sourceFile?: string;
43
+ /** Optional registry override for testability */
44
+ registry?: ActionMappingRegistry;
45
+ /** Provenance accumulator to write to */
46
+ provenance: ProvenanceAccumulator;
47
+ }
48
+
49
+ /**
50
+ * Map GitHub `runs-on` value to a GitLab `image` (best effort).
51
+ * Self-hosted runners with extra labels map to `tags:`.
52
+ */
53
+ function translateRunsOn(
54
+ runsOn: unknown,
55
+ ctx: { logicalId: string; jobName: string; sourceFile?: string },
56
+ prov: ProvenanceAccumulator,
57
+ ): { image?: string; tags?: string[] } {
58
+ if (typeof runsOn === "string") {
59
+ const map: Record<string, string> = {
60
+ "ubuntu-latest": "ubuntu:24.04",
61
+ "ubuntu-24.04": "ubuntu:24.04",
62
+ "ubuntu-22.04": "ubuntu:22.04",
63
+ "ubuntu-20.04": "ubuntu:20.04",
64
+ };
65
+ if (Object.prototype.hasOwnProperty.call(map, runsOn)) {
66
+ prov.push({
67
+ gitlabPath: `jobs.${ctx.logicalId}.image`,
68
+ gitlabLogicalId: ctx.logicalId,
69
+ sourceKey: `jobs.${ctx.jobName}.runs-on`,
70
+ sourceFile: ctx.sourceFile,
71
+ category: "literal",
72
+ rule: "MIG-RUNS-ON-001",
73
+ note: `runs-on: ${runsOn} → image: ${map[runsOn]}`,
74
+ });
75
+ return { image: map[runsOn] };
76
+ }
77
+ if (/^macos|^windows/.test(runsOn)) {
78
+ prov.push({
79
+ gitlabPath: `jobs.${ctx.logicalId}.image`,
80
+ gitlabLogicalId: ctx.logicalId,
81
+ sourceKey: `jobs.${ctx.jobName}.runs-on`,
82
+ sourceFile: ctx.sourceFile,
83
+ category: "needs-review",
84
+ rule: "MIG-RUNS-ON-NON-LINUX",
85
+ note: `runs-on: ${runsOn} has no direct GitLab image; configure a self-hosted runner with appropriate tag`,
86
+ });
87
+ return { tags: [runsOn] };
88
+ }
89
+ // Custom label → tags
90
+ prov.push({
91
+ gitlabPath: `jobs.${ctx.logicalId}.tags`,
92
+ gitlabLogicalId: ctx.logicalId,
93
+ sourceKey: `jobs.${ctx.jobName}.runs-on`,
94
+ sourceFile: ctx.sourceFile,
95
+ category: "literal",
96
+ rule: "MIG-RUNS-ON-TAG",
97
+ note: `runs-on: ${runsOn} → tags: [${runsOn}]`,
98
+ });
99
+ return { tags: [runsOn] };
100
+ }
101
+ if (Array.isArray(runsOn)) {
102
+ prov.push({
103
+ gitlabPath: `jobs.${ctx.logicalId}.tags`,
104
+ gitlabLogicalId: ctx.logicalId,
105
+ sourceKey: `jobs.${ctx.jobName}.runs-on`,
106
+ sourceFile: ctx.sourceFile,
107
+ category: "literal",
108
+ rule: "MIG-RUNS-ON-TAG",
109
+ note: `runs-on: [${runsOn.join(", ")}] → tags`,
110
+ });
111
+ return { tags: runsOn.map(String) };
112
+ }
113
+ return {};
114
+ }
115
+
116
+ /**
117
+ * Translate a GitHub `on:` trigger spec to GitLab `workflow.rules`.
118
+ */
119
+ function translateOnTrigger(
120
+ on: unknown,
121
+ ctx: { sourceFile?: string },
122
+ prov: ProvenanceAccumulator,
123
+ ): unknown[] {
124
+ const rules: unknown[] = [];
125
+
126
+ const sources = typeof on === "string"
127
+ ? [on]
128
+ : Array.isArray(on)
129
+ ? on.map(String)
130
+ : on && typeof on === "object"
131
+ ? Object.keys(on as Record<string, unknown>)
132
+ : [];
133
+
134
+ for (const event of sources) {
135
+ switch (event) {
136
+ case "push":
137
+ rules.push({ if: '$CI_PIPELINE_SOURCE == "push"' });
138
+ prov.push({
139
+ gitlabPath: "workflow.rules",
140
+ sourceKey: "on.push",
141
+ sourceFile: ctx.sourceFile,
142
+ category: "literal",
143
+ rule: "MIG-ON-PUSH",
144
+ note: 'on: push → if: $CI_PIPELINE_SOURCE == "push"',
145
+ });
146
+ break;
147
+ case "pull_request":
148
+ case "pull_request_target":
149
+ rules.push({ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' });
150
+ prov.push({
151
+ gitlabPath: "workflow.rules",
152
+ sourceKey: `on.${event}`,
153
+ sourceFile: ctx.sourceFile,
154
+ category: "literal",
155
+ rule: "MIG-ON-PR",
156
+ note: `on: ${event} → if: $CI_PIPELINE_SOURCE == "merge_request_event"`,
157
+ });
158
+ break;
159
+ case "schedule":
160
+ rules.push({ if: '$CI_PIPELINE_SOURCE == "schedule"' });
161
+ prov.push({
162
+ gitlabPath: "workflow.rules",
163
+ sourceKey: "on.schedule",
164
+ sourceFile: ctx.sourceFile,
165
+ category: "needs-review",
166
+ rule: "MIG-ON-SCHEDULE",
167
+ note: "on: schedule → cron schedules must be configured in GitLab UI under CI/CD > Schedules",
168
+ });
169
+ break;
170
+ case "workflow_dispatch":
171
+ rules.push({ if: '$CI_PIPELINE_SOURCE == "web"' });
172
+ rules.push({ if: '$CI_PIPELINE_SOURCE == "api"' });
173
+ prov.push({
174
+ gitlabPath: "workflow.rules",
175
+ sourceKey: "on.workflow_dispatch",
176
+ sourceFile: ctx.sourceFile,
177
+ category: "needs-review",
178
+ rule: "MIG-ON-DISPATCH",
179
+ note: "on: workflow_dispatch → web/api triggers; inputs require spec:inputs (GitLab 17+) and per-input defaults",
180
+ });
181
+ break;
182
+ case "release":
183
+ case "issues":
184
+ case "issue_comment":
185
+ case "discussion":
186
+ case "label":
187
+ prov.push({
188
+ gitlabPath: "workflow.rules",
189
+ sourceKey: `on.${event}`,
190
+ sourceFile: ctx.sourceFile,
191
+ category: "needs-review",
192
+ rule: "MIG-ON-NON-GIT",
193
+ note: `on: ${event} has no GitLab equivalent — GitLab pipelines run only on git events. Consider gitlab-triage or webhooks.`,
194
+ });
195
+ break;
196
+ default:
197
+ prov.push({
198
+ gitlabPath: "workflow.rules",
199
+ sourceKey: `on.${event}`,
200
+ sourceFile: ctx.sourceFile,
201
+ category: "needs-review",
202
+ rule: "MIG-ON-UNKNOWN",
203
+ note: `on: ${event} not recognised; review manually`,
204
+ });
205
+ }
206
+ }
207
+
208
+ return rules;
209
+ }
210
+
211
+ /**
212
+ * Translate a step's env into job-level variables (merged later).
213
+ */
214
+ function collectStepEnv(
215
+ steps: Array<Record<string, unknown>>,
216
+ ctx: { logicalId: string; jobName: string; sourceFile?: string },
217
+ prov: ProvenanceAccumulator,
218
+ ): Record<string, unknown> {
219
+ const variables: Record<string, unknown> = {};
220
+ for (const [i, step] of steps.entries()) {
221
+ if (step.env && typeof step.env === "object") {
222
+ for (const [k, v] of Object.entries(step.env as Record<string, unknown>)) {
223
+ if (k in variables && variables[k] !== v) {
224
+ prov.push({
225
+ gitlabPath: `jobs.${ctx.logicalId}.variables.${k}`,
226
+ gitlabLogicalId: ctx.logicalId,
227
+ sourceKey: `jobs.${ctx.jobName}.steps[${i}].env.${k}`,
228
+ sourceFile: ctx.sourceFile,
229
+ category: "needs-review",
230
+ rule: "MIG-STEP-ENV-CONFLICT",
231
+ note: `Step-level env var ${k} conflicts across steps; using last value`,
232
+ });
233
+ }
234
+ variables[k] = v;
235
+ }
236
+ }
237
+ }
238
+ return variables;
239
+ }
240
+
241
+ /**
242
+ * Translate a step's `run` block into script lines, applying expression
243
+ * substitution. Returns the produced script lines + provenance.
244
+ */
245
+ function translateRunStep(
246
+ step: Record<string, unknown>,
247
+ i: number,
248
+ ctx: { logicalId: string; jobName: string; sourceFile?: string },
249
+ prov: ProvenanceAccumulator,
250
+ ): string[] {
251
+ const run = String(step.run ?? "");
252
+ // GitHub's `run:` supports multi-line shell. Split on newlines.
253
+ const lines = run.split(/\r?\n/).filter((l) => l.length > 0);
254
+ const out: string[] = [];
255
+ for (const [j, line] of lines.entries()) {
256
+ const subbed = substituteExpressions(line, {
257
+ gitlabPath: `jobs.${ctx.logicalId}.script[${out.length}]`,
258
+ sourceKey: `jobs.${ctx.jobName}.steps[${i}].run[${j}]`,
259
+ sourceFile: ctx.sourceFile,
260
+ });
261
+ prov.pushAll(subbed.provenance);
262
+ out.push(subbed.output);
263
+ }
264
+ return out;
265
+ }
266
+
267
+ /**
268
+ * Translate a single GH job ResourceIR into a GitLab Job ResourceIR.
269
+ */
270
+ function translateJob(
271
+ ghJob: ResourceIR,
272
+ opts: TransformOptions,
273
+ ): { gitlabJob: ResourceIR; logicalId: string; needs: string[] } {
274
+ const prov = opts.provenance;
275
+ const logicalId = ghJob.logicalId;
276
+ const jobName = (ghJob.metadata?.originalName as string) ?? logicalId;
277
+ const props = ghJob.properties as Record<string, unknown>;
278
+ const gitlabProps: Record<string, unknown> = {};
279
+
280
+ // runs-on
281
+ if (props["runs-on"] !== undefined) {
282
+ const { image, tags } = translateRunsOn(props["runs-on"], { logicalId, jobName, sourceFile: opts.sourceFile }, prov);
283
+ if (image) {
284
+ gitlabProps.image = substituteValueExpressions(image, `jobs.${logicalId}.image`, `jobs.${jobName}.runs-on`, prov, opts.sourceFile);
285
+ }
286
+ if (tags) gitlabProps.tags = tags;
287
+ }
288
+
289
+ // env → variables (with embedded ${{ }} substitution on each value)
290
+ if (props.env && typeof props.env === "object") {
291
+ const rawEnv = props.env as Record<string, unknown>;
292
+ const substituted: Record<string, unknown> = {};
293
+ for (const [k, v] of Object.entries(rawEnv)) {
294
+ substituted[k] = substituteValueExpressions(
295
+ v,
296
+ `jobs.${logicalId}.variables.${k}`,
297
+ `jobs.${jobName}.env.${k}`,
298
+ prov,
299
+ opts.sourceFile,
300
+ );
301
+ }
302
+ gitlabProps.variables = substituted;
303
+ prov.push({
304
+ gitlabPath: `jobs.${logicalId}.variables`,
305
+ gitlabLogicalId: logicalId,
306
+ sourceKey: `jobs.${jobName}.env`,
307
+ sourceFile: opts.sourceFile,
308
+ category: "literal",
309
+ rule: "MIG-JOB-ENV",
310
+ note: "env: → variables:",
311
+ });
312
+ }
313
+
314
+ // timeout-minutes → timeout
315
+ if (props["timeout-minutes"] !== undefined) {
316
+ gitlabProps.timeout = `${props["timeout-minutes"]} minutes`;
317
+ prov.push({
318
+ gitlabPath: `jobs.${logicalId}.timeout`,
319
+ gitlabLogicalId: logicalId,
320
+ sourceKey: `jobs.${jobName}.timeout-minutes`,
321
+ sourceFile: opts.sourceFile,
322
+ category: "literal",
323
+ rule: "MIG-TIMEOUT",
324
+ note: `timeout-minutes → timeout`,
325
+ });
326
+ }
327
+
328
+ // continue-on-error → allow_failure
329
+ if (props["continue-on-error"] !== undefined) {
330
+ gitlabProps.allow_failure = props["continue-on-error"];
331
+ prov.push({
332
+ gitlabPath: `jobs.${logicalId}.allow_failure`,
333
+ gitlabLogicalId: logicalId,
334
+ sourceKey: `jobs.${jobName}.continue-on-error`,
335
+ sourceFile: opts.sourceFile,
336
+ category: "literal",
337
+ rule: "MIG-ALLOW-FAILURE",
338
+ note: "continue-on-error → allow_failure",
339
+ });
340
+ }
341
+
342
+ // if → rules
343
+ if (typeof props.if === "string") {
344
+ const { ifExpression, whenClause, provenance } = translateIfCondition(props.if, {
345
+ gitlabPath: `jobs.${logicalId}.rules[0]`,
346
+ sourceKey: `jobs.${jobName}.if`,
347
+ sourceFile: opts.sourceFile,
348
+ });
349
+ prov.pushAll(provenance);
350
+ const rule: Record<string, unknown> = {};
351
+ if (ifExpression) rule.if = ifExpression;
352
+ if (whenClause) rule.when = whenClause;
353
+ gitlabProps.rules = [rule];
354
+ }
355
+
356
+ // permissions — no per-job equivalent
357
+ if (props.permissions !== undefined) {
358
+ prov.push({
359
+ gitlabPath: `jobs.${logicalId}.permissions`,
360
+ gitlabLogicalId: logicalId,
361
+ sourceKey: `jobs.${jobName}.permissions`,
362
+ sourceFile: opts.sourceFile,
363
+ category: "needs-review",
364
+ rule: "MIG-PERMISSIONS-001",
365
+ note: "GitHub permissions: has no per-job GitLab equivalent. Configure CI/CD token access at the project level.",
366
+ });
367
+ }
368
+
369
+ // outputs — needs review (dotenv pattern)
370
+ if (props.outputs !== undefined) {
371
+ prov.push({
372
+ gitlabPath: `jobs.${logicalId}.artifacts.reports.dotenv`,
373
+ gitlabLogicalId: logicalId,
374
+ sourceKey: `jobs.${jobName}.outputs`,
375
+ sourceFile: opts.sourceFile,
376
+ category: "needs-review",
377
+ rule: "MIG-JOB-OUTPUTS",
378
+ note: "GitHub job outputs require the artifacts:reports:dotenv pattern in GitLab.",
379
+ });
380
+ }
381
+
382
+ // strategy.matrix → parallel.matrix
383
+ if (props.strategy && typeof props.strategy === "object") {
384
+ const strategy = props.strategy as Record<string, unknown>;
385
+ if (strategy.matrix && typeof strategy.matrix === "object") {
386
+ const matrix = strategy.matrix as Record<string, unknown>;
387
+ const parallel: Record<string, unknown> = {};
388
+ const matrixEntries: Record<string, unknown> = {};
389
+ for (const [k, v] of Object.entries(matrix)) {
390
+ if (k === "include" || k === "exclude") {
391
+ prov.push({
392
+ gitlabPath: `jobs.${logicalId}.parallel.matrix`,
393
+ gitlabLogicalId: logicalId,
394
+ sourceKey: `jobs.${jobName}.strategy.matrix.${k}`,
395
+ sourceFile: opts.sourceFile,
396
+ category: "needs-review",
397
+ rule: "MIG-MATRIX-INCLUDE-001",
398
+ note: `matrix.${k} has no direct GitLab equivalent; review manually`,
399
+ });
400
+ continue;
401
+ }
402
+ matrixEntries[k.toUpperCase()] = v;
403
+ }
404
+ if (Object.keys(matrixEntries).length > 0) {
405
+ parallel.matrix = [matrixEntries];
406
+ gitlabProps.parallel = parallel;
407
+ prov.push({
408
+ gitlabPath: `jobs.${logicalId}.parallel.matrix`,
409
+ gitlabLogicalId: logicalId,
410
+ sourceKey: `jobs.${jobName}.strategy.matrix`,
411
+ sourceFile: opts.sourceFile,
412
+ category: "literal",
413
+ rule: "MIG-MATRIX",
414
+ note: "strategy.matrix → parallel.matrix",
415
+ });
416
+ }
417
+ }
418
+ if (strategy["fail-fast"] === true) {
419
+ prov.push({
420
+ gitlabPath: `jobs.${logicalId}.parallel.matrix`,
421
+ gitlabLogicalId: logicalId,
422
+ sourceKey: `jobs.${jobName}.strategy.fail-fast`,
423
+ sourceFile: opts.sourceFile,
424
+ category: "needs-review",
425
+ rule: "MIG-FAIL-FAST",
426
+ note: "strategy.fail-fast: true has no GitLab equivalent (GitLab's default is non-fail-fast)",
427
+ });
428
+ }
429
+ }
430
+
431
+ // needs → needs (passthrough, kebab-case names preserved)
432
+ const needsArray: string[] = [];
433
+ if (props.needs !== undefined) {
434
+ const raw = props.needs;
435
+ const list = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw.map(String) : [];
436
+ needsArray.push(...list);
437
+ if (list.length > 0) {
438
+ gitlabProps.needs = list;
439
+ prov.push({
440
+ gitlabPath: `jobs.${logicalId}.needs`,
441
+ gitlabLogicalId: logicalId,
442
+ sourceKey: `jobs.${jobName}.needs`,
443
+ sourceFile: opts.sourceFile,
444
+ category: "literal",
445
+ rule: "MIG-NEEDS",
446
+ note: "needs: passthrough",
447
+ });
448
+ }
449
+ }
450
+
451
+ // concurrency.group → resource_group, cancel-in-progress → interruptible
452
+ if (props.concurrency !== undefined) {
453
+ const c = typeof props.concurrency === "object" && props.concurrency !== null
454
+ ? props.concurrency as Record<string, unknown>
455
+ : { group: String(props.concurrency) };
456
+ if (c.group) {
457
+ const subbed = substituteExpressions(String(c.group), {
458
+ gitlabPath: `jobs.${logicalId}.resource_group`,
459
+ sourceKey: `jobs.${jobName}.concurrency.group`,
460
+ sourceFile: opts.sourceFile,
461
+ });
462
+ prov.pushAll(subbed.provenance);
463
+ gitlabProps.resource_group = subbed.output;
464
+ }
465
+ if (c["cancel-in-progress"] === true) {
466
+ gitlabProps.interruptible = true;
467
+ }
468
+ prov.push({
469
+ gitlabPath: `jobs.${logicalId}.resource_group`,
470
+ gitlabLogicalId: logicalId,
471
+ sourceKey: `jobs.${jobName}.concurrency`,
472
+ sourceFile: opts.sourceFile,
473
+ category: "literal",
474
+ rule: "MIG-CONCURRENCY",
475
+ note: "concurrency.group → resource_group; cancel-in-progress → interruptible",
476
+ });
477
+ }
478
+
479
+ // services — pass through (shape is similar)
480
+ if (props.services !== undefined) {
481
+ gitlabProps.services = props.services;
482
+ prov.push({
483
+ gitlabPath: `jobs.${logicalId}.services`,
484
+ gitlabLogicalId: logicalId,
485
+ sourceKey: `jobs.${jobName}.services`,
486
+ sourceFile: opts.sourceFile,
487
+ category: "literal",
488
+ rule: "MIG-SERVICES",
489
+ note: "services: passthrough",
490
+ });
491
+ }
492
+
493
+ // container.image → image (overrides runs-on default)
494
+ if (props.container && typeof props.container === "object") {
495
+ const container = props.container as Record<string, unknown>;
496
+ if (container.image) {
497
+ gitlabProps.image = substituteValueExpressions(
498
+ container.image,
499
+ `jobs.${logicalId}.image`,
500
+ `jobs.${jobName}.container.image`,
501
+ prov,
502
+ opts.sourceFile,
503
+ );
504
+ prov.push({
505
+ gitlabPath: `jobs.${logicalId}.image`,
506
+ gitlabLogicalId: logicalId,
507
+ sourceKey: `jobs.${jobName}.container.image`,
508
+ sourceFile: opts.sourceFile,
509
+ category: "literal",
510
+ rule: "MIG-CONTAINER",
511
+ note: "container.image → image",
512
+ });
513
+ }
514
+ }
515
+
516
+ // steps → script
517
+ const script: string[] = [];
518
+ const beforeScript: string[] = [];
519
+ const stepEnv = collectStepEnv(Array.isArray(props.steps) ? props.steps as Array<Record<string, unknown>> : [], { logicalId, jobName, sourceFile: opts.sourceFile }, prov);
520
+ if (Object.keys(stepEnv).length > 0) {
521
+ const existing = (gitlabProps.variables as Record<string, unknown> | undefined) ?? {};
522
+ gitlabProps.variables = { ...existing, ...stepEnv };
523
+ }
524
+
525
+ if (Array.isArray(props.steps)) {
526
+ const stepsArr = props.steps as Array<Record<string, unknown>>;
527
+ for (const [i, step] of stepsArr.entries()) {
528
+ // run: shell command
529
+ if (typeof step.run === "string") {
530
+ const lines = translateRunStep(step, i, { logicalId, jobName, sourceFile: opts.sourceFile }, prov);
531
+ script.push(...lines);
532
+ continue;
533
+ }
534
+ // uses: marketplace action
535
+ if (typeof step.uses === "string") {
536
+ const action = lookupAction(step.uses, opts.registry);
537
+ if (action) {
538
+ const result = action.translate(step, {
539
+ logicalId,
540
+ jobName,
541
+ sourceFile: opts.sourceFile,
542
+ stepIndex: i,
543
+ });
544
+ script.push(...result.scriptLines);
545
+ if (result.beforeScript) beforeScript.push(...result.beforeScript);
546
+ if (result.image) {
547
+ // Action-mapping image values pass through user input like
548
+ // `node-version: ${{ matrix.node }}` verbatim from `with:`; run
549
+ // expression substitution so embedded ${{ ... }} doesn't leak
550
+ // into the output as a GitLab-side dead identifier.
551
+ gitlabProps.image = substituteValueExpressions(
552
+ result.image,
553
+ `jobs.${logicalId}.image`,
554
+ `jobs.${jobName}.steps[${i}].uses`,
555
+ prov,
556
+ opts.sourceFile,
557
+ );
558
+ }
559
+ if (result.services) gitlabProps.services = result.services;
560
+ if (result.cache) gitlabProps.cache = result.cache;
561
+ if (result.artifacts) gitlabProps.artifacts = result.artifacts;
562
+ if (result.variables) {
563
+ const existing = (gitlabProps.variables as Record<string, unknown> | undefined) ?? {};
564
+ const subbed: Record<string, unknown> = { ...existing };
565
+ for (const [k, v] of Object.entries(result.variables)) {
566
+ subbed[k] = substituteValueExpressions(
567
+ v,
568
+ `jobs.${logicalId}.variables.${k}`,
569
+ `jobs.${jobName}.steps[${i}].uses`,
570
+ prov,
571
+ opts.sourceFile,
572
+ );
573
+ }
574
+ gitlabProps.variables = subbed;
575
+ }
576
+ prov.pushAll(result.provenance);
577
+ } else {
578
+ script.push(`# TODO(migration): action "${step.uses}" not mapped — review manually`);
579
+ prov.push({
580
+ gitlabPath: `jobs.${logicalId}.script[${script.length - 1}]`,
581
+ gitlabLogicalId: logicalId,
582
+ sourceKey: `jobs.${jobName}.steps[${i}].uses`,
583
+ sourceFile: opts.sourceFile,
584
+ category: "needs-review",
585
+ rule: "MIG-ACTION-UNKNOWN",
586
+ note: `Marketplace action "${step.uses}" has no registered mapping; emitted TODO`,
587
+ actionRef: step.uses,
588
+ });
589
+ }
590
+ }
591
+ }
592
+ }
593
+ if (script.length > 0) gitlabProps.script = script;
594
+ if (beforeScript.length > 0) gitlabProps.before_script = beforeScript;
595
+
596
+ return { gitlabJob: { logicalId, type: "GitLab::CI::Job", properties: gitlabProps, metadata: { originalName: jobName } }, logicalId, needs: needsArray };
597
+ }
598
+
599
+ export async function transformIR(
600
+ ghIR: TemplateIR,
601
+ opts: TransformOptions,
602
+ ): Promise<{ ir: TemplateIR; stages: string[] }> {
603
+ const prov = opts.provenance;
604
+ const resources: ResourceIR[] = [];
605
+
606
+ // Extract workflow-level properties
607
+ const workflowResource = ghIR.resources.find((r) => r.type === "GitHub::Actions::Workflow");
608
+ if (workflowResource) {
609
+ const wf = workflowResource.properties;
610
+ const gitlabWorkflowProps: Record<string, unknown> = {};
611
+ if (wf.name) {
612
+ gitlabWorkflowProps.name = wf.name;
613
+ prov.push({
614
+ gitlabPath: "workflow.name",
615
+ sourceKey: "name",
616
+ sourceFile: opts.sourceFile,
617
+ category: "literal",
618
+ rule: "MIG-WORKFLOW-NAME",
619
+ note: "name → workflow.name",
620
+ });
621
+ }
622
+ if (wf.on !== undefined) {
623
+ const rules = translateOnTrigger(wf.on, { sourceFile: opts.sourceFile }, prov);
624
+ if (rules.length > 0) gitlabWorkflowProps.rules = rules;
625
+ }
626
+ if (Object.keys(gitlabWorkflowProps).length > 0) {
627
+ resources.push({
628
+ logicalId: "workflow",
629
+ type: "GitLab::CI::Workflow",
630
+ properties: gitlabWorkflowProps,
631
+ });
632
+ }
633
+ // Workflow-level env → top-level variables (handled via metadata)
634
+ if (wf.env && typeof wf.env === "object") {
635
+ prov.push({
636
+ gitlabPath: "variables",
637
+ sourceKey: "env",
638
+ sourceFile: opts.sourceFile,
639
+ category: "literal",
640
+ rule: "MIG-WORKFLOW-ENV",
641
+ note: "workflow env → top-level variables",
642
+ });
643
+ }
644
+ // Workflow-level permissions
645
+ if (wf.permissions !== undefined) {
646
+ prov.push({
647
+ gitlabPath: "(none)",
648
+ sourceKey: "permissions",
649
+ sourceFile: opts.sourceFile,
650
+ category: "needs-review",
651
+ rule: "MIG-PERMISSIONS-001",
652
+ note: "GitHub workflow-level permissions: configure CI/CD token access at the project level (no YAML equivalent).",
653
+ });
654
+ }
655
+ }
656
+
657
+ // Translate jobs
658
+ const ghJobs = ghIR.resources.filter(
659
+ (r) => r.type === "GitHub::Actions::Job" || r.type === "GitHub::Actions::ReusableWorkflowCallJob",
660
+ );
661
+ const translatedJobs: Array<ReturnType<typeof translateJob>> = [];
662
+ const jobSummaries: GhJobSummary[] = [];
663
+ for (const ghJob of ghJobs) {
664
+ if (ghJob.type === "GitHub::Actions::ReusableWorkflowCallJob") {
665
+ prov.push({
666
+ gitlabPath: `jobs.${ghJob.logicalId}`,
667
+ gitlabLogicalId: ghJob.logicalId,
668
+ sourceKey: `jobs.${(ghJob.metadata?.originalName as string) ?? ghJob.logicalId}.uses`,
669
+ sourceFile: opts.sourceFile,
670
+ category: "needs-review",
671
+ rule: "MIG-REUSABLE-WORKFLOW",
672
+ note: "GitHub reusable workflow `uses:` requires GitLab `include:` + variable substitution (no typed inputs/outputs)",
673
+ });
674
+ continue;
675
+ }
676
+ const translated = translateJob(ghJob, opts);
677
+ translatedJobs.push(translated);
678
+ jobSummaries.push({
679
+ logicalId: translated.logicalId,
680
+ originalName: (ghJob.metadata?.originalName as string) ?? translated.logicalId,
681
+ needs: translated.needs,
682
+ });
683
+ }
684
+
685
+ // Infer stages
686
+ const stageResult = inferStages(jobSummaries);
687
+ prov.pushAll(stageResult.provenance);
688
+
689
+ // Apply stage assignments
690
+ for (const tj of translatedJobs) {
691
+ const stage = stageResult.stageByJob.get(tj.logicalId);
692
+ if (stage) {
693
+ (tj.gitlabJob.properties as Record<string, unknown>).stage = stage;
694
+ }
695
+ resources.push(tj.gitlabJob);
696
+ }
697
+
698
+ // Workflow-level env collected into a top-level metadata.variables slot
699
+ const topLevelVars: Record<string, unknown> = {};
700
+ if (workflowResource && workflowResource.properties.env && typeof workflowResource.properties.env === "object") {
701
+ Object.assign(topLevelVars, workflowResource.properties.env);
702
+ }
703
+
704
+ const metadata: Record<string, unknown> = {
705
+ migration: {
706
+ sourceFile: opts.sourceFile ?? "(unknown)",
707
+ sourceTool: "github-actions",
708
+ },
709
+ stages: stageResult.stages,
710
+ };
711
+ if (Object.keys(topLevelVars).length > 0) {
712
+ metadata.variables = topLevelVars;
713
+ }
714
+
715
+ return {
716
+ ir: { resources, parameters: [], metadata },
717
+ stages: stageResult.stages,
718
+ };
719
+ }