From 63fdcf143fcca58c6853179794d20a721b66fb39 Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Tue, 7 Feb 2023 19:13:41 -0500 Subject: [PATCH] Support completing reusable job inputs --- .../src/complete.reusable-workflows.test.ts | 59 +++++++++++++++ .../src/test-utils/test-file-provider.ts | 75 +++++++++++++++++++ actions-languageservice/src/validate.ts | 9 ++- .../src/validate.workflow-references.test.ts | 71 +----------------- .../src/value-providers/default.ts | 5 ++ .../value-providers/reusable-job-inputs.ts | 40 ++++++++++ 6 files changed, 187 insertions(+), 72 deletions(-) create mode 100644 actions-languageservice/src/complete.reusable-workflows.test.ts create mode 100644 actions-languageservice/src/test-utils/test-file-provider.ts create mode 100644 actions-languageservice/src/value-providers/reusable-job-inputs.ts diff --git a/actions-languageservice/src/complete.reusable-workflows.test.ts b/actions-languageservice/src/complete.reusable-workflows.test.ts new file mode 100644 index 0000000..98fb858 --- /dev/null +++ b/actions-languageservice/src/complete.reusable-workflows.test.ts @@ -0,0 +1,59 @@ +import {CompletionItem, MarkupContent} from "vscode-languageserver-types"; +import {complete} from "./complete"; +import {getPositionFromCursor} from "./test-utils/cursor-position"; +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}; + }); +} + +describe("completion with reusable workflows", () => { + it("completes job inputs", async () => { + const input = ` +on: push + +jobs: + build: + uses: ./reusable-workflow-with-inputs.yaml + with: + | +`; + const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider}); + + expect(result).not.toBeUndefined(); + expect(mapResult(result)).toEqual([ + { + label: "name", + description: "An optional name" + }, + { + label: "username", + description: "A username passed from the caller workflow" + } + ]); + }); + + it("filters out existing job inputs", async () => { + const input = ` +on: push + +jobs: + build: + uses: ./reusable-workflow-with-inputs.yaml + with: + username: monalisa + | +`; + const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider}); + + expect(result).not.toBeUndefined(); + expect(mapResult(result)).toEqual([ + { + label: "name", + description: "An optional name" + } + ]); + }); +}); diff --git a/actions-languageservice/src/test-utils/test-file-provider.ts b/actions-languageservice/src/test-utils/test-file-provider.ts new file mode 100644 index 0000000..8e45679 --- /dev/null +++ b/actions-languageservice/src/test-utils/test-file-provider.ts @@ -0,0 +1,75 @@ +import {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider"; +import {fileIdentifier} from "@github/actions-workflow-parser/workflows/file-reference"; + +export const testFileProvider: FileProvider = { + getFileContent: async ref => { + switch (fileIdentifier(ref)) { + case "monalisa/octocat/workflow.yaml@main": + return { + name: "monalisa/octocat/workflow.yaml", + content: ` +on: workflow_call +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 +` + }; + + case "monalisa/octocat/.github/workflows/non-reusable-workflow.yaml@main": + return { + name: "monalisa/octocat/.github/workflows/non-reusable-workflow.yaml", + content: ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 +` + }; + + case "./reusable-workflow.yaml": + return { + name: "reusable-workflow.yaml", + content: ` +on: workflow_call +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 +` + }; + + case "./reusable-workflow-with-inputs.yaml": + return { + name: "reusable-workflow-with-inputs.yaml", + content: ` +on: + workflow_call: + inputs: + username: + description: 'A username passed from the caller workflow' + required: true + type: string + + name: + description: 'An optional name' + required: false + type: string + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + ` + }; + + default: + throw new Error("File not found"); + } + } +}; diff --git a/actions-languageservice/src/validate.ts b/actions-languageservice/src/validate.ts index ca91193..544a163 100644 --- a/actions-languageservice/src/validate.ts +++ b/actions-languageservice/src/validate.ts @@ -118,15 +118,20 @@ async function additionalValidations( // Allowed values coming from the schema have already been validated. Only check if // a value provider is defined for a token and if it is, validate the values match. - if (config?.valueProviderConfig && token.range && validationDefinition) { + if (token.range && validationDefinition) { const defKey = validationDefinition.key; if (defKey === "step-with") { // Action inputs should be validated already in validateAction continue; } + if (defKey === "workflow-job-with") { + // Reusable workflow job inputs are validated by the parser + continue; + } + // Try a custom value provider first - let valueProvider = config.valueProviderConfig[defKey]; + let valueProvider = config?.valueProviderConfig?.[defKey]; if (!valueProvider) { // fall back to default valueProvider = defaultValueProviders[defKey]; diff --git a/actions-languageservice/src/validate.workflow-references.test.ts b/actions-languageservice/src/validate.workflow-references.test.ts index e9f256a..48614cb 100644 --- a/actions-languageservice/src/validate.workflow-references.test.ts +++ b/actions-languageservice/src/validate.workflow-references.test.ts @@ -1,76 +1,7 @@ -import {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider"; -import {fileIdentifier} from "@github/actions-workflow-parser/workflows/file-reference"; import {createDocument} from "./test-utils/document"; +import {testFileProvider} from "./test-utils/test-file-provider"; import {validate} from "./validate"; -const testFileProvider: FileProvider = { - getFileContent: async ref => { - switch (fileIdentifier(ref)) { - case "monalisa/octocat/workflow.yaml@main": - return { - name: "monalisa/octocat/workflow.yaml", - content: ` -on: workflow_call -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 -` - }; - - case "monalisa/octocat/.github/workflows/non-reusable-workflow.yaml@main": - return { - name: "monalisa/octocat/.github/workflows/non-reusable-workflow.yaml", - content: ` -on: push -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 -` - }; - - case "./reusable-workflow.yaml": - return { - name: "reusable-workflow.yaml", - content: ` -on: workflow_call -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 -` - }; - - case "./reusable-workflow-with-inputs.yaml": - return { - name: "reusable-workflow-with-inputs.yaml", - content: ` -on: - workflow_call: - inputs: - username: - description: 'A username passed from the caller workflow' - required: true - type: string - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - ` - }; - - default: - throw new Error("File not found"); - } - } -}; - describe("workflow references validation", () => { it("invalid workflow reference", async () => { const input = ` diff --git a/actions-languageservice/src/value-providers/default.ts b/actions-languageservice/src/value-providers/default.ts index be59611..3c915cf 100644 --- a/actions-languageservice/src/value-providers/default.ts +++ b/actions-languageservice/src/value-providers/default.ts @@ -1,6 +1,7 @@ import {WorkflowContext} from "../context/workflow-context"; import {ValueProviderConfig, ValueProviderKind} from "./config"; import {needs} from "./needs"; +import {reusableJobInputs} from "./reusable-job-inputs"; import {stringsToValues} from "./strings-to-values"; export const defaultValueProviders: ValueProviderConfig = { @@ -8,6 +9,10 @@ export const defaultValueProviders: ValueProviderConfig = { kind: ValueProviderKind.AllowedValues, get: needs }, + "workflow-job-with": { + kind: ValueProviderKind.AllowedValues, + get: async context => reusableJobInputs(context) + }, "runs-on": { kind: ValueProviderKind.SuggestedValues, get: async (_: WorkflowContext) => diff --git a/actions-languageservice/src/value-providers/reusable-job-inputs.ts b/actions-languageservice/src/value-providers/reusable-job-inputs.ts new file mode 100644 index 0000000..391d5a8 --- /dev/null +++ b/actions-languageservice/src/value-providers/reusable-job-inputs.ts @@ -0,0 +1,40 @@ +import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token"; +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"; +import {stringsToValues} from "./strings-to-values"; + +export function reusableJobInputs(context: WorkflowContext): Value[] { + if (!context.reusableWorkflowJob?.["input-definitions"]) { + return []; + } + + const values: Value[] = []; + + for (const {key, value} of context.reusableWorkflowJob["input-definitions"]) { + if (!isString(key)) { + continue; + } + + values.push({ + label: key.value, + description: inputDescription(value) + }); + } + + return values; +} + +function inputDescription(inputDef: TemplateToken): string | undefined { + if (!isMapping(inputDef)) { + return undefined; + } + + const descriptionToken = inputDef.find("description"); + if (!descriptionToken || !isString(descriptionToken)) { + return undefined; + } + + return descriptionToken.value; +}