@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.
- package/README.md +4 -0
- package/dist/integrity.json +3 -2
- package/dist/manifest.json +1 -1
- package/dist/skills/chant-gitlab-migrate.md +117 -0
- package/package.json +11 -4
- package/src/import/generator.ts +20 -2
- package/src/migrate/from-github/actions/index.ts +27 -0
- package/src/migrate/from-github/actions/registry.ts +112 -0
- package/src/migrate/from-github/actions/tier-1.test.ts +128 -0
- package/src/migrate/from-github/actions/tier-1.ts +325 -0
- package/src/migrate/from-github/actions/tier-2-3.test.ts +144 -0
- package/src/migrate/from-github/actions/tier-2.ts +296 -0
- package/src/migrate/from-github/actions/tier-3.ts +124 -0
- package/src/migrate/from-github/composites/patterns.ts +167 -0
- package/src/migrate/from-github/composites/rewriter.test.ts +98 -0
- package/src/migrate/from-github/composites/rewriter.ts +29 -0
- package/src/migrate/from-github/diagnostics.ts +45 -0
- package/src/migrate/from-github/emit-ts.test.ts +49 -0
- package/src/migrate/from-github/emit-yaml.ts +128 -0
- package/src/migrate/from-github/expressions.test.ts +124 -0
- package/src/migrate/from-github/expressions.ts +302 -0
- package/src/migrate/from-github/fixtures/README.md +27 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-checkout/expected-report.json +15 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-checkout/expected.gitlab-ci.yml +13 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-checkout/input.yml +7 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-node/expected-report.json +20 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-node/expected.gitlab-ci.yml +20 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-node/input.yml +12 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-python/expected-report.json +20 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-python/expected.gitlab-ci.yml +17 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-python/input.yml +12 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/docker-build-push/expected-report.json +24 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/docker-build-push/expected.gitlab-ci.yml +20 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/docker-build-push/input.yml +16 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/upload-download-artifact/expected-report.json +24 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/upload-download-artifact/expected.gitlab-ci.yml +27 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/upload-download-artifact/input.yml +20 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/codecov-action/expected-report.json +24 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/codecov-action/expected.gitlab-ci.yml +15 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/codecov-action/input.yml +13 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/setup-bun/expected-report.json +20 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/setup-bun/expected.gitlab-ci.yml +17 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/setup-bun/input.yml +11 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-3/paths-filter/expected-report.json +21 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-3/paths-filter/expected.gitlab-ci.yml +15 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-3/paths-filter/input.yml +11 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/01-triggers/expected-report.json +20 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/01-triggers/expected.gitlab-ci.yml +16 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/01-triggers/input.yml +12 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/02-stages-needs/expected-report.json +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/02-stages-needs/expected.gitlab-ci.yml +31 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/02-stages-needs/input.yml +16 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/expected-report.json +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/expected.gitlab-ci.yml +20 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/input.yml +10 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/04-env-secrets/expected-report.json +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/04-env-secrets/expected.gitlab-ci.yml +18 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/04-env-secrets/input.yml +11 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/05-conditional/expected-report.json +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/05-conditional/expected.gitlab-ci.yml +24 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/05-conditional/input.yml +12 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/06-services/expected-report.json +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/06-services/expected.gitlab-ci.yml +18 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/06-services/input.yml +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/07-job-control/expected-report.json +20 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/07-job-control/expected.gitlab-ci.yml +17 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/07-job-control/input.yml +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/08-workflow-name/expected-report.json +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/08-workflow-name/expected.gitlab-ci.yml +14 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/08-workflow-name/input.yml +7 -0
- package/src/migrate/from-github/fixtures.test.ts +92 -0
- package/src/migrate/from-github/index.ts +128 -0
- package/src/migrate/from-github/provenance.ts +68 -0
- package/src/migrate/from-github/rules.ts +82 -0
- package/src/migrate/from-github/stages.test.ts +99 -0
- package/src/migrate/from-github/stages.ts +177 -0
- package/src/migrate/from-github/transformer.test.ts +278 -0
- package/src/migrate/from-github/transformer.ts +719 -0
- package/src/migrate.mcp.test.ts +69 -0
- package/src/plugin.test.ts +7 -3
- package/src/plugin.ts +105 -1
- package/src/skills/chant-gitlab-migrate.md +117 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { inferStages, type GhJobSummary } from "./stages";
|
|
3
|
+
|
|
4
|
+
function job(name: string, needs: string[] = [], id?: string): GhJobSummary {
|
|
5
|
+
return { logicalId: id ?? name, originalName: name, needs };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("inferStages", () => {
|
|
9
|
+
test("single job → build", () => {
|
|
10
|
+
const r = inferStages([job("build")]);
|
|
11
|
+
expect(r.stageByJob.get("build")).toBe("build");
|
|
12
|
+
expect(r.stages).toEqual(["build"]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("name heuristic: lint goes to lint stage", () => {
|
|
16
|
+
const r = inferStages([job("lint-check")]);
|
|
17
|
+
expect(r.stageByJob.get("lint-check")).toBe("lint");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("name heuristic: deploy at depth 0 still maps to deploy", () => {
|
|
21
|
+
const r = inferStages([job("deploy-prod")]);
|
|
22
|
+
expect(r.stageByJob.get("deploy-prod")).toBe("deploy");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("linear chain build → test → deploy", () => {
|
|
26
|
+
const r = inferStages([
|
|
27
|
+
job("build"),
|
|
28
|
+
job("test", ["build"]),
|
|
29
|
+
job("deploy", ["test"]),
|
|
30
|
+
]);
|
|
31
|
+
expect(r.stageByJob.get("build")).toBe("build");
|
|
32
|
+
expect(r.stageByJob.get("test")).toBe("test");
|
|
33
|
+
expect(r.stageByJob.get("deploy")).toBe("deploy");
|
|
34
|
+
expect(r.stages).toEqual(["build", "test", "deploy"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("diamond DAG", () => {
|
|
38
|
+
const r = inferStages([
|
|
39
|
+
job("setup"),
|
|
40
|
+
job("test-unit", ["setup"]),
|
|
41
|
+
job("test-int", ["setup"]),
|
|
42
|
+
job("ship", ["test-unit", "test-int"]),
|
|
43
|
+
]);
|
|
44
|
+
expect(r.stageByJob.get("setup")).toBe("build");
|
|
45
|
+
expect(r.stageByJob.get("test-unit")).toBe("test");
|
|
46
|
+
expect(r.stageByJob.get("test-int")).toBe("test");
|
|
47
|
+
expect(r.stageByJob.get("ship")).toBe("deploy");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("multiple roots", () => {
|
|
51
|
+
const r = inferStages([
|
|
52
|
+
job("build-a"),
|
|
53
|
+
job("build-b"),
|
|
54
|
+
job("test-a", ["build-a"]),
|
|
55
|
+
job("test-b", ["build-b"]),
|
|
56
|
+
]);
|
|
57
|
+
expect(r.stageByJob.get("build-a")).toBe("build");
|
|
58
|
+
expect(r.stageByJob.get("build-b")).toBe("build");
|
|
59
|
+
expect(r.stageByJob.get("test-a")).toBe("test");
|
|
60
|
+
expect(r.stageByJob.get("test-b")).toBe("test");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("cycle: each job in its own stage + needs-review", () => {
|
|
64
|
+
const r = inferStages([
|
|
65
|
+
job("a", ["b"]),
|
|
66
|
+
job("b", ["a"]),
|
|
67
|
+
]);
|
|
68
|
+
expect(r.stageByJob.get("a")).toBe("cycle-a");
|
|
69
|
+
expect(r.stageByJob.get("b")).toBe("cycle-b");
|
|
70
|
+
expect(r.provenance.some((p) => p.rule === "MIG-NEEDS-CYCLE-001")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("post-N for depths beyond deploy", () => {
|
|
74
|
+
const r = inferStages([
|
|
75
|
+
job("a"),
|
|
76
|
+
job("b", ["a"]),
|
|
77
|
+
job("c", ["b"]),
|
|
78
|
+
job("d", ["c"]),
|
|
79
|
+
job("e", ["d"]),
|
|
80
|
+
]);
|
|
81
|
+
expect(r.stageByJob.get("d")).toBe("post-1");
|
|
82
|
+
expect(r.stageByJob.get("e")).toBe("post-2");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("stages sorted lint < build < test < deploy", () => {
|
|
86
|
+
const r = inferStages([
|
|
87
|
+
job("deploy"),
|
|
88
|
+
job("test", ["build"]),
|
|
89
|
+
job("build"),
|
|
90
|
+
job("lint"),
|
|
91
|
+
]);
|
|
92
|
+
// lint and build are both at depth 0, deploy is at depth 0 too (no needs)
|
|
93
|
+
// The sort guarantees lint, build, deploy ordering for those at the same depth.
|
|
94
|
+
const indexOf = (s: string) => r.stages.indexOf(s);
|
|
95
|
+
expect(indexOf("lint")).toBeLessThan(indexOf("build"));
|
|
96
|
+
expect(indexOf("build")).toBeLessThan(indexOf("test"));
|
|
97
|
+
expect(indexOf("test")).toBeLessThan(indexOf("deploy"));
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage inference for GitHub jobs → GitLab stages.
|
|
3
|
+
*
|
|
4
|
+
* GitHub Actions has no `stages:` concept; jobs only declare optional
|
|
5
|
+
* `needs:` edges. GitLab CI requires every job to belong to a stage.
|
|
6
|
+
*
|
|
7
|
+
* Approach (Kahn topological sort on the `needs:` DAG):
|
|
8
|
+
* 1. Build adjacency from `needs:` (jobs without `needs:` are depth 0).
|
|
9
|
+
* 2. Peel zero-indegree nodes; assign stage by depth + name heuristic.
|
|
10
|
+
* 3. Map depths to stage names:
|
|
11
|
+
* depth 0 → "lint" / "build" (by name heuristic, default "build")
|
|
12
|
+
* depth 1 → "test"
|
|
13
|
+
* depth 2 → "deploy"
|
|
14
|
+
* depth N>2 → `post-${N-2}`
|
|
15
|
+
* 4. If a cycle is detected, place each remaining job in its own stage
|
|
16
|
+
* and append a needs-review record so the user notices.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ProvenanceRecord } from "./provenance";
|
|
20
|
+
|
|
21
|
+
export interface GhJobSummary {
|
|
22
|
+
logicalId: string;
|
|
23
|
+
needs: string[];
|
|
24
|
+
/** Original GH job key (kebab-case) for name heuristics */
|
|
25
|
+
originalName: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StageInferenceResult {
|
|
29
|
+
/** Map from job logicalId to assigned stage name */
|
|
30
|
+
stageByJob: Map<string, string>;
|
|
31
|
+
/** Ordered list of distinct stages, declaration-order */
|
|
32
|
+
stages: string[];
|
|
33
|
+
/** Any provenance records (cycles, synthesis events) */
|
|
34
|
+
provenance: ProvenanceRecord[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const NAME_HEURISTIC_PATTERNS: Array<{ pattern: RegExp; stage: string }> = [
|
|
38
|
+
{ pattern: /^(lint|check|fmt|format|style|typecheck|tsc)/i, stage: "lint" },
|
|
39
|
+
{ pattern: /^(build|compile|bundle|pack)/i, stage: "build" },
|
|
40
|
+
{ pattern: /^(test|spec|e2e|integration|unit)/i, stage: "test" },
|
|
41
|
+
{ pattern: /^(deploy|publish|release|push)/i, stage: "deploy" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function heuristicStage(name: string, fallback: string): string {
|
|
45
|
+
for (const { pattern, stage } of NAME_HEURISTIC_PATTERNS) {
|
|
46
|
+
if (pattern.test(name)) return stage;
|
|
47
|
+
}
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function defaultStageForDepth(depth: number): string {
|
|
52
|
+
if (depth === 0) return "build";
|
|
53
|
+
if (depth === 1) return "test";
|
|
54
|
+
if (depth === 2) return "deploy";
|
|
55
|
+
return `post-${depth - 2}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function inferStages(jobs: GhJobSummary[]): StageInferenceResult {
|
|
59
|
+
const provenance: ProvenanceRecord[] = [];
|
|
60
|
+
const stageByJob = new Map<string, string>();
|
|
61
|
+
const jobsById = new Map(jobs.map((j) => [j.logicalId, j]));
|
|
62
|
+
|
|
63
|
+
// Compute indegree based on needs that resolve to known jobs
|
|
64
|
+
const indegree = new Map<string, number>();
|
|
65
|
+
for (const job of jobs) {
|
|
66
|
+
indegree.set(job.logicalId, 0);
|
|
67
|
+
}
|
|
68
|
+
for (const job of jobs) {
|
|
69
|
+
for (const dep of job.needs) {
|
|
70
|
+
if (jobsById.has(dep)) {
|
|
71
|
+
indegree.set(job.logicalId, (indegree.get(job.logicalId) ?? 0) + 1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Kahn's algorithm, recording depth per node
|
|
77
|
+
const depth = new Map<string, number>();
|
|
78
|
+
const queue: string[] = [];
|
|
79
|
+
for (const job of jobs) {
|
|
80
|
+
if ((indegree.get(job.logicalId) ?? 0) === 0) {
|
|
81
|
+
queue.push(job.logicalId);
|
|
82
|
+
depth.set(job.logicalId, 0);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const processed = new Set<string>();
|
|
87
|
+
while (queue.length > 0) {
|
|
88
|
+
const current = queue.shift()!;
|
|
89
|
+
processed.add(current);
|
|
90
|
+
const currentDepth = depth.get(current) ?? 0;
|
|
91
|
+
for (const next of jobs) {
|
|
92
|
+
if (next.needs.includes(current)) {
|
|
93
|
+
const remaining = (indegree.get(next.logicalId) ?? 0) - 1;
|
|
94
|
+
indegree.set(next.logicalId, remaining);
|
|
95
|
+
const proposed = currentDepth + 1;
|
|
96
|
+
const existing = depth.get(next.logicalId);
|
|
97
|
+
if (existing === undefined || proposed > existing) {
|
|
98
|
+
depth.set(next.logicalId, proposed);
|
|
99
|
+
}
|
|
100
|
+
if (remaining === 0) {
|
|
101
|
+
queue.push(next.logicalId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Detect cycles — any job not processed is on a cycle
|
|
108
|
+
const cycleJobs = jobs.filter((j) => !processed.has(j.logicalId));
|
|
109
|
+
for (const job of cycleJobs) {
|
|
110
|
+
provenance.push({
|
|
111
|
+
gitlabPath: `jobs.${job.logicalId}.stage`,
|
|
112
|
+
gitlabLogicalId: job.logicalId,
|
|
113
|
+
sourceKey: `jobs.${job.originalName}.needs`,
|
|
114
|
+
category: "needs-review",
|
|
115
|
+
rule: "MIG-NEEDS-CYCLE-001",
|
|
116
|
+
note: `Job "${job.originalName}" is part of a needs: cycle; assigned its own stage`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Assign stages
|
|
121
|
+
for (const job of jobs) {
|
|
122
|
+
if (cycleJobs.includes(job)) {
|
|
123
|
+
stageByJob.set(job.logicalId, `cycle-${job.logicalId}`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const d = depth.get(job.logicalId) ?? 0;
|
|
127
|
+
const defaultStage = defaultStageForDepth(d);
|
|
128
|
+
const stage = d === 0
|
|
129
|
+
? heuristicStage(job.originalName, defaultStage)
|
|
130
|
+
: d === 1
|
|
131
|
+
? heuristicStage(job.originalName, defaultStage)
|
|
132
|
+
: defaultStage;
|
|
133
|
+
stageByJob.set(job.logicalId, stage);
|
|
134
|
+
if (stage !== defaultStage) {
|
|
135
|
+
provenance.push({
|
|
136
|
+
gitlabPath: `jobs.${job.logicalId}.stage`,
|
|
137
|
+
gitlabLogicalId: job.logicalId,
|
|
138
|
+
sourceKey: `jobs.${job.originalName}`,
|
|
139
|
+
category: "synthesis",
|
|
140
|
+
rule: "MIG-STAGE-HEURISTIC",
|
|
141
|
+
note: `Stage "${stage}" inferred from job name "${job.originalName}"`,
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
provenance.push({
|
|
145
|
+
gitlabPath: `jobs.${job.logicalId}.stage`,
|
|
146
|
+
gitlabLogicalId: job.logicalId,
|
|
147
|
+
sourceKey: `jobs.${job.originalName}`,
|
|
148
|
+
category: "synthesis",
|
|
149
|
+
rule: "MIG-STAGE-TOPO",
|
|
150
|
+
note: `Stage "${stage}" assigned from needs: depth ${d}`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Collect distinct stages, ordered by appearance
|
|
156
|
+
const stagesSet = new Set<string>();
|
|
157
|
+
const stagesOrdered: string[] = [];
|
|
158
|
+
for (const job of jobs) {
|
|
159
|
+
const s = stageByJob.get(job.logicalId);
|
|
160
|
+
if (s && !stagesSet.has(s)) {
|
|
161
|
+
stagesSet.add(s);
|
|
162
|
+
stagesOrdered.push(s);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Sort by canonical depth order: lint, build, test, deploy, post-N, cycle-*
|
|
166
|
+
const canonicalOrder = ["lint", "build", "test", "deploy"];
|
|
167
|
+
stagesOrdered.sort((a, b) => {
|
|
168
|
+
const ai = canonicalOrder.indexOf(a);
|
|
169
|
+
const bi = canonicalOrder.indexOf(b);
|
|
170
|
+
if (ai !== -1 && bi !== -1) return ai - bi;
|
|
171
|
+
if (ai !== -1) return -1;
|
|
172
|
+
if (bi !== -1) return 1;
|
|
173
|
+
return a.localeCompare(b);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return { stageByJob, stages: stagesOrdered, provenance };
|
|
177
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { transform, detectGitHubWorkflow } from "./index";
|
|
3
|
+
|
|
4
|
+
describe("detectGitHubWorkflow", () => {
|
|
5
|
+
test("recognises a minimal GH workflow", () => {
|
|
6
|
+
const yml = `name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hello\n`;
|
|
7
|
+
expect(detectGitHubWorkflow(yml)).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("rejects unrelated YAML", () => {
|
|
11
|
+
const yml = `stages:\n - build\nbuild-job:\n script:\n - make\n`;
|
|
12
|
+
expect(detectGitHubWorkflow(yml)).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("transform — minimal workflow", () => {
|
|
17
|
+
test("emits jobs at top level + stages", async () => {
|
|
18
|
+
const yml = `name: CI
|
|
19
|
+
on: push
|
|
20
|
+
jobs:
|
|
21
|
+
build:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- run: npm ci
|
|
25
|
+
- run: npm run build
|
|
26
|
+
test:
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
needs: build
|
|
29
|
+
steps:
|
|
30
|
+
- run: npm test
|
|
31
|
+
`;
|
|
32
|
+
const result = await transform(yml, { sourceFile: "ci.yml" });
|
|
33
|
+
expect(result.output).toContain("stages:");
|
|
34
|
+
expect(result.output).toContain("build:");
|
|
35
|
+
expect(result.output).toContain("test:");
|
|
36
|
+
expect(result.output).toContain("- npm ci");
|
|
37
|
+
expect(result.output).toContain("- npm test");
|
|
38
|
+
expect(result.stages).toContain("build");
|
|
39
|
+
expect(result.stages).toContain("test");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("workflow name → workflow.name", async () => {
|
|
43
|
+
const yml = `name: My CI
|
|
44
|
+
on: push
|
|
45
|
+
jobs:
|
|
46
|
+
build:
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
steps:
|
|
49
|
+
- run: make
|
|
50
|
+
`;
|
|
51
|
+
const result = await transform(yml);
|
|
52
|
+
expect(result.output).toMatch(/workflow:[\s\S]*name:/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("runs-on: ubuntu-latest → image: ubuntu:24.04", async () => {
|
|
56
|
+
const yml = `on: push
|
|
57
|
+
jobs:
|
|
58
|
+
job1:
|
|
59
|
+
runs-on: ubuntu-latest
|
|
60
|
+
steps:
|
|
61
|
+
- run: echo hi
|
|
62
|
+
`;
|
|
63
|
+
const result = await transform(yml);
|
|
64
|
+
expect(result.output).toContain("ubuntu:24.04");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("env at workflow + job level lands in variables", async () => {
|
|
68
|
+
const yml = `on: push
|
|
69
|
+
env:
|
|
70
|
+
TOP: top-value
|
|
71
|
+
jobs:
|
|
72
|
+
build:
|
|
73
|
+
runs-on: ubuntu-latest
|
|
74
|
+
env:
|
|
75
|
+
JOBVAR: job-value
|
|
76
|
+
steps:
|
|
77
|
+
- run: echo $TOP $JOBVAR
|
|
78
|
+
`;
|
|
79
|
+
const result = await transform(yml);
|
|
80
|
+
expect(result.output).toContain("TOP");
|
|
81
|
+
expect(result.output).toContain("JOBVAR");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("matrix → parallel.matrix", async () => {
|
|
85
|
+
const yml = `on: push
|
|
86
|
+
jobs:
|
|
87
|
+
test:
|
|
88
|
+
runs-on: ubuntu-latest
|
|
89
|
+
strategy:
|
|
90
|
+
matrix:
|
|
91
|
+
node: [18, 20, 22]
|
|
92
|
+
os: [ubuntu, windows]
|
|
93
|
+
steps:
|
|
94
|
+
- run: echo testing
|
|
95
|
+
`;
|
|
96
|
+
const result = await transform(yml);
|
|
97
|
+
expect(result.output).toContain("parallel:");
|
|
98
|
+
expect(result.output).toContain("matrix:");
|
|
99
|
+
expect(result.output).toContain("NODE");
|
|
100
|
+
expect(result.output).toContain("OS");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("timeout-minutes → timeout", async () => {
|
|
104
|
+
const yml = `on: push
|
|
105
|
+
jobs:
|
|
106
|
+
build:
|
|
107
|
+
runs-on: ubuntu-latest
|
|
108
|
+
timeout-minutes: 30
|
|
109
|
+
steps:
|
|
110
|
+
- run: make
|
|
111
|
+
`;
|
|
112
|
+
const result = await transform(yml);
|
|
113
|
+
expect(result.output).toMatch(/timeout:\s*'?30 minutes'?/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("continue-on-error → allow_failure", async () => {
|
|
117
|
+
const yml = `on: push
|
|
118
|
+
jobs:
|
|
119
|
+
flaky:
|
|
120
|
+
runs-on: ubuntu-latest
|
|
121
|
+
continue-on-error: true
|
|
122
|
+
steps:
|
|
123
|
+
- run: maybe-fail
|
|
124
|
+
`;
|
|
125
|
+
const result = await transform(yml);
|
|
126
|
+
expect(result.output).toMatch(/allow_failure:\s*true/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("if: github.ref == 'refs/heads/main' translates", async () => {
|
|
130
|
+
const yml = `on: push
|
|
131
|
+
jobs:
|
|
132
|
+
deploy:
|
|
133
|
+
runs-on: ubuntu-latest
|
|
134
|
+
if: github.ref == 'refs/heads/main'
|
|
135
|
+
steps:
|
|
136
|
+
- run: ./deploy.sh
|
|
137
|
+
`;
|
|
138
|
+
const result = await transform(yml);
|
|
139
|
+
expect(result.output).toContain("$CI_COMMIT_REF_NAME");
|
|
140
|
+
expect(result.output).toContain("rules:");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("permissions emits needs-review", async () => {
|
|
144
|
+
const yml = `on: push
|
|
145
|
+
jobs:
|
|
146
|
+
build:
|
|
147
|
+
runs-on: ubuntu-latest
|
|
148
|
+
permissions:
|
|
149
|
+
contents: read
|
|
150
|
+
steps:
|
|
151
|
+
- run: make
|
|
152
|
+
`;
|
|
153
|
+
const result = await transform(yml);
|
|
154
|
+
expect(result.provenance.some((p) => p.rule === "MIG-PERMISSIONS-001")).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("actions/checkout becomes a no-op skip (Tier 1 registry)", async () => {
|
|
158
|
+
const yml = `on: push
|
|
159
|
+
jobs:
|
|
160
|
+
build:
|
|
161
|
+
runs-on: ubuntu-latest
|
|
162
|
+
steps:
|
|
163
|
+
- uses: actions/checkout@v4
|
|
164
|
+
- run: npm test
|
|
165
|
+
`;
|
|
166
|
+
const result = await transform(yml);
|
|
167
|
+
// With Tier 1 registry, actions/checkout is intentionally skipped
|
|
168
|
+
expect(result.provenance.some((p) => p.rule === "ACT-actions-checkout" && p.category === "skipped")).toBe(true);
|
|
169
|
+
// npm test still emitted
|
|
170
|
+
expect(result.output).toContain("- npm test");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("unknown action emits MIG-ACTION-UNKNOWN", async () => {
|
|
174
|
+
const yml = `on: push
|
|
175
|
+
jobs:
|
|
176
|
+
build:
|
|
177
|
+
runs-on: ubuntu-latest
|
|
178
|
+
steps:
|
|
179
|
+
- uses: some-org/never-mapped-action@v1
|
|
180
|
+
- run: echo done
|
|
181
|
+
`;
|
|
182
|
+
const result = await transform(yml);
|
|
183
|
+
expect(result.provenance.some((p) => p.rule === "MIG-ACTION-UNKNOWN")).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("transform — provenance + diagnostics", () => {
|
|
188
|
+
test("clean translation emits no error diagnostics", async () => {
|
|
189
|
+
const yml = `on: push
|
|
190
|
+
jobs:
|
|
191
|
+
build:
|
|
192
|
+
runs-on: ubuntu-latest
|
|
193
|
+
steps:
|
|
194
|
+
- run: npm test
|
|
195
|
+
`;
|
|
196
|
+
const result = await transform(yml);
|
|
197
|
+
const errors = result.diagnostics.filter((d) => d.severity === "error");
|
|
198
|
+
expect(errors).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("strict escalates needs-review to error", async () => {
|
|
202
|
+
const yml = `on: schedule
|
|
203
|
+
jobs:
|
|
204
|
+
cron:
|
|
205
|
+
runs-on: ubuntu-latest
|
|
206
|
+
steps:
|
|
207
|
+
- run: ./run.sh
|
|
208
|
+
`;
|
|
209
|
+
const result = await transform(yml, { strict: true });
|
|
210
|
+
const errors = result.diagnostics.filter((d) => d.severity === "error");
|
|
211
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("matrix-driven image substitutes to $NODE (not leaking ${{ }})", async () => {
|
|
215
|
+
const yml = `on: push
|
|
216
|
+
jobs:
|
|
217
|
+
test:
|
|
218
|
+
runs-on: ubuntu-latest
|
|
219
|
+
strategy:
|
|
220
|
+
matrix:
|
|
221
|
+
node: [18, 20, 22]
|
|
222
|
+
steps:
|
|
223
|
+
- uses: actions/setup-node@v4
|
|
224
|
+
with:
|
|
225
|
+
node-version: \${{ matrix.node }}
|
|
226
|
+
- run: npm test
|
|
227
|
+
`;
|
|
228
|
+
const result = await transform(yml);
|
|
229
|
+
expect(result.output).toContain("image: node:$node");
|
|
230
|
+
expect(result.output).not.toContain("\${{ matrix.node }}");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("env value with embedded ${{ secrets.X }} substitutes to $X", async () => {
|
|
234
|
+
const yml = `on: push
|
|
235
|
+
jobs:
|
|
236
|
+
deploy:
|
|
237
|
+
runs-on: ubuntu-latest
|
|
238
|
+
env:
|
|
239
|
+
TOKEN: \${{ secrets.API_TOKEN }}
|
|
240
|
+
steps:
|
|
241
|
+
- run: ./deploy.sh
|
|
242
|
+
`;
|
|
243
|
+
const result = await transform(yml);
|
|
244
|
+
// YAML emitter quotes strings starting with `$` as `'$API_TOKEN'`
|
|
245
|
+
expect(result.output).toMatch(/TOKEN:\s*'?\$API_TOKEN'?/);
|
|
246
|
+
expect(result.output).not.toContain("\${{ secrets.API_TOKEN }}");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("container.image with embedded expression substitutes", async () => {
|
|
250
|
+
const yml = `on: push
|
|
251
|
+
jobs:
|
|
252
|
+
test:
|
|
253
|
+
runs-on: ubuntu-latest
|
|
254
|
+
container:
|
|
255
|
+
image: my-registry/app:\${{ github.sha }}
|
|
256
|
+
steps:
|
|
257
|
+
- run: ./test.sh
|
|
258
|
+
`;
|
|
259
|
+
const result = await transform(yml);
|
|
260
|
+
expect(result.output).toContain("my-registry/app:$CI_COMMIT_SHA");
|
|
261
|
+
expect(result.output).not.toContain("\${{ github.sha }}");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("ir.metadata.migration is set", async () => {
|
|
265
|
+
const yml = `on: push
|
|
266
|
+
jobs:
|
|
267
|
+
build:
|
|
268
|
+
runs-on: ubuntu-latest
|
|
269
|
+
steps:
|
|
270
|
+
- run: make
|
|
271
|
+
`;
|
|
272
|
+
const result = await transform(yml, { sourceFile: "ci.yml" });
|
|
273
|
+
expect(result.ir.metadata?.migration).toEqual({
|
|
274
|
+
sourceFile: "ci.yml",
|
|
275
|
+
sourceTool: "github-actions",
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|