diff --git a/actions-languageservice/src/validate.ts b/actions-languageservice/src/validate.ts index d46c779..ca91193 100644 --- a/actions-languageservice/src/validate.ts +++ b/actions-languageservice/src/validate.ts @@ -14,6 +14,7 @@ import {BasicExpressionToken} from "@github/actions-workflow-parser/templates/to import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; import {File} from "@github/actions-workflow-parser/workflows/file"; +import {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider"; import {TextDocument} from "vscode-languageserver-textdocument"; import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types"; import {ActionInputs, ActionReference} from "./action"; @@ -35,6 +36,7 @@ export type ValidationConfig = { valueProviderConfig?: ValueProviderConfig; contextProviderConfig?: ContextProviderConfig; getActionInputs?(action: ActionReference): Promise; + fileProvider?: FileProvider; }; /** @@ -55,7 +57,15 @@ export async function validate(textDocument: TextDocument, config?: ValidationCo const result: ParseWorkflowResult = parseWorkflow(file, nullTrace); if (result.value) { // Errors will be updated in the context. Attempt to do the conversion anyway in order to give the user more information - const template = await convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion); + const template = await convertWorkflowTemplate( + result.context, + result.value, + ErrorPolicy.TryConversion, + config?.fileProvider, + { + fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0 + } + ); // Validate expressions and value providers await additionalValidations(diagnostics, textDocument.uri, template, result.value, config); diff --git a/actions-languageservice/src/validate.workflow-references.test.ts b/actions-languageservice/src/validate.workflow-references.test.ts new file mode 100644 index 0000000..e9f256a --- /dev/null +++ b/actions-languageservice/src/validate.workflow-references.test.ts @@ -0,0 +1,208 @@ +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 {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 = ` +on: push + +jobs: + build: + uses: monalisa/octocat/.github/workflows/non-reusable-workflow.yaml@main +`; + const result = await validate(createDocument("wf.yaml", input), { + fileProvider: testFileProvider + }); + + expect(result).toEqual([ + { + message: "workflow_call key is not defined in the referenced workflow.", + range: { + start: { + character: 10, + line: 5 + }, + end: { + character: 76, + line: 5 + } + } + } + ]); + }); + + it("reference to a non-reusable workflow", async () => { + const input = ` +on: push + +jobs: + build: + uses: monalisa/octocat/workflow.yaml@not-a-branch +`; + const result = await validate(createDocument("wf.yaml", input), { + fileProvider: testFileProvider + }); + + expect(result).toEqual([ + { + message: "Unable to find reusable workflow", + range: { + start: { + character: 10, + line: 5 + }, + end: { + character: 53, + line: 5 + } + } + } + ]); + }); + + it("valid reference to a reusable workflow", async () => { + const input = ` +on: push + +jobs: + build: + uses: monalisa/octocat/workflow.yaml@main +`; + const result = await validate(createDocument("wf.yaml", input), { + fileProvider: testFileProvider + }); + + expect(result).toEqual([]); + }); + + it("valid reference to a local workflow", async () => { + const input = ` +on: push + +jobs: + build: + uses: ./reusable-workflow.yaml +`; + const result = await validate(createDocument("wf.yaml", input), { + fileProvider: testFileProvider + }); + + expect(result).toEqual([]); + }); + + it("workflow reference without required inputs", async () => { + const input = ` +on: push + +jobs: + build: + uses: ./reusable-workflow-with-inputs.yaml +`; + const result = await validate(createDocument("wf.yaml", input), { + fileProvider: testFileProvider + }); + + expect(result).toEqual([ + { + message: "Input username is required, but not provided while calling.", + range: { + start: { + character: 10, + line: 5 + }, + end: { + character: 46, + line: 5 + } + } + } + ]); + }); + + it("workflow reference with required inputs", async () => { + const input = ` +on: push + +jobs: + build: + uses: ./reusable-workflow-with-inputs.yaml + with: + username: monalisa +`; + const result = await validate(createDocument("wf.yaml", input), { + fileProvider: testFileProvider + }); + + expect(result).toEqual([]); + }); +}); diff --git a/actions-workflow-parser/src/model/converter/referencedWorkflow.ts b/actions-workflow-parser/src/model/converter/referencedWorkflow.ts index b40f739..9de9b49 100644 --- a/actions-workflow-parser/src/model/converter/referencedWorkflow.ts +++ b/actions-workflow-parser/src/model/converter/referencedWorkflow.ts @@ -13,18 +13,21 @@ export function convertReferencedWorkflow( ) { const mapping = referencedWorkflow.assertMapping("root"); - let onToken: TemplateToken | undefined; + // The language service doesn't currently handles on other documents, + // So use the ref in the original workflow as the error location + const tokenForErrors = job.ref; + for (const pair of mapping) { const key = pair.key.assertString("root key"); switch (key.value) { case "on": { - handleTemplateTokenErrors(mapping, context, undefined, () => + handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertReferencedWorkflowOn(context, pair.value, job) ); break; } case "jobs": { - job.jobs = handleTemplateTokenErrors(mapping, context, [], () => convertJobs(context, pair.value)); + job.jobs = handleTemplateTokenErrors(tokenForErrors, context, [], () => convertJobs(context, pair.value)); break; } } @@ -32,11 +35,12 @@ export function convertReferencedWorkflow( } function convertReferencedWorkflowOn(context: TemplateContext, on: TemplateToken, job: ReusableWorkflowJob) { + const tokenForErrors = job.ref; switch (on.templateTokenType) { case TokenType.String: { const event = on.assertString("Reference workflow on value").value; if (event === "workflow_call") { - handleTemplateTokenErrors(on, context, undefined, () => convertWorkflowJobInputs(context, job)); + handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job)); return; } break; @@ -47,7 +51,7 @@ function convertReferencedWorkflowOn(context: TemplateContext, on: TemplateToken for (const eventToken of events) { const event = eventToken.assertString(`Reference workflow on value ${eventToken}`).value; if (event === "workflow_call") { - handleTemplateTokenErrors(on, context, undefined, () => convertWorkflowJobInputs(context, job)); + handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job)); return; } } @@ -64,7 +68,7 @@ function convertReferencedWorkflowOn(context: TemplateContext, on: TemplateToken } if (pair.value.templateTokenType === TokenType.Null) { - handleTemplateTokenErrors(on, context, undefined, () => convertWorkflowJobInputs(context, job)); + handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job)); return; } @@ -82,12 +86,12 @@ function convertReferencedWorkflowOn(context: TemplateContext, on: TemplateToken } } - handleTemplateTokenErrors(on, context, undefined, () => convertWorkflowJobInputs(context, job)); + handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job)); return; } break; } } - context.error(on, "workflow_call key is not defined in the referenced workflow."); + context.error(tokenForErrors, "workflow_call key is not defined in the referenced workflow."); }