Skip to content

Commit adfe4a6

Browse files
committed
Add language service support for action.yml files
1 parent 3734de1 commit adfe4a6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+3707
-466
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ lerna-debug.log
44
node_modules
55
.DS_Store
66

7+
# Nx cache (generated by Lerna/Nx)
8+
.nx/
9+
710
# Minified JSON (generated at build time)
811
*.min.json
912

expressions/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"clean": "rimraf dist",
3737
"format": "prettier --write '**/*.ts'",
3838
"format-check": "prettier --check '**/*.ts'",
39-
"lint": "eslint 'src/**/*.ts'",
39+
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
4040
"lint-fix": "eslint --fix 'src/**/*.ts'",
4141
"prepublishOnly": "npm run build && npm run test",
4242
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",

languageserver/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"clean": "rimraf dist",
3737
"format": "prettier --write '**/*.ts'",
3838
"format-check": "prettier --check '**/*.ts'",
39-
"lint": "eslint 'src/**/*.ts'",
39+
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
4040
"lint-fix": "eslint --fix 'src/**/*.ts'",
4141
"prepublishOnly": "npm run build && npm run test",
4242
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",

languageserver/src/context-providers/steps.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,14 @@ it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
8585
// Get the step context
8686
const stepContext = stepsContext?.get("cache-primes");
8787
expect(stepContext).toBeDefined();
88-
expect(isDescriptionDictionary(stepContext!)).toBe(true);
88+
if (!stepContext) return;
89+
expect(isDescriptionDictionary(stepContext)).toBe(true);
8990

9091
// Get the outputs - should be a dictionary, not null
9192
const outputs = (stepContext as DescriptionDictionary).get("outputs");
9293
expect(outputs).toBeDefined();
93-
expect(isDescriptionDictionary(outputs!)).toBe(true);
94+
if (!outputs) return;
95+
expect(isDescriptionDictionary(outputs)).toBe(true);
9496

9597
// Outputs should be marked incomplete to allow dynamic outputs
9698
const outputsDict = outputs as DescriptionDictionary;

