From 643917ae9d1e9d811aa0884d8767cf58e55196dc Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Wed, 30 Nov 2022 11:21:32 -0500 Subject: [PATCH 1/2] Add a `needs` context provider for auto-completion --- .../src/complete.expressions.test.ts | 64 +++++++++++++++++++ actions-languageservice/src/complete.ts | 4 +- .../src/context-providers/default.ts | 14 +++- .../src/context-providers/needs.ts | 27 ++++++++ .../src/validate.expressions.test.ts | 19 ++++++ actions-languageservice/src/validate.ts | 12 +++- 6 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 actions-languageservice/src/context-providers/needs.ts diff --git a/actions-languageservice/src/complete.expressions.test.ts b/actions-languageservice/src/complete.expressions.test.ts index 1f5cfc4..4c5b32d 100644 --- a/actions-languageservice/src/complete.expressions.test.ts +++ b/actions-languageservice/src/complete.expressions.test.ts @@ -148,4 +148,68 @@ jobs: expect(result.map(x => x.label)).toEqual(["DEPLOY_KEY"]); }); + + it("needs context only includes referenced jobs", async () => { + const input = ` +on: push +jobs: + a: + runs-on: ubuntu-latest + steps: + - run: echo hello a + b: + needs: [a] + runs-on: ubuntu-latest + steps: + - run: echo hello b + c: + needs: [b] + runs-on: ubuntu-latest + steps: + - run: echo "hello \${{ needs.| +`; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual(["b"]); + }); + + it("needs.", async () => { + const input = ` +on: push +jobs: + a: + runs-on: ubuntu-latest + steps: + - run: echo hello a + b: + needs: [a] + runs-on: ubuntu-latest + steps: + - run: echo "hello \${{ needs.a.| +`; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual(["outputs", "result"]); + }); + + it.failing("needs..outputs includes outputs", async () => { + const input = ` +on: push +jobs: + a: + outputs: + build_id: my-build-id + runs-on: ubuntu-latest + steps: + - run: echo hello a + b: + needs: [a] + runs-on: ubuntu-latest + steps: + - run: echo "hello \${{ needs.a.outputs.| +`; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual(["build_id"]); + }); }); diff --git a/actions-languageservice/src/complete.ts b/actions-languageservice/src/complete.ts index 76e308c..1ba93ad 100644 --- a/actions-languageservice/src/complete.ts +++ b/actions-languageservice/src/complete.ts @@ -65,6 +65,7 @@ export async function complete( const {token, keyToken, parent, path} = findToken(newPos, result.value); const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion); + const workflowContext = getWorkflowContext(textDocument.uri, template, path); // If we are inside an expression, take a different code-path. The workflow parser does not correctly create // expression nodes for invalid expressions and during editing expressions are invalid most of the time. @@ -82,13 +83,12 @@ export async function complete( const expressionInput = (getExpressionInput(currentInput, relCharPos) || "").trim(); const allowedContext = getAllowedContext(token, parent!); - const context = await getContext(allowedContext, contextProviderConfig); + const context = await getContext(allowedContext, contextProviderConfig, workflowContext); return completeExpression(expressionInput, context, []); } } - const workflowContext = getWorkflowContext(textDocument.uri, template, path); const values = await getValues(token, parent, valueProviderConfig, workflowContext); return values.map(value => CompletionItem.create(value.label)); } diff --git a/actions-languageservice/src/context-providers/default.ts b/actions-languageservice/src/context-providers/default.ts index 49c9bea..d47c876 100644 --- a/actions-languageservice/src/context-providers/default.ts +++ b/actions-languageservice/src/context-providers/default.ts @@ -1,13 +1,19 @@ import {data} from "@github/actions-expressions"; +import {WorkflowContext} from "../context/workflow-context"; import {ContextProviderConfig} from "./config"; +import {getNeedsContext} from "./needs"; -export async function getContext(names: string[], config: ContextProviderConfig | undefined): Promise { +export async function getContext( + names: string[], + config: ContextProviderConfig | undefined, + workflowContext: WorkflowContext +): Promise { const context = new data.Dictionary(); for (const contextName of names) { let value: data.Dictionary | undefined; - value = getDefaultContext(contextName); + value = getDefaultContext(contextName, workflowContext); if (!value) { value = await config?.getContext(contextName); @@ -23,7 +29,7 @@ export async function getContext(names: string[], config: ContextProviderConfig return context; } -function getDefaultContext(name: string): data.Dictionary | undefined { +function getDefaultContext(name: string, workflowContext: WorkflowContext): data.Dictionary | undefined { switch (name) { case "runner": return objectToDictionary({ @@ -33,6 +39,8 @@ function getDefaultContext(name: string): data.Dictionary | undefined { tool_cache: "/opt/hostedtoolcache", temp: "/home/runner/work/_temp" }); + case "needs": + return getNeedsContext(workflowContext); } return undefined; diff --git a/actions-languageservice/src/context-providers/needs.ts b/actions-languageservice/src/context-providers/needs.ts new file mode 100644 index 0000000..8ecf240 --- /dev/null +++ b/actions-languageservice/src/context-providers/needs.ts @@ -0,0 +1,27 @@ +import {data} from "@github/actions-expressions/."; +import {WorkflowContext} from "../context/workflow-context"; + +export function getNeedsContext(workflowContext: WorkflowContext): data.Dictionary { + const d = new data.Dictionary(); + if (!workflowContext.job) { + return d; + } + + for (const job of workflowContext.job.needs) { + d.add(job, defaultNeedsValues()); + } + + return d; +} + +function defaultNeedsValues(): data.Dictionary { + // https://docs.github.com/en/actions/learn-github-actions/contexts#needs-context + const d = new data.Dictionary(); + + // Once job config contains outputs, this can be populated + d.add("outputs", new data.Null()); + + // Can be "success", "failure", "cancelled", or "skipped" + d.add("result", new data.Null()); + return d; +} diff --git a/actions-languageservice/src/validate.expressions.test.ts b/actions-languageservice/src/validate.expressions.test.ts index 1312614..02afca2 100644 --- a/actions-languageservice/src/validate.expressions.test.ts +++ b/actions-languageservice/src/validate.expressions.test.ts @@ -54,4 +54,23 @@ describe("expression validation", () => { } ]); }); + + it.failing("needs.", async () => { + const input = ` +on: push +jobs: + a: + runs-on: ubuntu-latest + steps: + - run: echo hello a + b: + needs: [a] + runs-on: ubuntu-latest + steps: + - run: echo "hello \${{ needs.a }}" +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([]); + }); }); diff --git a/actions-languageservice/src/validate.ts b/actions-languageservice/src/validate.ts index 7fb802c..2da881d 100644 --- a/actions-languageservice/src/validate.ts +++ b/actions-languageservice/src/validate.ts @@ -116,7 +116,12 @@ async function additionalValidations( for (const token of TemplateToken.traverse(root)) { // If this is an expression, validate it if (isBasicExpression(token)) { - await validateExpression(diagnostics, token, contextProviderConfig); + await validateExpression( + diagnostics, + token, + contextProviderConfig, + getProviderContext(documentUri, template, root, token) + ); } // Allowed values coming from the schema have already been validated. Only check if @@ -196,7 +201,8 @@ function getProviderContext( async function validateExpression( diagnostics: Diagnostic[], token: BasicExpressionToken, - contextProviderConfig: ContextProviderConfig | undefined + contextProviderConfig: ContextProviderConfig | undefined, + workflowContext: WorkflowContext ) { // Validate the expression for (const expression of token.originalExpressions || [token]) { @@ -217,7 +223,7 @@ async function validateExpression( } try { - const context = await getContext(namedContexts, contextProviderConfig); + const context = await getContext(namedContexts, contextProviderConfig, workflowContext); const e = new Evaluator(expr, wrapDictionary(context)); e.evaluate(); From 71bde2b801ed56fbde146c4229054fc970edddde Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Wed, 30 Nov 2022 11:31:55 -0500 Subject: [PATCH 2/2] Validate that `job.needs` exists --- actions-languageservice/src/context-providers/needs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions-languageservice/src/context-providers/needs.ts b/actions-languageservice/src/context-providers/needs.ts index 8ecf240..d65e76f 100644 --- a/actions-languageservice/src/context-providers/needs.ts +++ b/actions-languageservice/src/context-providers/needs.ts @@ -3,7 +3,7 @@ import {WorkflowContext} from "../context/workflow-context"; export function getNeedsContext(workflowContext: WorkflowContext): data.Dictionary { const d = new data.Dictionary(); - if (!workflowContext.job) { + if (!workflowContext.job || !workflowContext.job.needs) { return d; }