@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,29 @@
1
+ /**
2
+ * `--use-composites` orchestrator: walks the registered patterns and
3
+ * applies the first match. Patterns are tried in declaration order
4
+ * (NodePipeline before NodeCI so the 2-job shape wins over the 1-job
5
+ * shape when both could match).
6
+ */
7
+
8
+ import type { TemplateIR } from "@intentius/chant/import/parser";
9
+ import type { ProvenanceRecord } from "../provenance";
10
+ import { PATTERNS, type CompositePattern } from "./patterns";
11
+
12
+ export function applyComposites(
13
+ ir: TemplateIR,
14
+ registry: CompositePattern[] = PATTERNS,
15
+ ): { ir: TemplateIR; provenance: ProvenanceRecord[] } {
16
+ const provenance: ProvenanceRecord[] = [];
17
+ let current = ir;
18
+ for (const pattern of registry) {
19
+ const match = pattern.match(current);
20
+ if (match) {
21
+ const result = pattern.rewrite(current, match);
22
+ current = result.ir;
23
+ provenance.push(...result.provenance);
24
+ // Only one composite rewrite per migrate run (keeps semantics simple)
25
+ break;
26
+ }
27
+ }
28
+ return { ir: current, provenance };
29
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Convert ProvenanceRecord[] into LintDiagnostic[] for SARIF output.
3
+ *
4
+ * Mapping:
5
+ * - category "needs-review" → severity "warning" (or "error" if --strict)
6
+ * - category "skipped" → severity "info"
7
+ * - category "action-map" tier 3 → severity "warning"
8
+ * - all others → no diagnostic (clean translation)
9
+ */
10
+
11
+ import type { LintDiagnostic } from "@intentius/chant/lint/rule";
12
+ import type { ProvenanceRecord } from "./provenance";
13
+
14
+ export interface DiagnosticOptions {
15
+ /** When true, needs-review records become errors instead of warnings */
16
+ strict?: boolean;
17
+ }
18
+
19
+ export function provenanceToDiagnostics(
20
+ records: ProvenanceRecord[],
21
+ opts: DiagnosticOptions = {},
22
+ ): LintDiagnostic[] {
23
+ const out: LintDiagnostic[] = [];
24
+ for (const r of records) {
25
+ let severity: "error" | "warning" | "info" | undefined;
26
+ if (r.category === "needs-review") {
27
+ severity = opts.strict ? "error" : "warning";
28
+ } else if (r.category === "skipped") {
29
+ severity = "info";
30
+ } else if (r.category === "action-map" && r.mappingTier === 3) {
31
+ severity = "warning";
32
+ } else {
33
+ continue;
34
+ }
35
+ out.push({
36
+ file: r.sourceFile ?? "<input>",
37
+ line: r.sourceLine ?? 1,
38
+ column: r.sourceColumn ?? 1,
39
+ ruleId: r.rule,
40
+ severity,
41
+ message: r.note ?? r.rule,
42
+ });
43
+ }
44
+ return out;
45
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { transform } from "./index";
3
+
4
+ describe("transform --emit ts", () => {
5
+ test("produces TS source importing from @intentius/chant-lexicon-gitlab", async () => {
6
+ const yml = `on: push
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - run: npm ci
12
+ - run: npm run build
13
+ `;
14
+ const result = await transform(yml, { emit: "ts", sourceFile: "ci.yml" });
15
+ expect(result.output).toContain('from "@intentius/chant-lexicon-gitlab"');
16
+ expect(result.output).toContain("new Job(");
17
+ expect(result.output).toContain("Migrated from ci.yml");
18
+ });
19
+
20
+ test("emits TODO footer for NeedsReview items", async () => {
21
+ const yml = `on: schedule
22
+ jobs:
23
+ cron:
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - run: ./run.sh
27
+ `;
28
+ const result = await transform(yml, { emit: "ts", sourceFile: "ci.yml" });
29
+ expect(result.output).toContain("TODO(migration)");
30
+ expect(result.output).toContain("MIG-ON-SCHEDULE");
31
+ });
32
+
33
+ test("yaml and ts emit modes produce equivalent IR (same jobs)", async () => {
34
+ const yml = `on: push
35
+ jobs:
36
+ build:
37
+ runs-on: ubuntu-latest
38
+ needs: []
39
+ steps:
40
+ - run: make
41
+ `;
42
+ const yamlResult = await transform(yml, { emit: "yaml" });
43
+ const tsResult = await transform(yml, { emit: "ts" });
44
+ // Same IR underneath; output strings differ.
45
+ expect(yamlResult.ir.resources.map((r) => r.logicalId)).toEqual(
46
+ tsResult.ir.resources.map((r) => r.logicalId),
47
+ );
48
+ });
49
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Direct GitLab TemplateIR → `.gitlab-ci.yml` text emission.
3
+ *
4
+ * We deliberately bypass the existing `gitlabSerializer.serialize()` because
5
+ * that path consumes `Map<string, Declarable>` (entity instances built by
6
+ * user TS code), not a raw IR. Going through entity instances would require
7
+ * runtime construction of typed objects we don't have. Direct emission is
8
+ * ~60 LOC.
9
+ */
10
+
11
+ import type { TemplateIR } from "@intentius/chant/import/parser";
12
+ import { emitYAML } from "@intentius/chant/yaml";
13
+
14
+ const RESERVED_TOP_LEVEL_ORDER = [
15
+ "spec",
16
+ "include",
17
+ "default",
18
+ "stages",
19
+ "variables",
20
+ "workflow",
21
+ "image",
22
+ "services",
23
+ "before_script",
24
+ "after_script",
25
+ "cache",
26
+ "pages",
27
+ ];
28
+
29
+ function isReservedTopLevelKey(key: string): boolean {
30
+ return RESERVED_TOP_LEVEL_ORDER.includes(key);
31
+ }
32
+
33
+ function emitKeyValue(key: string, value: unknown, indent: number): string {
34
+ const prefix = " ".repeat(indent);
35
+ if (value === null || value === undefined) return `${prefix}${key}: null`;
36
+ if (typeof value === "boolean" || typeof value === "number") return `${prefix}${key}: ${value}`;
37
+ if (typeof value === "string") return `${prefix}${key}: ${emitYAML(value, indent + 1)}`;
38
+ if (Array.isArray(value)) {
39
+ if (value.length === 0) return `${prefix}${key}: []`;
40
+ return `${prefix}${key}:${emitYAML(value, indent + 1)}`;
41
+ }
42
+ if (typeof value === "object") {
43
+ if (Object.keys(value).length === 0) return `${prefix}${key}: {}`;
44
+ return `${prefix}${key}:${emitYAML(value, indent + 1)}`;
45
+ }
46
+ return `${prefix}${key}: ${String(value)}`;
47
+ }
48
+
49
+ function emitBlock(obj: Record<string, unknown>, indent: number): string {
50
+ return Object.entries(obj)
51
+ .filter(([, v]) => v !== undefined)
52
+ .map(([k, v]) => emitKeyValue(k, v, indent))
53
+ .join("\n");
54
+ }
55
+
56
+ /**
57
+ * Convert a GitLab `TemplateIR` (produced by the GH→GL transformer) into
58
+ * `.gitlab-ci.yml` text.
59
+ */
60
+ export function emitGitlabYaml(ir: TemplateIR): string {
61
+ const top: Record<string, unknown> = {};
62
+
63
+ // Stages from metadata
64
+ const meta = (ir.metadata ?? {}) as Record<string, unknown>;
65
+ if (Array.isArray(meta.stages) && meta.stages.length > 0) {
66
+ top.stages = meta.stages;
67
+ }
68
+
69
+ // Top-level variables from metadata.variables
70
+ if (meta.variables && typeof meta.variables === "object" && Object.keys(meta.variables).length > 0) {
71
+ top.variables = meta.variables;
72
+ }
73
+
74
+ // Workflow / Default / Include resources go into reserved top-level keys
75
+ for (const r of ir.resources) {
76
+ if (r.type === "GitLab::CI::Workflow") {
77
+ top.workflow = r.properties;
78
+ } else if (r.type === "GitLab::CI::Default") {
79
+ top.default = r.properties;
80
+ } else if (r.type === "GitLab::CI::Include") {
81
+ top.include = r.properties;
82
+ }
83
+ }
84
+
85
+ // Job resources go at the top level keyed by logicalId (kebab-cased)
86
+ const jobs: Record<string, unknown> = {};
87
+ for (const r of ir.resources) {
88
+ if (r.type === "GitLab::CI::Job") {
89
+ const key = (r.metadata?.originalName as string) ?? r.logicalId;
90
+ if (isReservedTopLevelKey(key)) {
91
+ // Avoid colliding with reserved keywords; prefix
92
+ jobs[`job-${key}`] = r.properties;
93
+ } else {
94
+ jobs[key] = r.properties;
95
+ }
96
+ }
97
+ }
98
+
99
+ // Emit ordered top-level keys (reserved first, then jobs)
100
+ const lines: string[] = [];
101
+
102
+ for (const key of RESERVED_TOP_LEVEL_ORDER) {
103
+ if (top[key] !== undefined) {
104
+ lines.push(emitKeyValue(key, top[key], 0));
105
+ }
106
+ }
107
+
108
+ // Migration banner comment
109
+ if (meta.migration && typeof meta.migration === "object") {
110
+ const m = meta.migration as Record<string, unknown>;
111
+ if (lines.length === 0) {
112
+ lines.push(`# Generated by chant migrate from ${m.sourceFile ?? "(unknown)"}`);
113
+ lines.push("");
114
+ } else {
115
+ lines.unshift("");
116
+ lines.unshift(`# Generated by chant migrate from ${m.sourceFile ?? "(unknown)"}`);
117
+ }
118
+ }
119
+
120
+ // Jobs (sorted by logicalId for determinism)
121
+ const jobKeys = Object.keys(jobs).sort();
122
+ for (const key of jobKeys) {
123
+ lines.push("");
124
+ lines.push(emitKeyValue(key, jobs[key], 0));
125
+ }
126
+
127
+ return lines.join("\n") + "\n";
128
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { substituteExpressions, translateIfCondition, translateIfFunction } from "./expressions";
3
+
4
+ describe("substituteExpressions — context vars", () => {
5
+ const ctx = { gitlabPath: "x", sourceKey: "y" };
6
+
7
+ test.each([
8
+ ["${{ github.sha }}", "$CI_COMMIT_SHA"],
9
+ ["${{ github.ref }}", "$CI_COMMIT_REF_NAME"],
10
+ ["${{ github.ref_name }}", "$CI_COMMIT_REF_NAME"],
11
+ ["${{ github.repository }}", "$CI_PROJECT_PATH"],
12
+ ["${{ github.repository_owner }}", "$CI_PROJECT_NAMESPACE"],
13
+ ["${{ github.actor }}", "$GITLAB_USER_LOGIN"],
14
+ ["${{ github.event_name }}", "$CI_PIPELINE_SOURCE"],
15
+ ["${{ github.run_id }}", "$CI_PIPELINE_ID"],
16
+ ["${{ github.run_number }}", "$CI_PIPELINE_IID"],
17
+ ["${{ github.job }}", "$CI_JOB_NAME"],
18
+ ["${{ github.head_ref }}", "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"],
19
+ ["${{ github.base_ref }}", "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"],
20
+ ["${{ github.workspace }}", "$CI_PROJECT_DIR"],
21
+ ["${{ runner.workspace }}", "$CI_PROJECT_DIR"],
22
+ ["${{ job.status }}", "$CI_JOB_STATUS"],
23
+ ["${{ strategy.job-index }}", "$CI_NODE_INDEX"],
24
+ ["${{ strategy.job-total }}", "$CI_NODE_TOTAL"],
25
+ ])("%s → %s", (input, expected) => {
26
+ expect(substituteExpressions(input, ctx).output).toBe(expected);
27
+ });
28
+ });
29
+
30
+ describe("substituteExpressions — user-defined vars", () => {
31
+ const ctx = { gitlabPath: "x", sourceKey: "y" };
32
+
33
+ test("env.NAME → $NAME", () => {
34
+ expect(substituteExpressions("${{ env.NODE_VERSION }}", ctx).output).toBe("$NODE_VERSION");
35
+ });
36
+
37
+ test("vars.NAME → $NAME", () => {
38
+ expect(substituteExpressions("${{ vars.REGISTRY }}", ctx).output).toBe("$REGISTRY");
39
+ });
40
+
41
+ test("secrets.NAME → $NAME", () => {
42
+ expect(substituteExpressions("${{ secrets.API_TOKEN }}", ctx).output).toBe("$API_TOKEN");
43
+ });
44
+
45
+ test("inputs.NAME → $NAME", () => {
46
+ expect(substituteExpressions("${{ inputs.environment }}", ctx).output).toBe("$environment");
47
+ });
48
+
49
+ test("matrix.NAME → $NAME", () => {
50
+ expect(substituteExpressions("${{ matrix.os }}", ctx).output).toBe("$os");
51
+ });
52
+
53
+ test("multiple substitutions in one string", () => {
54
+ expect(
55
+ substituteExpressions("Deploying ${{ env.APP }} to ${{ vars.ENV }}", ctx).output,
56
+ ).toBe("Deploying $APP to $ENV");
57
+ });
58
+ });
59
+
60
+ describe("substituteExpressions — needs-review", () => {
61
+ const ctx = { gitlabPath: "x", sourceKey: "y" };
62
+
63
+ test("steps.<id>.outputs.<name> → $name + needs-review", () => {
64
+ const { output, provenance } = substituteExpressions("${{ steps.get-version.outputs.tag }}", ctx);
65
+ expect(output).toBe("$tag");
66
+ expect(provenance.some((p) => p.category === "needs-review")).toBe(true);
67
+ });
68
+
69
+ test("needs.<job>.outputs.<name> → $name + needs-review", () => {
70
+ const { output, provenance } = substituteExpressions("${{ needs.build.outputs.version }}", ctx);
71
+ expect(output).toBe("$version");
72
+ expect(provenance.some((p) => p.rule === "MIG-NEEDS-OUTPUTS-001")).toBe(true);
73
+ });
74
+
75
+ test("runner.os has no equivalent", () => {
76
+ const { provenance } = substituteExpressions("${{ runner.os }}", ctx);
77
+ expect(provenance.some((p) => p.rule === "MIG-EXPR-NO-EQUIV")).toBe(true);
78
+ });
79
+
80
+ test("github.run_attempt has no equivalent", () => {
81
+ const { provenance } = substituteExpressions("${{ github.run_attempt }}", ctx);
82
+ expect(provenance.some((p) => p.rule === "MIG-EXPR-NO-EQUIV")).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe("translateIfFunction", () => {
87
+ test("always()", () => {
88
+ expect(translateIfFunction("always()").whenClause).toBe("always");
89
+ });
90
+ test("success()", () => {
91
+ expect(translateIfFunction("success()").whenClause).toBe("on_success");
92
+ });
93
+ test("failure()", () => {
94
+ expect(translateIfFunction("failure()").whenClause).toBe("on_failure");
95
+ });
96
+ test("cancelled() flagged for review", () => {
97
+ expect(translateIfFunction("cancelled()").needsReview).toBe(true);
98
+ });
99
+ });
100
+
101
+ describe("translateIfCondition", () => {
102
+ const ctx = { gitlabPath: "rules", sourceKey: "if" };
103
+
104
+ test("simple branch comparison", () => {
105
+ const { ifExpression } = translateIfCondition("github.ref == 'refs/heads/main'", ctx);
106
+ expect(ifExpression).toContain("$CI_COMMIT_REF_NAME");
107
+ });
108
+
109
+ test("boolean function alone → when:", () => {
110
+ const r = translateIfCondition("failure()", ctx);
111
+ expect(r.whenClause).toBe("on_failure");
112
+ expect(r.ifExpression).toBe("");
113
+ });
114
+
115
+ test("retains operators", () => {
116
+ const { ifExpression } = translateIfCondition(
117
+ "github.event_name == 'push' && github.ref == 'refs/heads/main'",
118
+ ctx,
119
+ );
120
+ expect(ifExpression).toContain("$CI_PIPELINE_SOURCE");
121
+ expect(ifExpression).toContain("$CI_COMMIT_REF_NAME");
122
+ expect(ifExpression).toContain("&&");
123
+ });
124
+ });
@@ -0,0 +1,302 @@
1
+ /**
2
+ * GitHub Actions expression substitution.
3
+ *
4
+ * Translates `${{ github.X }}`, `${{ runner.X }}`, `${{ secrets.X }}`,
5
+ * `${{ env.X }}`, `${{ vars.X }}`, `${{ inputs.X }}`, `${{ matrix.X }}`,
6
+ * and a small set of expression functions (`always()`, `failure()`, etc.)
7
+ * to their GitLab CI predefined-variable equivalents.
8
+ *
9
+ * Source: the upstream `github-actions-to-gitlab-ci` skill's
10
+ * `references/syntax-mapping.md` "github.* context expressions" tables.
11
+ */
12
+
13
+ import type { ProvenanceRecord } from "./provenance";
14
+
15
+ /**
16
+ * Direct 1:1 mappings from GitHub context identifiers to GitLab predefined
17
+ * variables. Keys are the dotted identifier *without* the `${{ }}` wrapper.
18
+ */
19
+ const GITHUB_CONTEXT_MAP: Record<string, string> = {
20
+ "github.sha": "$CI_COMMIT_SHA",
21
+ "github.ref": "$CI_COMMIT_REF_NAME",
22
+ "github.ref_name": "$CI_COMMIT_REF_NAME",
23
+ "github.repository": "$CI_PROJECT_PATH",
24
+ "github.repository_owner": "$CI_PROJECT_NAMESPACE",
25
+ "github.actor": "$GITLAB_USER_LOGIN",
26
+ "github.triggering_actor": "$GITLAB_USER_LOGIN",
27
+ "github.event_name": "$CI_PIPELINE_SOURCE",
28
+ "github.workflow": "$CI_PIPELINE_NAME",
29
+ "github.run_id": "$CI_PIPELINE_ID",
30
+ "github.run_number": "$CI_PIPELINE_IID",
31
+ "github.job": "$CI_JOB_NAME",
32
+ "github.head_ref": "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME",
33
+ "github.base_ref": "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME",
34
+ "github.workspace": "$CI_PROJECT_DIR",
35
+ "github.server_url": "$CI_SERVER_URL",
36
+ "runner.workspace": "$CI_PROJECT_DIR",
37
+ "job.status": "$CI_JOB_STATUS",
38
+ "strategy.job-index": "$CI_NODE_INDEX",
39
+ "strategy.job-total": "$CI_NODE_TOTAL",
40
+ };
41
+
42
+ /**
43
+ * GitHub expressions with no GitLab equivalent. When detected, the
44
+ * substitution leaves a placeholder and registers a needs-review record.
45
+ */
46
+ const NO_EQUIVALENT = new Set<string>([
47
+ "github.run_attempt",
48
+ "runner.os",
49
+ "runner.arch",
50
+ "runner.temp",
51
+ "job.container.id",
52
+ ]);
53
+
54
+ /**
55
+ * Map a GitHub `if:` boolean function to a GitLab `when:` value or rule.
56
+ *
57
+ * Returned tuple is `[gitlabExpression, isWhenClause]` — when `isWhenClause`
58
+ * is true, the caller should emit `when: <expr>` rather than `if: <expr>`.
59
+ */
60
+ export function translateIfFunction(
61
+ expr: string,
62
+ ): { expression: string; whenClause?: string; needsReview?: boolean } {
63
+ const trimmed = expr.trim();
64
+ if (trimmed === "always()") return { expression: "true", whenClause: "always" };
65
+ if (trimmed === "success()") return { expression: "true", whenClause: "on_success" };
66
+ if (trimmed === "failure()") return { expression: "true", whenClause: "on_failure" };
67
+ if (trimmed === "cancelled()" || trimmed === "canceled()") {
68
+ return { expression: "true", needsReview: true };
69
+ }
70
+ return { expression: trimmed };
71
+ }
72
+
73
+ /**
74
+ * Substitute all `${{ ... }}` template expressions in a string with their
75
+ * GitLab equivalents. Returns the substituted string plus any provenance
76
+ * records describing which substitutions happened (or which need review).
77
+ */
78
+ export function substituteExpressions(
79
+ input: string,
80
+ ctx: { gitlabPath: string; sourceKey?: string; sourceFile?: string },
81
+ ): { output: string; provenance: ProvenanceRecord[] } {
82
+ const records: ProvenanceRecord[] = [];
83
+ // GitHub expressions are wrapped in ${{ ... }}.
84
+ const output = input.replace(/\$\{\{\s*([^}]+?)\s*\}\}/g, (match, expr: string) => {
85
+ const trimmed = expr.trim();
86
+
87
+ // Direct context lookup
88
+ if (Object.prototype.hasOwnProperty.call(GITHUB_CONTEXT_MAP, trimmed)) {
89
+ records.push({
90
+ gitlabPath: ctx.gitlabPath,
91
+ sourceKey: ctx.sourceKey,
92
+ sourceFile: ctx.sourceFile,
93
+ category: "rewrite",
94
+ rule: "MIG-EXPR-CONTEXT",
95
+ note: `${trimmed} → ${GITHUB_CONTEXT_MAP[trimmed]}`,
96
+ });
97
+ return GITHUB_CONTEXT_MAP[trimmed];
98
+ }
99
+
100
+ // env.NAME / vars.NAME / secrets.NAME → $NAME
101
+ const userVarMatch = /^(env|vars|secrets|inputs|matrix)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed);
102
+ if (userVarMatch) {
103
+ const [, , name] = userVarMatch;
104
+ records.push({
105
+ gitlabPath: ctx.gitlabPath,
106
+ sourceKey: ctx.sourceKey,
107
+ sourceFile: ctx.sourceFile,
108
+ category: "rewrite",
109
+ rule: "MIG-EXPR-USERVAR",
110
+ note: `${trimmed} → $${name}`,
111
+ });
112
+ return `$${name}`;
113
+ }
114
+
115
+ // steps.<id>.outputs.<name> → $name with needs-review note (requires dotenv)
116
+ const stepsOutputMatch = /^steps\.([A-Za-z_][A-Za-z0-9_-]*)\.outputs\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed);
117
+ if (stepsOutputMatch) {
118
+ const [, , name] = stepsOutputMatch;
119
+ records.push({
120
+ gitlabPath: ctx.gitlabPath,
121
+ sourceKey: ctx.sourceKey,
122
+ sourceFile: ctx.sourceFile,
123
+ category: "needs-review",
124
+ rule: "MIG-NEEDS-OUTPUTS-001",
125
+ note: `${trimmed} requires artifacts:reports:dotenv pattern in GitLab; emitting $${name} placeholder`,
126
+ });
127
+ return `$${name}`;
128
+ }
129
+
130
+ // needs.<job>.outputs.<name> → $name with needs-review note
131
+ const needsOutputMatch = /^needs\.([A-Za-z_][A-Za-z0-9_-]*)\.outputs\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed);
132
+ if (needsOutputMatch) {
133
+ const [, , name] = needsOutputMatch;
134
+ records.push({
135
+ gitlabPath: ctx.gitlabPath,
136
+ sourceKey: ctx.sourceKey,
137
+ sourceFile: ctx.sourceFile,
138
+ category: "needs-review",
139
+ rule: "MIG-NEEDS-OUTPUTS-001",
140
+ note: `${trimmed} requires artifacts:reports:dotenv pattern in GitLab; emitting $${name} placeholder`,
141
+ });
142
+ return `$${name}`;
143
+ }
144
+
145
+ // Boolean expression functions: always(), success(), failure(), cancelled()
146
+ const fnResult = translateIfFunction(trimmed);
147
+ if (fnResult.whenClause) {
148
+ records.push({
149
+ gitlabPath: ctx.gitlabPath,
150
+ sourceKey: ctx.sourceKey,
151
+ sourceFile: ctx.sourceFile,
152
+ category: "rewrite",
153
+ rule: "MIG-EXPR-FUNCTION",
154
+ note: `${trimmed} → when: ${fnResult.whenClause}`,
155
+ });
156
+ return fnResult.expression;
157
+ }
158
+
159
+ // No-equivalent context expressions
160
+ if (NO_EQUIVALENT.has(trimmed)) {
161
+ records.push({
162
+ gitlabPath: ctx.gitlabPath,
163
+ sourceKey: ctx.sourceKey,
164
+ sourceFile: ctx.sourceFile,
165
+ category: "needs-review",
166
+ rule: "MIG-EXPR-NO-EQUIV",
167
+ note: `${trimmed} has no GitLab predefined variable equivalent`,
168
+ });
169
+ return `# TODO: ${trimmed} has no GitLab equivalent`;
170
+ }
171
+
172
+ // Unknown expression — leave the original and flag
173
+ records.push({
174
+ gitlabPath: ctx.gitlabPath,
175
+ sourceKey: ctx.sourceKey,
176
+ sourceFile: ctx.sourceFile,
177
+ category: "needs-review",
178
+ rule: "MIG-EXPR-UNKNOWN",
179
+ note: `Could not translate expression: ${match}`,
180
+ });
181
+ return match;
182
+ });
183
+
184
+ return { output, provenance: records };
185
+ }
186
+
187
+ /**
188
+ * Substitute GitHub identifiers (e.g. `github.ref`) appearing anywhere
189
+ * in a string with their GitLab predefined-variable equivalents.
190
+ * Used for if-conditions, concurrency.group values, and other places
191
+ * where identifiers appear without `${{ }}` wrapping.
192
+ */
193
+ export function substituteIdentifiers(
194
+ input: string,
195
+ ctx: { gitlabPath: string; sourceKey?: string; sourceFile?: string },
196
+ ): { output: string; provenance: ProvenanceRecord[] } {
197
+ const records: ProvenanceRecord[] = [];
198
+ // Match dotted identifiers like `github.ref_name`, `runner.os`,
199
+ // `steps.foo.outputs.bar`, `env.NAME`, etc. — but not when they are
200
+ // already prefixed by `$` (already substituted).
201
+ const output = input.replace(/(?<![\w$])([a-z]+(?:\.[A-Za-z_][A-Za-z0-9_-]*)+)/g, (match: string) => {
202
+ if (Object.prototype.hasOwnProperty.call(GITHUB_CONTEXT_MAP, match)) {
203
+ records.push({
204
+ gitlabPath: ctx.gitlabPath,
205
+ sourceKey: ctx.sourceKey,
206
+ sourceFile: ctx.sourceFile,
207
+ category: "rewrite",
208
+ rule: "MIG-EXPR-CONTEXT",
209
+ note: `${match} → ${GITHUB_CONTEXT_MAP[match]}`,
210
+ });
211
+ return GITHUB_CONTEXT_MAP[match];
212
+ }
213
+ const userVar = /^(env|vars|secrets|inputs|matrix)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(match);
214
+ if (userVar) {
215
+ records.push({
216
+ gitlabPath: ctx.gitlabPath,
217
+ sourceKey: ctx.sourceKey,
218
+ sourceFile: ctx.sourceFile,
219
+ category: "rewrite",
220
+ rule: "MIG-EXPR-USERVAR",
221
+ note: `${match} → $${userVar[2]}`,
222
+ });
223
+ return `$${userVar[2]}`;
224
+ }
225
+ const stepsOutput = /^steps\.[A-Za-z_][A-Za-z0-9_-]*\.outputs\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(match);
226
+ if (stepsOutput) {
227
+ records.push({
228
+ gitlabPath: ctx.gitlabPath,
229
+ sourceKey: ctx.sourceKey,
230
+ sourceFile: ctx.sourceFile,
231
+ category: "needs-review",
232
+ rule: "MIG-NEEDS-OUTPUTS-001",
233
+ note: `${match} requires artifacts:reports:dotenv pattern; emitting $${stepsOutput[1]} placeholder`,
234
+ });
235
+ return `$${stepsOutput[1]}`;
236
+ }
237
+ const needsOutput = /^needs\.[A-Za-z_][A-Za-z0-9_-]*\.outputs\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(match);
238
+ if (needsOutput) {
239
+ records.push({
240
+ gitlabPath: ctx.gitlabPath,
241
+ sourceKey: ctx.sourceKey,
242
+ sourceFile: ctx.sourceFile,
243
+ category: "needs-review",
244
+ rule: "MIG-NEEDS-OUTPUTS-001",
245
+ note: `${match} requires artifacts:reports:dotenv pattern; emitting $${needsOutput[1]} placeholder`,
246
+ });
247
+ return `$${needsOutput[1]}`;
248
+ }
249
+ if (NO_EQUIVALENT.has(match)) {
250
+ records.push({
251
+ gitlabPath: ctx.gitlabPath,
252
+ sourceKey: ctx.sourceKey,
253
+ sourceFile: ctx.sourceFile,
254
+ category: "needs-review",
255
+ rule: "MIG-EXPR-NO-EQUIV",
256
+ note: `${match} has no GitLab predefined variable equivalent`,
257
+ });
258
+ return match;
259
+ }
260
+ return match;
261
+ });
262
+ return { output, provenance: records };
263
+ }
264
+
265
+ /**
266
+ * Translate a GitHub `if:` condition expression (without `${{ }}` wrapping)
267
+ * into a GitLab `rules:if:` expression. Returns substituted expression and
268
+ * any whenClause hint extracted from boolean function calls.
269
+ */
270
+ export function translateIfCondition(
271
+ rawIf: string,
272
+ ctx: { gitlabPath: string; sourceKey?: string; sourceFile?: string },
273
+ ): { ifExpression: string; whenClause?: string; provenance: ProvenanceRecord[] } {
274
+ const records: ProvenanceRecord[] = [];
275
+ let whenClause: string | undefined;
276
+
277
+ // If the whole expression is a single boolean function, translate to when:
278
+ const fnAlone = /^\s*([a-z]+\(\))\s*$/i.exec(rawIf);
279
+ if (fnAlone) {
280
+ const fnResult = translateIfFunction(fnAlone[1]);
281
+ if (fnResult.whenClause) {
282
+ records.push({
283
+ gitlabPath: ctx.gitlabPath,
284
+ sourceKey: ctx.sourceKey,
285
+ sourceFile: ctx.sourceFile,
286
+ category: "rewrite",
287
+ rule: "MIG-IF-WHEN",
288
+ note: `if: ${fnAlone[1]} → when: ${fnResult.whenClause}`,
289
+ });
290
+ whenClause = fnResult.whenClause;
291
+ return { ifExpression: "", whenClause, provenance: records };
292
+ }
293
+ }
294
+
295
+ // Strip any `${{ }}` wrappers first
296
+ const unwrapped = rawIf.replace(/\$\{\{\s*/g, "").replace(/\s*\}\}/g, "");
297
+ // Then substitute identifiers inline
298
+ const subbed = substituteIdentifiers(unwrapped, ctx);
299
+ records.push(...subbed.provenance);
300
+
301
+ return { ifExpression: subbed.output, whenClause, provenance: records };
302
+ }