languageservice/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"clean": "rimraf dist",
3636
"format": "prettier --write '**/*.ts'",
3737
"format-check": "prettier --check '**/*.ts'",
38-
"lint": "eslint 'src/**/*.ts'",
38+
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
3939
"lint-fix": "eslint --fix 'src/**/*.ts'",
4040
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/objects.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
4141
"prebuild": "npm run minify-json",
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import {TextDocument} from "vscode-languageserver-textdocument";
2+
import {complete} from "./complete";
3+
import {clearCache} from "./utils/workflow-cache";
4+
5+
beforeEach(() => {
6+
clearCache();
7+
});
8+
9+
describe("complete action files", () => {
10+
function createActionDocument(
11+
content: string,
12+
uri = "file:///test/action.yml"
13+
): [TextDocument, {line: number; character: number}] {
14+
// Parse cursor position and remove the | character
15+
const cursorIndex = content.indexOf("|");
16+
if (cursorIndex === -1) {
17+
throw new Error("No cursor (|) found in content");
18+
}
19+
const newContent = content.substring(0, cursorIndex) + content.substring(cursorIndex + 1);
20+
const doc = TextDocument.create(uri, "yaml", 1, newContent);
21+
const position = doc.positionAt(cursorIndex);
22+
return [doc, position];
23+
}
24+
25+
describe("expression completion in composite actions", () => {
26+
it("completes inputs context", async () => {
27+
const [doc, position] = createActionDocument(`name: My Action
28+
description: Test action
29+
inputs:
30+
name:
31+
description: The name
32+
greeting:
33+
description: The greeting
34+
default: Hello
35+
runs:
36+
using: composite
37+
steps:
38+
- run: echo "\${{ inputs.| }}"
39+
shell: bash`);
40+
const completions = await complete(doc, position);
41+
const labels = completions.map(c => c.label);
42+
43+
expect(labels).toContain("name");
44+
expect(labels).toContain("greeting");
45+
});
46+
47+
it("completes steps context with prior step IDs", async () => {
48+
const [doc, position] = createActionDocument(`name: My Action
49+
description: Test action
50+
runs:
51+
using: composite
52+
steps:
53+
- id: step1
54+
run: echo "hello"
55+
shell: bash
56+
- id: step2
57+
run: echo "\${{ steps.| }}"
58+
shell: bash`);
59+
const completions = await complete(doc, position);
60+
const labels = completions.map(c => c.label);
61+
62+
expect(labels).toContain("step1");
63+
expect(labels).not.toContain("step2"); // Current step should not be included
64+
});
65+
66+
it("completes step properties", async () => {
67+
const [doc, position] = createActionDocument(`name: My Action
68+
description: Test action
69+
runs:
70+
using: composite
71+
steps:
72+
- id: greet
73+
run: echo "hello"
74+
shell: bash
75+
- run: echo "\${{ steps.greet.| }}"
76+
shell: bash`);
77+
const completions = await complete(doc, position);
78+
const labels = completions.map(c => c.label);
79+
80+
expect(labels).toContain("outputs");
81+
expect(labels).toContain("outcome");
82+
expect(labels).toContain("conclusion");
83+
});
84+
85+
it("does not include steps from after cursor position", async () => {
86+
const [doc, position] = createActionDocument(`name: My Action
87+
description: Test action
88+
runs:
89+
using: composite
90+
steps:
91+
- id: first
92+
run: echo "first"
93+
shell: bash
94+
- run: echo "\${{ steps.| }}"
95+
shell: bash
96+
- id: last
97+
run: echo "last"
98+
shell: bash`);
99+
const completions = await complete(doc, position);
100+
const labels = completions.map(c => c.label);
101+
102+
expect(labels).toContain("first");
103+
expect(labels).not.toContain("last");
104+
});
105+
106+
it("completes github context in actions", async () => {
107+
const [doc, position] = createActionDocument(`name: My Action
108+
description: Test action
109+
runs:
110+
using: composite
111+
steps:
112+
- run: echo "\${{ github.| }}"
113+
shell: bash`);
114+
const completions = await complete(doc, position);
115+
const labels = completions.map(c => c.label);
116+
117+
expect(labels).toContain("actor");
118+
expect(labels).toContain("repository");
119+
expect(labels).toContain("ref");
120+
});
121+
122+
it("completes runner context in actions", async () => {
123+
const [doc, position] = createActionDocument(`name: My Action
124+
description: Test action
125+
runs:
126+
using: composite
127+
steps:
128+
- run: echo "\${{ runner.| }}"
129+
shell: bash`);
130+
const completions = await complete(doc, position);
131+
const labels = completions.map(c => c.label);
132+
133+
expect(labels).toContain("os");
134+
expect(labels).toContain("arch");
135+
expect(labels).toContain("temp");
136+
});
137+
});
138+
139+
describe("top-level completions", () => {
140+
it("completes top-level keys", async () => {
141+
const [doc, position] = createActionDocument(`n|`);
142+
const completions = await complete(doc, position);
143+
const labels = completions.map(c => c.label);
144+
145+
expect(labels).toContain("name");
146+
});
147+
148+
it("completes at empty line", async () => {
149+
const [doc, position] = createActionDocument(`name: My Action
150+
|`);
151+
const completions = await complete(doc, position);
152+
const labels = completions.map(c => c.label);
153+
154+
expect(labels).toContain("description");
155+
expect(labels).toContain("runs");
156+
expect(labels).toContain("inputs");
157+
expect(labels).toContain("outputs");
158+
expect(labels).toContain("branding");
159+
expect(labels).toContain("author");
160+
});
161+
});
162+
163+
describe("runs completions", () => {
164+
it("completes runs.using values", async () => {
165+
const [doc, position] = createActionDocument(`name: Test
166+
description: Test
167+
runs:
168+
using: |`);
169+
const completions = await complete(doc, position);
170+
const labels = completions.map(c => c.label);
171+
172+
expect(labels).toContain("composite");
173+
expect(labels).toContain("node20");
174+
expect(labels).toContain("docker");
175+
});
176+
177+
it("completes runs keys", async () => {
178+
const [doc, position] = createActionDocument(`name: Test
179+
description: Test
180+
runs:
181+
|`);
182+
const completions = await complete(doc, position);
183+
const labels = completions.map(c => c.label);
184+
185+
expect(labels).toContain("using");
186+
});
187+
});
188+
189+
describe("branding completions", () => {
190+
it("completes branding keys", async () => {
191+
const [doc, position] = createActionDocument(`name: Test
192+
description: Test
193+
runs:
194+
using: node20
195+
main: index.js
196+
branding:
197+
|`);
198+
const completions = await complete(doc, position);
199+
const labels = completions.map(c => c.label);
200+
201+
expect(labels).toContain("icon");
202+
expect(labels).toContain("color");
203+
});
204+
205+
it("completes branding color values", async () => {
206+
const [doc, position] = createActionDocument(`name: Test
207+
description: Test
208+
runs:
209+
using: node20
210+
main: index.js
211+
branding:
212+
color: |`);
213+
const completions = await complete(doc, position);
214+
const labels = completions.map(c => c.label);
215+
216+
expect(labels).toContain("blue");
217+
expect(labels).toContain("green");
218+
expect(labels).toContain("red");
219+
});
220+
});
221+
222+
describe("inputs completions", () => {
223+
it("completes input property keys", async () => {
224+
const [doc, position] = createActionDocument(`name: Test
225+
description: Test
226+
inputs:
227+
my-input:
228+
|
229+
runs:
230+
using: node20
231+
main: index.js`);
232+
const completions = await complete(doc, position);
233+
const labels = completions.map(c => c.label);
234+
235+
expect(labels).toContain("description");
236+
expect(labels).toContain("required");
237+
expect(labels).toContain("default");
238+
expect(labels).toContain("deprecationMessage");
239+
});
240+
});
241+
242+
describe("document type routing", () => {
243+
it("routes action.yml to action completion", async () => {
244+
const [doc, position] = createActionDocument(`n|`, "file:///my-repo/action.yml");
245+
const completions = await complete(doc, position);
246+
const labels = completions.map(c => c.label);
247+
248+
expect(labels).toContain("name");
249+
// Should NOT contain workflow-specific keys
250+
expect(labels).not.toContain("on");
251+
expect(labels).not.toContain("jobs");
252+
});
253+
254+
it("does not route workflow files to action completion", async () => {
255+
const doc = TextDocument.create("file:///repo/.github/workflows/ci.yml", "yaml", 1, `o`);
256+
const completions = await complete(doc, {line: 0, character: 1});
257+
const labels = completions.map(c => c.label);
258+
259+
expect(labels).toContain("on");
260+
expect(labels).toContain("jobs");
261+
});
262+
});
263+
});

0 commit comments

Comments
 (0)