diff --git a/languageservice/src/complete.reusable-workflows.test.ts b/languageservice/src/complete.reusable-workflows.test.ts index 98fb858..85ef204 100644 --- a/languageservice/src/complete.reusable-workflows.test.ts +++ b/languageservice/src/complete.reusable-workflows.test.ts @@ -5,7 +5,7 @@ import {testFileProvider} from "./test-utils/test-file-provider"; function mapResult(result: CompletionItem[]) { return result.map(x => { - return {label: x.label, description: (x.documentation as MarkupContent).value}; + return {label: x.label, description: (x.documentation as MarkupContent)?.value}; }); } @@ -24,6 +24,9 @@ jobs: expect(result).not.toBeUndefined(); expect(mapResult(result)).toEqual([ + { + label: "key" + }, { label: "name", description: "An optional name" @@ -50,10 +53,78 @@ jobs: expect(result).not.toBeUndefined(); expect(mapResult(result)).toEqual([ + { + label: "key" + }, { label: "name", description: "An optional name" } ]); }); + + it("completes job secrets", async () => { + const input = ` +on: push + +jobs: + build: + uses: ./reusable-workflow-with-inputs.yaml + secrets: + | +`; + const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider}); + + expect(result).not.toBeUndefined(); + // includes inherit since no secrets have been passed in + expect(mapResult(result)).toEqual([ + { + label: "envPAT", + description: "A secret for the environment" + }, + { + label: "inherit" + }, + { + label: "serverPAT" + } + ]); + }); + + it("completes inherit secrets", async () => { + const input = ` +on: push + +jobs: + build: + uses: ./reusable-workflow-with-inputs.yaml + secrets: | +`; + const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider}); + + expect(result).not.toBeUndefined(); + //TODO contains instead of equals + expect(mapResult(result)).toContainEqual({label: "inherit"}); + }); + + it("filters existing secrets", async () => { + const input = ` +on: push + +jobs: + build: + uses: ./reusable-workflow-with-inputs.yaml + secrets: + envPAT: "myPAT" + | +`; + const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider}); + + expect(result).not.toBeUndefined(); + expect(mapResult(result)).toEqual([ + { + label: "serverPAT" + } + ]); + }); }); diff --git a/languageservice/src/complete.ts b/languageservice/src/complete.ts index 8050570..5489c63 100644 --- a/languageservice/src/complete.ts +++ b/languageservice/src/complete.ts @@ -27,7 +27,7 @@ import {findToken} from "./utils/find-token"; import {guessIndentation} from "./utils/indentation-guesser"; import {mapRange} from "./utils/range"; import {getRelCharOffset} from "./utils/rel-char-pos"; -import {transform} from "./utils/transform"; +import {isPlaceholder, transform} from "./utils/transform"; import {Value, ValueProviderConfig} from "./value-providers/config"; import {defaultValueProviders} from "./value-providers/default"; import {definitionValues} from "./value-providers/definition"; @@ -157,7 +157,7 @@ async function getValues( const defaultValueProvider = valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key]; if (defaultValueProvider) { - const values = await defaultValueProvider.get(workflowContext); + const values = await defaultValueProvider.get(workflowContext, existingValues); return filterAndSortCompletionOptions(values, existingValues); } @@ -197,8 +197,8 @@ export function getExistingValues(token: TemplateToken | null, parent: TemplateT const mapKeys = new Set(); const mapToken = parent as MappingToken; - for (const {key} of mapToken) { - if (isString(key)) { + for (const {key, value} of mapToken) { + if (isString(key) && !isPlaceholder(key, value)) { mapKeys.add(key.value); } } diff --git a/languageservice/src/test-utils/test-file-provider.ts b/languageservice/src/test-utils/test-file-provider.ts index 99d075d..d80469d 100644 --- a/languageservice/src/test-utils/test-file-provider.ts +++ b/languageservice/src/test-utils/test-file-provider.ts @@ -59,6 +59,13 @@ on: description: 'An optional name' required: false type: string + key: + type: string + secrets: + envPAT: + required: true + description: 'A secret for the environment' + serverPAT: jobs: build: diff --git a/languageservice/src/utils/transform.ts b/languageservice/src/utils/transform.ts index a71ef9e..d3e2a78 100644 --- a/languageservice/src/utils/transform.ts +++ b/languageservice/src/utils/transform.ts @@ -1,3 +1,6 @@ +import {isString} from "@github/actions-workflow-parser"; +import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; +import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; import {Position, TextDocument} from "vscode-languageserver-textdocument"; import {Range} from "vscode-languageserver-types"; @@ -72,3 +75,12 @@ export function transform(doc: TextDocument, pos: Position): [TextDocument, Posi return [newDoc, newDoc.positionAt(offset)]; } + +// Detect placeholder key and value added by transform +export function isPlaceholder(key: StringToken, value: TemplateToken) { + if (key.value === PLACEHOLDER_KEY && isString(value) && value.value == "") { + return true; + } + + return false; +} diff --git a/languageservice/src/validate.workflow-references.test.ts b/languageservice/src/validate.workflow-references.test.ts index 48614cb..3532840 100644 --- a/languageservice/src/validate.workflow-references.test.ts +++ b/languageservice/src/validate.workflow-references.test.ts @@ -98,6 +98,8 @@ on: push jobs: build: uses: ./reusable-workflow-with-inputs.yaml + secrets: + envPAT: pat `; const result = await validate(createDocument("wf.yaml", input), { fileProvider: testFileProvider @@ -129,6 +131,8 @@ jobs: uses: ./reusable-workflow-with-inputs.yaml with: username: monalisa + secrets: + envPAT: pat `; const result = await validate(createDocument("wf.yaml", input), { fileProvider: testFileProvider diff --git a/languageservice/src/value-providers/config.ts b/languageservice/src/value-providers/config.ts index 40b88df..8531651 100644 --- a/languageservice/src/value-providers/config.ts +++ b/languageservice/src/value-providers/config.ts @@ -21,7 +21,7 @@ export enum ValueProviderKind { export type ValueProvider = { kind: ValueProviderKind; - get: (context: WorkflowContext) => Promise; + get: (context: WorkflowContext, existingValues?: Set) => Promise; }; export interface ValueProviderConfig { diff --git a/languageservice/src/value-providers/default.ts b/languageservice/src/value-providers/default.ts index 8081f45..693d506 100644 --- a/languageservice/src/value-providers/default.ts +++ b/languageservice/src/value-providers/default.ts @@ -2,6 +2,7 @@ import {WorkflowContext} from "../context/workflow-context"; import {ValueProviderConfig, ValueProviderKind} from "./config"; import {needs} from "./needs"; import {reusableJobInputs} from "./reusable-job-inputs"; +import {reusableJobSecrets} from "./reusable-job-secrets"; import {stringsToValues} from "./strings-to-values"; export const DEFAULT_RUNNER_LABELS = [ @@ -28,6 +29,10 @@ export const defaultValueProviders: ValueProviderConfig = { kind: ValueProviderKind.AllowedValues, get: async context => reusableJobInputs(context) }, + "workflow-job-secrets": { + kind: ValueProviderKind.SuggestedValues, + get: async (context, existingValues) => reusableJobSecrets(context, existingValues) + }, "runs-on": { kind: ValueProviderKind.SuggestedValues, get: async (_: WorkflowContext) => stringsToValues(DEFAULT_RUNNER_LABELS) diff --git a/languageservice/src/value-providers/reusable-job-secrets.ts b/languageservice/src/value-providers/reusable-job-secrets.ts new file mode 100644 index 0000000..9eeb45f --- /dev/null +++ b/languageservice/src/value-providers/reusable-job-secrets.ts @@ -0,0 +1,53 @@ +import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; +import {isMapping, isString} from "@github/actions-workflow-parser/templates/tokens/type-guards"; +import {WorkflowContext} from "../context/workflow-context"; +import {Value} from "./config"; + +export function reusableJobSecrets(context: WorkflowContext, existingValues?: Set): Value[] { + if (!context.reusableWorkflowJob) { + return []; + } + + const values: Value[] = []; + + const inheritSecrets = context.reusableWorkflowJob["inherit-secrets"]; + if (inheritSecrets) { + return values; + } + + // Suggest inherit if no other secrets have been set + if (!existingValues || existingValues.size === 0) { + values.push({ + label: "inherit" + }); + } + + if (context.reusableWorkflowJob?.["secret-definitions"]) { + for (const {key, value} of context.reusableWorkflowJob["secret-definitions"]) { + if (!isString(key)) { + continue; + } + + values.push({ + label: key.value, + description: secretDescription(value), + insertText: `${key.value}: ` + }); + } + } + + return values; +} + +function secretDescription(secretDef: TemplateToken): string | undefined { + if (!isMapping(secretDef)) { + return ""; + } + + const descriptionToken = secretDef.find("description"); + if (!descriptionToken || !isString(descriptionToken)) { + return ""; + } + + return descriptionToken.value; +}