From fa27dfa563811564a86c84f9877f7cb3a1780cf0 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Tue, 13 Jan 2026 09:14:20 -0600 Subject: [PATCH 1/2] Add action.yml scaffolding snippets (#296) --- expressions/src/features.test.ts | 6 +- expressions/src/features.ts | 13 +- languageservice/src/complete-action.test.ts | 163 ++++++- languageservice/src/complete-action.ts | 468 ++++++++++++++++++++ languageservice/src/complete.ts | 112 +---- languageservice/src/index.ts | 2 +- 6 files changed, 661 insertions(+), 103 deletions(-) create mode 100644 languageservice/src/complete-action.ts diff --git a/expressions/src/features.test.ts b/expressions/src/features.test.ts index 6694141..425c72a 100644 --- a/expressions/src/features.test.ts +++ b/expressions/src/features.test.ts @@ -51,7 +51,11 @@ describe("FeatureFlags", () => { it("returns all features when all is enabled", () => { const flags = new FeatureFlags({all: true}); - expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix", "blockScalarChompingWarning"]); + expect(flags.getEnabledFeatures()).toEqual([ + "missingInputsQuickfix", + "blockScalarChompingWarning", + "actionScaffoldingSnippets" + ]); }); }); }); diff --git a/expressions/src/features.ts b/expressions/src/features.ts index b0a0770..159fbd1 100644 --- a/expressions/src/features.ts +++ b/expressions/src/features.ts @@ -28,6 +28,13 @@ export interface ExperimentalFeatures { * @default false */ blockScalarChompingWarning?: boolean; + + /** + * Enable action scaffolding snippets in action.yml files. + * Offers Node.js, Composite, and Docker action scaffolds. + * @default false + */ + actionScaffoldingSnippets?: boolean; } /** @@ -39,7 +46,11 @@ export type ExperimentalFeatureKey = Exclude; * All known experimental feature keys. * This list must be kept in sync with the ExperimentalFeatures interface. */ -const allFeatureKeys: ExperimentalFeatureKey[] = ["missingInputsQuickfix", "blockScalarChompingWarning"]; +const allFeatureKeys: ExperimentalFeatureKey[] = [ + "missingInputsQuickfix", + "blockScalarChompingWarning", + "actionScaffoldingSnippets" +]; export class FeatureFlags { private readonly features: ExperimentalFeatures; diff --git a/languageservice/src/complete-action.test.ts b/languageservice/src/complete-action.test.ts index 1ffe00b..1f2e5eb 100644 --- a/languageservice/src/complete-action.test.ts +++ b/languageservice/src/complete-action.test.ts @@ -1,11 +1,17 @@ +import {FeatureFlags} from "@actions/expressions"; import {TextDocument} from "vscode-languageserver-textdocument"; -import {complete} from "./complete"; +import {complete, CompletionConfig} from "./complete"; import {clearCache} from "./utils/workflow-cache"; beforeEach(() => { clearCache(); }); +// Config to enable action scaffolding snippets +const scaffoldingConfig: CompletionConfig = { + featureFlags: new FeatureFlags({actionScaffoldingSnippets: true}) +}; + describe("complete action files", () => { function createActionDocument( content: string, @@ -393,4 +399,159 @@ runs: expect(labels).toContain("jobs"); }); }); + + describe("action scaffolding snippets", () => { + it("offers full scaffolding snippets in empty file", async () => { + const [doc, position] = createActionDocument(`|`); + const completions = await complete(doc, position, scaffoldingConfig); + const labels = completions.map(c => c.label); + + expect(labels).toContain("Node.js Action"); + expect(labels).toContain("Composite Action"); + expect(labels).toContain("Docker Action"); + + // Verify they are snippets + const nodeSnippet = completions.find(c => c.label === "Node.js Action"); + expect(nodeSnippet?.kind).toBe(15); // CompletionItemKind.Snippet + expect(nodeSnippet?.insertTextFormat).toBe(2); // InsertTextFormat.Snippet + }); + + it("offers full scaffolding snippets when no name or description exists", async () => { + const [doc, position] = createActionDocument(`author: me +|`); + const completions = await complete(doc, position, scaffoldingConfig); + + const nodeSnippet = completions.find(c => c.label === "Node.js Action"); + expect(nodeSnippet).toBeDefined(); + // Full snippet should include name: + expect((nodeSnippet?.textEdit as {newText: string})?.newText).toContain("name:"); + }); + + it("offers runs-only snippets when name exists", async () => { + const [doc, position] = createActionDocument(`name: My Action +|`); + const completions = await complete(doc, position, scaffoldingConfig); + + const nodeSnippet = completions.find(c => c.label === "Node.js Action"); + expect(nodeSnippet).toBeDefined(); + // Runs-only snippet should start with inputs:, not name: + expect((nodeSnippet?.textEdit as {newText: string})?.newText).toMatch(/^inputs:/); + expect((nodeSnippet?.textEdit as {newText: string})?.newText).toContain("runs:"); + }); + + it("offers runs-only snippets when description exists", async () => { + const [doc, position] = createActionDocument(`description: Does something +|`); + const completions = await complete(doc, position, scaffoldingConfig); + + const compositeSnippet = completions.find(c => c.label === "Composite Action"); + expect(compositeSnippet).toBeDefined(); + // Runs-only snippet should start with inputs:, not description: + expect((compositeSnippet?.textEdit as {newText: string})?.newText).toMatch(/^inputs:/); + expect((compositeSnippet?.textEdit as {newText: string})?.newText).toContain("runs:"); + }); + + it("does not offer snippets when runs.using already exists", async () => { + const [doc, position] = createActionDocument(`name: My Action +description: Test +runs: + using: composite + |`); + const completions = await complete(doc, position, scaffoldingConfig); + const labels = completions.map(c => c.label); + + expect(labels).not.toContain("Node.js Action"); + expect(labels).not.toContain("Composite Action"); + expect(labels).not.toContain("Docker Action"); + }); + + it("offers snippets inside runs when using is not set", async () => { + const [doc, position] = createActionDocument(`name: My Action +description: Test +runs: + |`); + const completions = await complete(doc, position, scaffoldingConfig); + const labels = completions.map(c => c.label); + + expect(labels).toContain("Node.js Action"); + expect(labels).toContain("Composite Action"); + expect(labels).toContain("Docker Action"); + }); + + it("does not offer snippets at root level when runs exists", async () => { + const [doc, position] = createActionDocument(`name: My Action +description: Test +runs: + steps: [] +|`); + const completions = await complete(doc, position, scaffoldingConfig); + const labels = completions.map(c => c.label); + + expect(labels).not.toContain("Node.js Action"); + expect(labels).not.toContain("Composite Action"); + expect(labels).not.toContain("Docker Action"); + }); + + it("does not offer snippets when nested inside runs steps", async () => { + const [doc, position] = createActionDocument(`name: My Action +description: Test +runs: + using: composite + steps: + - |`); + const completions = await complete(doc, position, scaffoldingConfig); + const labels = completions.map(c => c.label); + + expect(labels).not.toContain("Node.js Action"); + expect(labels).not.toContain("Composite Action"); + expect(labels).not.toContain("Docker Action"); + }); + + it("Node.js snippet contains expected content", async () => { + const [doc, position] = createActionDocument(`|`); + const completions = await complete(doc, position, scaffoldingConfig); + + const nodeSnippet = completions.find(c => c.label === "Node.js Action"); + const text = (nodeSnippet?.textEdit as {newText: string})?.newText; + + expect(text).toContain("using: node24"); + expect(text).toContain("main:"); + expect(text).toContain("inputs:"); + expect(text).toContain("outputs:"); + }); + + it("Composite snippet contains expected content", async () => { + const [doc, position] = createActionDocument(`|`); + const completions = await complete(doc, position, scaffoldingConfig); + + const compositeSnippet = completions.find(c => c.label === "Composite Action"); + const text = (compositeSnippet?.textEdit as {newText: string})?.newText; + + expect(text).toContain("using: composite"); + expect(text).toContain("steps:"); + expect(text).toContain("shell: bash"); + }); + + it("Docker snippet contains expected content", async () => { + const [doc, position] = createActionDocument(`|`); + const completions = await complete(doc, position, scaffoldingConfig); + + const dockerSnippet = completions.find(c => c.label === "Docker Action"); + const text = (dockerSnippet?.textEdit as {newText: string})?.newText; + + expect(text).toContain("using: docker"); + expect(text).toContain("image:"); + expect(text).toContain("entrypoint:"); + }); + + it("does not offer snippets when feature flag is disabled", async () => { + const [doc, position] = createActionDocument(`|`); + const completions = await complete(doc, position); + const labels = completions.map(c => c.label); + + expect(labels).not.toContain("Node.js Action"); + expect(labels).not.toContain("Composite Action"); + expect(labels).not.toContain("Docker Action"); + }); + }); }); diff --git a/languageservice/src/complete-action.ts b/languageservice/src/complete-action.ts new file mode 100644 index 0000000..e265255 --- /dev/null +++ b/languageservice/src/complete-action.ts @@ -0,0 +1,468 @@ +import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index"; +import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token"; +import {Position} from "vscode-languageserver-textdocument"; +import {CompletionItem, CompletionItemKind, InsertTextFormat, TextEdit} from "vscode-languageserver-types"; +import {Value} from "./value-providers/config.js"; + +/** + * Valid keys for each action type under the `runs:` section. + * Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs + */ +const ACTION_NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]); +const ACTION_COMPOSITE_KEYS = new Set(["using", "steps"]); +const ACTION_DOCKER_KEYS = new Set([ + "using", + "image", + "args", + "env", + "entrypoint", + "pre-entrypoint", + "pre-if", + "post-entrypoint", + "post-if" +]); + +/** + * Action scaffolding snippets. + * + * Full variants include name, description, inputs, outputs, and runs. + * Runs-only variants include just the runs block. + */ +const ACTION_SNIPPET_NODEJS_FULL = `name: '\${1:Action Name}' +description: '\${2:What this action does}' + +inputs: + name: + description: 'Name to greet' + required: false + default: 'World' + +outputs: + greeting: + description: 'The greeting message' + +runs: + # For more on JavaScript actions (including @actions/toolkit), see: + # https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action + using: node24 + main: index.js + # Sample index.js (vanilla JS, no build required): + # + # const fs = require('fs'); + # const name = process.env.INPUT_NAME || 'World'; + # const greeting = \\\`Hello \\\${name}\\\`; + # console.log(greeting); + # fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`); + # + # For JavaScript actions with @actions/toolkit, see: + # https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action +`; + +const ACTION_SNIPPET_NODEJS_RUNS = `inputs: + name: + description: 'Name to greet' + required: false + default: 'World' + +outputs: + greeting: + description: 'The greeting message' + +runs: + # For more on JavaScript actions (including @actions/toolkit), see: + # https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action + using: node24 + main: index.js + # Sample index.js (vanilla JS, no build required): + # + # const fs = require('fs'); + # const name = process.env.INPUT_NAME || 'World'; + # const greeting = \\\`Hello \\\${name}\\\`; + # console.log(greeting); + # fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`); +`; + +const ACTION_SNIPPET_NODEJS_USING = `# For more on JavaScript actions (including @actions/toolkit), see: + # https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action + using: node24 + main: index.js + # Sample index.js (vanilla JS, no build required): + # + # console.log('Hello World'); +`; + +const ACTION_SNIPPET_COMPOSITE_FULL = `name: '\${1:Action Name}' +description: '\${2:What this action does}' + +inputs: + name: + description: 'Name to greet' + required: false + default: 'World' + +outputs: + greeting: + description: 'The greeting message' + value: \\\${{ steps.greet.outputs.greeting }} + +runs: + # For more on composite actions, see: + # https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action + using: composite + steps: + - id: greet + shell: bash + env: + INPUT_NAME: \\\${{ inputs.name }} + run: | + GREETING="Hello $INPUT_NAME" + echo "$GREETING" + echo "greeting=$GREETING" >> $GITHUB_OUTPUT +`; + +const ACTION_SNIPPET_COMPOSITE_RUNS = `inputs: + name: + description: 'Name to greet' + required: false + default: 'World' + +outputs: + greeting: + description: 'The greeting message' + value: \\\${{ steps.greet.outputs.greeting }} + +runs: + # For more on composite actions, see: + # https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action + using: composite + steps: + - id: greet + shell: bash + env: + INPUT_NAME: \\\${{ inputs.name }} + run: | + GREETING="Hello $INPUT_NAME" + echo "$GREETING" + echo "greeting=$GREETING" >> $GITHUB_OUTPUT +`; + +const ACTION_SNIPPET_COMPOSITE_USING = `# For more on composite actions, see: + # https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action + using: composite + steps: + - shell: bash + run: echo "Hello World" +`; + +const ACTION_SNIPPET_DOCKER_FULL = `name: '\${1:Action Name}' +description: '\${2:What this action does}' + +inputs: + name: + description: 'Name to greet' + required: false + default: 'World' + +outputs: + greeting: + description: 'The greeting message' + +runs: + # For more on Docker actions, see: + # https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action + using: docker + # 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally + image: '\${3:docker://alpine:3.20}' + env: + INPUT_NAME: \\\${{ inputs.name }} + entrypoint: '\${4:sh}' + args: + - -c + - | + GREETING="Hello $INPUT_NAME" + echo "$GREETING" + echo "greeting=$GREETING" >> $GITHUB_OUTPUT +`; + +const ACTION_SNIPPET_DOCKER_RUNS = `inputs: + name: + description: 'Name to greet' + required: false + default: 'World' + +outputs: + greeting: + description: 'The greeting message' + +runs: + # For more on Docker actions, see: + # https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action + using: docker + # 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally + image: '\${1:docker://alpine:3.20}' + env: + INPUT_NAME: \\\${{ inputs.name }} + entrypoint: '\${2:sh}' + args: + - -c + - | + GREETING="Hello $INPUT_NAME" + echo "$GREETING" + echo "greeting=$GREETING" >> $GITHUB_OUTPUT +`; + +const ACTION_SNIPPET_DOCKER_USING = `# For more on Docker actions, see: + # https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action + using: docker + # 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally + image: '\${1:docker://alpine:3.20}' + entrypoint: '\${2:sh}' + args: + - -c + - echo "Hello World" +`; + +/** + * Filters action.yml `runs:` completions based on the `using:` value. + * + * When the user is completing keys under `runs:`: + * - If `using: node20` is set, only show Node.js action keys + * - If `using: composite` is set, only show composite action keys + * - If `using: docker` is set, only show Docker action keys + * - If `using:` is not set, show all keys but prioritize `using` first + */ +export function filterActionRunsCompletions(values: Value[], path: TemplateToken[], root: TemplateToken): Value[] { + // Find the runs mapping from the root + let runsMapping: MappingToken | undefined; + if (root instanceof MappingToken) { + for (let i = 0; i < root.count; i++) { + const {key, value} = root.get(i); + if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) { + runsMapping = value; + break; + } + } + } + if (!runsMapping) { + return values; + } + + // Check if the runs mapping is in our path (meaning we're completing inside it) + const isInsideRuns = path.some(token => token === runsMapping); + if (!isInsideRuns) { + return values; + } + + // Find where runsMapping is in the path + const runsMappingIndex = path.indexOf(runsMapping); + if (runsMappingIndex === -1) { + return values; + } + + // Check if there's anything after runsMapping in the path + // If so, we're nested deeper (e.g., inside steps sequence or a step mapping) + if (runsMappingIndex < path.length - 1) { + return values; + } + + // Get the using value from the runs mapping + let usingValue: string | undefined; + for (let i = 0; i < runsMapping.count; i++) { + const {key, value} = runsMapping.get(i); + if (key.toString().toLowerCase() === "using") { + usingValue = value.toString(); + break; + } + } + + // Determine which keys to allow + let allowedKeys: Set; + + if (!usingValue) { + // No using value set - show all keys but prioritize "using" + return values.map(v => { + if (v.label.toLowerCase() === "using") { + return {...v, sortText: "0_using"}; // Sort first + } + return v; + }); + } else if (usingValue.match(/^node\d+$/i)) { + allowedKeys = ACTION_NODE_KEYS; + } else if (usingValue.toLowerCase() === "composite") { + allowedKeys = ACTION_COMPOSITE_KEYS; + } else if (usingValue.toLowerCase() === "docker") { + allowedKeys = ACTION_DOCKER_KEYS; + } else { + // Unknown using value - show all + return values; + } + + // Filter to only allowed keys + return values.filter(v => allowedKeys.has(v.label.toLowerCase())); +} + +/** + * Gets action scaffolding snippet completions for action.yml files. + * + * Returns snippet completions when `runs.using` is not present, offering + * three action types: Node.js, Composite, and Docker. + * + * Three variants per type: + * - "_FULL": Full scaffold with name, description, inputs, outputs, and runs + * - "_RUNS": Inputs, outputs, and runs (when name/description already exists) + * - "_USING": Minimal runs content (when inside `runs:` mapping) + * + * Which variant is shown depends on context: + * - Inside `runs:` mapping → "_USING" variants + * - At root with name/description → "_RUNS" variants + * - At root without name/description → "_FULL" variants + */ +export function getActionScaffoldingSnippets( + root: TemplateToken | undefined, + path: TemplateToken[], + position: Position +): CompletionItem[] { + // Get the runs mapping from the root, if it exists + let runsMapping: MappingToken | undefined; + if (root instanceof MappingToken) { + for (let i = 0; i < root.count; i++) { + const {key, value} = root.get(i); + if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) { + runsMapping = value; + break; + } + } + } + + // Check if runs.using already exists - if so, no scaffolding needed + if (runsMapping) { + for (let i = 0; i < runsMapping.count; i++) { + const {key} = runsMapping.get(i); + if (key.toString().toLowerCase() === "using") { + return []; + } + } + } + + // Show "_USING" variants directly inside `runs` + const runsMappingIndex = runsMapping ? path.indexOf(runsMapping) : -1; + const isDirectlyInsideRuns = runsMappingIndex !== -1 && runsMappingIndex === path.length - 1; + if (isDirectlyInsideRuns) { + return [ + createSnippetCompletion( + "Node.js Action", + "Scaffold a Node.js action", + ACTION_SNIPPET_NODEJS_USING, + position, + "1_nodejs" + ), + createSnippetCompletion( + "Composite Action", + "Scaffold a composite action", + ACTION_SNIPPET_COMPOSITE_USING, + position, + "2_composite" + ), + createSnippetCompletion( + "Docker Action", + "Scaffold a Docker action", + ACTION_SNIPPET_DOCKER_USING, + position, + "3_docker" + ) + ]; + } + + // Not at root or `runs` already exists? + const isAtRoot = path.length === 0 || (path.length === 1 && path[0] === root); + if (!isAtRoot || runsMapping) { + return []; + } + + // Determine which variant to show based on existing root keys + let hasNameOrDescription = false; + if (root instanceof MappingToken) { + for (let i = 0; i < root.count; i++) { + const keyStr = root.get(i).key.toString().toLowerCase(); + if (keyStr === "name" || keyStr === "description") { + hasNameOrDescription = true; + break; + } + } + } + + // Show "_RUNS" variants (inputs, outputs, and runs block) + if (hasNameOrDescription) { + return [ + createSnippetCompletion( + "Node.js Action", + "Scaffold a Node.js action", + ACTION_SNIPPET_NODEJS_RUNS, + position, + "1_nodejs" + ), + createSnippetCompletion( + "Composite Action", + "Scaffold a composite action", + ACTION_SNIPPET_COMPOSITE_RUNS, + position, + "2_composite" + ), + createSnippetCompletion( + "Docker Action", + "Scaffold a Docker action", + ACTION_SNIPPET_DOCKER_RUNS, + position, + "3_docker" + ) + ]; + } + + // Show "_FULL" variants (complete scaffold) + return [ + createSnippetCompletion( + "Node.js Action", + "Scaffold a complete Node.js action", + ACTION_SNIPPET_NODEJS_FULL, + position, + "1_nodejs" + ), + createSnippetCompletion( + "Composite Action", + "Scaffold a complete composite action", + ACTION_SNIPPET_COMPOSITE_FULL, + position, + "2_composite" + ), + createSnippetCompletion( + "Docker Action", + "Scaffold a complete Docker action", + ACTION_SNIPPET_DOCKER_FULL, + position, + "3_docker" + ) + ]; +} + +/** + * Creates a snippet completion item. + */ +function createSnippetCompletion( + label: string, + description: string, + snippetText: string, + position: Position, + sortText: string +): CompletionItem { + return { + label, + kind: CompletionItemKind.Snippet, + documentation: { + kind: "markdown", + value: description + }, + insertTextFormat: InsertTextFormat.Snippet, + sortText, + textEdit: TextEdit.insert(position, snippetText) + }; +} diff --git a/languageservice/src/complete.ts b/languageservice/src/complete.ts index b71da41..a760ac7 100644 --- a/languageservice/src/complete.ts +++ b/languageservice/src/complete.ts @@ -1,4 +1,4 @@ -import {complete as completeExpression, DescriptionDictionary} from "@actions/expressions"; +import {complete as completeExpression, DescriptionDictionary, FeatureFlags} from "@actions/expressions"; import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion"; import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser"; import {getActionSchema} from "@actions/workflow-parser/actions/action-schema"; @@ -16,6 +16,7 @@ import {FileProvider} from "@actions/workflow-parser/workflows/file-provider"; import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema"; import {Position, TextDocument} from "vscode-languageserver-textdocument"; import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types"; +import {filterActionRunsCompletions, getActionScaffoldingSnippets} from "./complete-action.js"; import {ContextProviderConfig} from "./context-providers/config.js"; import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js"; import {ActionContext, getActionContext} from "./context/action-context.js"; @@ -38,24 +39,6 @@ import {Value, ValueProviderConfig} from "./value-providers/config.js"; import {defaultValueProviders} from "./value-providers/default.js"; import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js"; -/** - * Valid keys for each action type under the `runs:` section. - * Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs - */ -const ACTION_NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]); -const ACTION_COMPOSITE_KEYS = new Set(["using", "steps"]); -const ACTION_DOCKER_KEYS = new Set([ - "using", - "image", - "args", - "env", - "entrypoint", - "pre-entrypoint", - "pre-if", - "post-entrypoint", - "post-if" -]); - export function getExpressionInput(input: string, pos: number): string { // Find start marker around the cursor position let startPos = input.lastIndexOf(OPEN_EXPRESSION, pos); @@ -72,6 +55,7 @@ export type CompletionConfig = { valueProviderConfig?: ValueProviderConfig; contextProviderConfig?: ContextProviderConfig; fileProvider?: FileProvider; + featureFlags?: FeatureFlags; }; export async function complete( @@ -174,6 +158,12 @@ export async function complete( const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema); values.push(...escapeHatches); + // Get action scaffolding snippets if applicable + let actionSnippets: CompletionItem[] = []; + if (isAction && config?.featureFlags?.isEnabled("actionScaffoldingSnippets")) { + actionSnippets = getActionScaffoldingSnippets(parsedTemplate.value, path, position); + } + // Figure out what text to replace when the user picks a completion. // For example, if they typed `runs-|` and pick `runs-on`, we need to replace `runs-`. let replaceRange: Range | undefined; @@ -202,7 +192,7 @@ export async function complete( } // Convert values to LSP CompletionItems - return values.map(value => { + const completionItems = values.map(value => { const newText = value.insertText || value.label; // Escape hatches provide their own textEdit to restructure the YAML @@ -237,6 +227,9 @@ export async function complete( return item; }); + + // Add action scaffolding snippets if available + return [...completionItems, ...actionSnippets]; } /** @@ -626,82 +619,3 @@ function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: P // = 32 + 11 = 43 return lengthOfContentBeforeCurrentLine + pos.character; } - -/** - * Filters action.yml `runs:` completions based on the `using:` value. - * - * When the user is completing keys under `runs:`: - * - If `using: node20` is set, only show Node.js action keys - * - If `using: composite` is set, only show composite action keys - * - If `using: docker` is set, only show Docker action keys - * - If `using:` is not set, show all keys but prioritize `using` first - */ -function filterActionRunsCompletions(values: Value[], path: TemplateToken[], root: TemplateToken): Value[] { - // Find the runs mapping from the root - let runsMapping: MappingToken | undefined; - if (root instanceof MappingToken) { - for (let i = 0; i < root.count; i++) { - const {key, value} = root.get(i); - if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) { - runsMapping = value; - break; - } - } - } - if (!runsMapping) { - return values; - } - - // Check if the runs mapping is in our path (meaning we're completing inside it) - const isInsideRuns = path.some(token => token === runsMapping); - if (!isInsideRuns) { - return values; - } - - // Find where runsMapping is in the path - const runsMappingIndex = path.indexOf(runsMapping); - if (runsMappingIndex === -1) { - return values; - } - - // Check if there's anything after runsMapping in the path - // If so, we're nested deeper (e.g., inside steps sequence or a step mapping) - if (runsMappingIndex < path.length - 1) { - return values; - } - - // Get the using value from the runs mapping - let usingValue: string | undefined; - for (let i = 0; i < runsMapping.count; i++) { - const {key, value} = runsMapping.get(i); - if (key.toString().toLowerCase() === "using") { - usingValue = value.toString(); - break; - } - } - - // Determine which keys to allow - let allowedKeys: Set; - - if (!usingValue) { - // No using value set - show all keys but prioritize "using" - return values.map(v => { - if (v.label.toLowerCase() === "using") { - return {...v, sortText: "0_using"}; // Sort first - } - return v; - }); - } else if (usingValue.match(/^node\d+$/i)) { - allowedKeys = ACTION_NODE_KEYS; - } else if (usingValue.toLowerCase() === "composite") { - allowedKeys = ACTION_COMPOSITE_KEYS; - } else if (usingValue.toLowerCase() === "docker") { - allowedKeys = ACTION_DOCKER_KEYS; - } else { - // Unknown using value - show all - return values; - } - - // Filter to only allowed keys - return values.filter(v => allowedKeys.has(v.label.toLowerCase())); -} diff --git a/languageservice/src/index.ts b/languageservice/src/index.ts index 20c5c76..059dae2 100644 --- a/languageservice/src/index.ts +++ b/languageservice/src/index.ts @@ -1,4 +1,4 @@ -export {complete} from "./complete.js"; +export {complete, CompletionConfig} from "./complete.js"; export {ContextProviderConfig} from "./context-providers/config.js"; export {documentLinks} from "./document-links.js"; export {hover} from "./hover.js"; From 1baa74a67edfb7d408f60973333cca930e8a5044 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 19:27:39 -0600 Subject: [PATCH 2/2] Release extension version 0.3.35 (#297) Co-authored-by: GitHub Actions --- expressions/package.json | 2 +- languageserver/package.json | 6 +++--- languageservice/package.json | 6 +++--- lerna.json | 2 +- package-lock.json | 18 +++++++++--------- workflow-parser/package.json | 4 ++-- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/expressions/package.json b/expressions/package.json index 8fabe65..baa1309 100755 --- a/expressions/package.json +++ b/expressions/package.json @@ -1,6 +1,6 @@ { "name": "@actions/expressions", - "version": "0.3.34", + "version": "0.3.35", "license": "MIT", "type": "module", "source": "./src/index.ts", diff --git a/languageserver/package.json b/languageserver/package.json index 85b6f11..acede5a 100644 --- a/languageserver/package.json +++ b/languageserver/package.json @@ -1,6 +1,6 @@ { "name": "@actions/languageserver", - "version": "0.3.34", + "version": "0.3.35", "description": "Language server for GitHub Actions", "license": "MIT", "type": "module", @@ -48,8 +48,8 @@ "actions-languageserver": "./bin/actions-languageserver" }, "dependencies": { - "@actions/languageservice": "^0.3.34", - "@actions/workflow-parser": "^0.3.34", + "@actions/languageservice": "^0.3.35", + "@actions/workflow-parser": "^0.3.35", "@octokit/rest": "^21.1.1", "@octokit/types": "^9.0.0", "vscode-languageserver": "^8.0.2", diff --git a/languageservice/package.json b/languageservice/package.json index 86e8e16..9e201d9 100644 --- a/languageservice/package.json +++ b/languageservice/package.json @@ -1,6 +1,6 @@ { "name": "@actions/languageservice", - "version": "0.3.34", + "version": "0.3.35", "description": "Language service for GitHub Actions", "license": "MIT", "type": "module", @@ -47,8 +47,8 @@ "watch": "tsc --build tsconfig.build.json --watch" }, "dependencies": { - "@actions/expressions": "^0.3.34", - "@actions/workflow-parser": "^0.3.34", + "@actions/expressions": "^0.3.35", + "@actions/workflow-parser": "^0.3.35", "vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-types": "^3.17.2", "vscode-uri": "^3.0.8", diff --git a/lerna.json b/lerna.json index ab6c0d4..d16055c 100644 --- a/lerna.json +++ b/lerna.json @@ -6,5 +6,5 @@ "languageservice", "languageserver" ], - "version": "0.3.34" + "version": "0.3.35" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d3df828..3f6e168 100644 --- a/package-lock.json +++ b/package-lock.json @@ -136,7 +136,7 @@ }, "expressions": { "name": "@actions/expressions", - "version": "0.3.34", + "version": "0.3.35", "license": "MIT", "devDependencies": { "@types/jest": "^29.0.3", @@ -396,11 +396,11 @@ }, "languageserver": { "name": "@actions/languageserver", - "version": "0.3.34", + "version": "0.3.35", "license": "MIT", "dependencies": { - "@actions/languageservice": "^0.3.34", - "@actions/workflow-parser": "^0.3.34", + "@actions/languageservice": "^0.3.35", + "@actions/workflow-parser": "^0.3.35", "@octokit/rest": "^21.1.1", "@octokit/types": "^9.0.0", "vscode-languageserver": "^8.0.2", @@ -940,11 +940,11 @@ }, "languageservice": { "name": "@actions/languageservice", - "version": "0.3.34", + "version": "0.3.35", "license": "MIT", "dependencies": { - "@actions/expressions": "^0.3.34", - "@actions/workflow-parser": "^0.3.34", + "@actions/expressions": "^0.3.35", + "@actions/workflow-parser": "^0.3.35", "vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-types": "^3.17.2", "vscode-uri": "^3.0.8", @@ -13345,10 +13345,10 @@ }, "workflow-parser": { "name": "@actions/workflow-parser", - "version": "0.3.34", + "version": "0.3.35", "license": "MIT", "dependencies": { - "@actions/expressions": "^0.3.34", + "@actions/expressions": "^0.3.35", "cronstrue": "^2.21.0", "yaml": "^2.0.0-8" }, diff --git a/workflow-parser/package.json b/workflow-parser/package.json index 47f8f8f..a9c8d59 100644 --- a/workflow-parser/package.json +++ b/workflow-parser/package.json @@ -1,6 +1,6 @@ { "name": "@actions/workflow-parser", - "version": "0.3.34", + "version": "0.3.35", "license": "MIT", "type": "module", "source": "./src/index.ts", @@ -48,7 +48,7 @@ "watch": "tsc --build tsconfig.build.json --watch" }, "dependencies": { - "@actions/expressions": "^0.3.34", + "@actions/expressions": "^0.3.35", "cronstrue": "^2.21.0", "yaml": "^2.0.0-8" },