diff --git a/actions-languageserver/src/context-providers.ts b/actions-languageserver/src/context-providers.ts new file mode 100644 index 0000000..d36eb0c --- /dev/null +++ b/actions-languageserver/src/context-providers.ts @@ -0,0 +1,32 @@ +import {data} from "@github/actions-expressions"; +import {ContextProviderConfig} from "@github/actions-languageservice/."; +import {Octokit} from "@octokit/rest"; +import {getSecrets} from "./context-providers/secrets"; +import {RepositoryContext} from "./initializationOptions"; +import {TTLCache} from "./utils/cache"; + +export function contextProviders( + sessionToken: string | undefined, + repo: RepositoryContext | undefined, + cache: TTLCache +): ContextProviderConfig { + if (!repo || !sessionToken) { + return {getContext: (_: string) => Promise.resolve(undefined)}; + } + + const octokit = new Octokit({ + auth: sessionToken + }); + + const getContext = async (name: string) => { + switch (name) { + case "secrets": + const secrets = await getSecrets(octokit, cache, repo.owner, repo.name); + const secretContext = new data.Dictionary(); + secrets.forEach(secret => secretContext.add(secret.value, secret)); + return secretContext; + } + }; + + return {getContext}; +} diff --git a/actions-languageserver/src/context-providers/secrets.ts b/actions-languageserver/src/context-providers/secrets.ts new file mode 100644 index 0000000..a1755dd --- /dev/null +++ b/actions-languageserver/src/context-providers/secrets.ts @@ -0,0 +1,22 @@ +import {StringData} from "@github/actions-expressions/data/string"; +import {Octokit} from "@octokit/rest"; +import {TTLCache} from "../utils/cache"; + +export function getSecrets(octokit: Octokit, cache: TTLCache, owner: string, name: string): Promise { + return cache.get(`${owner}/${name}/secrets`, undefined, () => fetchSecrets(octokit, owner, name)); +} + +async function fetchSecrets(octokit: Octokit, owner: string, name: string): Promise { + try { + const response = await octokit.actions.listRepoSecrets({ + owner, + repo: name + }); + + return response.data.secrets.map(secret => new StringData(secret.name)); + } catch (e) { + console.log("Failure to retrieve secrets: ", e); + } + + return []; +} diff --git a/actions-languageserver/src/on-completion.ts b/actions-languageserver/src/on-completion.ts index 28f8fb7..cf93539 100644 --- a/actions-languageserver/src/on-completion.ts +++ b/actions-languageserver/src/on-completion.ts @@ -1,6 +1,7 @@ import { complete } from "@github/actions-languageservice/complete"; import { CompletionItem, Position } from "vscode-languageserver"; import { TextDocument } from "vscode-languageserver-textdocument"; +import { contextProviders } from "./context-providers"; import { RepositoryContext } from "./initializationOptions"; import { TTLCache } from "./utils/cache"; import { valueProviders } from "./value-providers"; @@ -15,6 +16,7 @@ export async function onCompletion( return await complete( document, position, - repoContext && valueProviders(sessionToken, repoContext, cache) + repoContext && valueProviders(sessionToken, repoContext, cache), + repoContext && contextProviders(sessionToken, repoContext, cache) ); } diff --git a/actions-languageservice/src/complete.expressions.test.ts b/actions-languageservice/src/complete.expressions.test.ts index 1bc0114..1f5cfc4 100644 --- a/actions-languageservice/src/complete.expressions.test.ts +++ b/actions-languageservice/src/complete.expressions.test.ts @@ -4,13 +4,18 @@ import {ContextProviderConfig} from "./context-providers/config"; import {getPositionFromCursor} from "./test-utils/cursor-position"; const contextProviderConfig: ContextProviderConfig = { - getContext: (context: string) => { + getContext: async (context: string) => { switch (context) { case "github": return new data.Dictionary({ key: "event", value: new data.StringData("push") }); + case "secrets": + return new data.Dictionary({ + key: "DEPLOY_KEY", + value: new data.StringData("DEPLOY_KEY") + }); } return undefined; @@ -125,4 +130,22 @@ jobs: expect(result.map(x => x.label)).toEqual(["event"]); }); }); + + it("context inherited from parent", async () => { + // The token definition is just a `string` and + // the parent `step-with` holds the allowed context + const input = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/deploy@v100 + with: + deploy-key: \${{ secrets.| +`; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual(["DEPLOY_KEY"]); + }); }); diff --git a/actions-languageservice/src/complete.ts b/actions-languageservice/src/complete.ts index 7471262..76e308c 100644 --- a/actions-languageservice/src/complete.ts +++ b/actions-languageservice/src/complete.ts @@ -12,6 +12,7 @@ import {ContextProviderConfig} from "./context-providers/config"; import {getContext} from "./context-providers/default"; import {getWorkflowContext, WorkflowContext} from "./context/workflow-context"; import {nullTrace} from "./nulltrace"; +import {getAllowedContext} from "./utils/allowed-context"; import {findToken} from "./utils/find-token"; import {transform} from "./utils/transform"; import {Value, ValueProviderConfig} from "./value-providers/config"; @@ -80,7 +81,8 @@ export async function complete( const expressionInput = (getExpressionInput(currentInput, relCharPos) || "").trim(); - const context = getContext(token.definition?.readerContext || [], contextProviderConfig); + const allowedContext = getAllowedContext(token, parent!); + const context = await getContext(allowedContext, contextProviderConfig); return completeExpression(expressionInput, context, []); } diff --git a/actions-languageservice/src/context-providers/config.ts b/actions-languageservice/src/context-providers/config.ts index e77057a..08c441f 100644 --- a/actions-languageservice/src/context-providers/config.ts +++ b/actions-languageservice/src/context-providers/config.ts @@ -3,7 +3,7 @@ import {Dictionary} from "@github/actions-expressions/data/dictionary"; import {ExpressionData, Pair} from "@github/actions-expressions/data/expressiondata"; export type ContextProviderConfig = { - getContext: (name: string) => data.Dictionary | undefined; + getContext: (name: string) => Promise; }; /** diff --git a/actions-languageservice/src/context-providers/default.ts b/actions-languageservice/src/context-providers/default.ts index aa10e1a..49c9bea 100644 --- a/actions-languageservice/src/context-providers/default.ts +++ b/actions-languageservice/src/context-providers/default.ts @@ -1,7 +1,7 @@ import {data} from "@github/actions-expressions"; import {ContextProviderConfig} from "./config"; -export function getContext(names: string[], config: ContextProviderConfig | undefined): data.Dictionary { +export async function getContext(names: string[], config: ContextProviderConfig | undefined): Promise { const context = new data.Dictionary(); for (const contextName of names) { @@ -10,7 +10,7 @@ export function getContext(names: string[], config: ContextProviderConfig | unde value = getDefaultContext(contextName); if (!value) { - value = config?.getContext(contextName); + value = await config?.getContext(contextName); } if (!value) { diff --git a/actions-languageservice/src/context-providers/runner.ts b/actions-languageservice/src/context-providers/runner.ts deleted file mode 100644 index e69de29..0000000 diff --git a/actions-languageservice/src/utils/allowed-context.ts b/actions-languageservice/src/utils/allowed-context.ts new file mode 100644 index 0000000..0eb8704 --- /dev/null +++ b/actions-languageservice/src/utils/allowed-context.ts @@ -0,0 +1,15 @@ +import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; + +export function getAllowedContext(token: TemplateToken, parent: TemplateToken): string[] { + // Workaround for https://github.com/github/c2c-actions-experience/issues/6876 + // Context is inherited from the parent + const allowedContext = new Set(); + for (const t of [token, parent]) { + if (t.definition?.readerContext) { + for (const context of t.definition.readerContext) { + allowedContext.add(context); + } + } + } + return Array.from(allowedContext); +} diff --git a/actions-languageservice/src/validate.ts b/actions-languageservice/src/validate.ts index 88b8bac..7fb802c 100644 --- a/actions-languageservice/src/validate.ts +++ b/actions-languageservice/src/validate.ts @@ -116,7 +116,7 @@ async function additionalValidations( for (const token of TemplateToken.traverse(root)) { // If this is an expression, validate it if (isBasicExpression(token)) { - validateExpression(diagnostics, token, contextProviderConfig); + await validateExpression(diagnostics, token, contextProviderConfig); } // Allowed values coming from the schema have already been validated. Only check if @@ -193,7 +193,7 @@ function getProviderContext( return getWorkflowContext(documentUri, template, path); } -function validateExpression( +async function validateExpression( diagnostics: Diagnostic[], token: BasicExpressionToken, contextProviderConfig: ContextProviderConfig | undefined @@ -217,7 +217,7 @@ function validateExpression( } try { - const context = getContext(namedContexts, contextProviderConfig); + const context = await getContext(namedContexts, contextProviderConfig); const e = new Evaluator(expr, wrapDictionary(context)); e.evaluate();