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..d65e76f --- /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 || !workflowContext.job.needs) { + 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();