From 7a9d8738c566af4aac1c648971b2a65a392277c2 Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Tue, 20 Dec 2022 12:58:09 -0500 Subject: [PATCH] Add a context provider for action outputs --- .../src/context-providers.ts | 14 +++- .../src/context-providers/action-outputs.ts | 12 +++ .../src/context-providers/steps.ts | 75 +++++++++++++++++++ .../src/utils/action-metadata.ts | 68 +++++++++++++++++ .../src/value-providers/action-inputs.ts | 74 +----------------- actions-languageservice/src/action.ts | 8 ++ .../src/context-providers/config.ts | 7 +- .../src/context-providers/default.ts | 2 +- 8 files changed, 186 insertions(+), 74 deletions(-) create mode 100644 actions-languageserver/src/context-providers/action-outputs.ts create mode 100644 actions-languageserver/src/context-providers/steps.ts create mode 100644 actions-languageserver/src/utils/action-metadata.ts diff --git a/actions-languageserver/src/context-providers.ts b/actions-languageserver/src/context-providers.ts index 20301c7..edc8cfa 100644 --- a/actions-languageserver/src/context-providers.ts +++ b/actions-languageserver/src/context-providers.ts @@ -1,7 +1,9 @@ import {data} from "@github/actions-expressions"; import {ContextProviderConfig} from "@github/actions-languageservice/."; +import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context"; import {Octokit} from "@octokit/rest"; import {getSecrets} from "./context-providers/secrets"; +import {getStepsContext} from "./context-providers/steps"; import {RepositoryContext} from "./initializationOptions"; import {TTLCache} from "./utils/cache"; @@ -18,14 +20,22 @@ export function contextProviders( auth: sessionToken }); - const getContext = async (name: string, defaultContext: data.Dictionary | undefined) => { + const getContext = async ( + name: string, + defaultContext: data.Dictionary | undefined, + workflowContext: WorkflowContext + ) => { switch (name) { - case "secrets": + case "secrets": { const secrets = await getSecrets(octokit, cache, repo.owner, repo.name); defaultContext = defaultContext || new data.Dictionary(); secrets.forEach(secret => defaultContext!.add(secret.value, new data.StringData("***"))); return defaultContext; + } + case "steps": { + return await getStepsContext(octokit, cache, defaultContext, workflowContext); + } } }; diff --git a/actions-languageserver/src/context-providers/action-outputs.ts b/actions-languageserver/src/context-providers/action-outputs.ts new file mode 100644 index 0000000..de5e919 --- /dev/null +++ b/actions-languageserver/src/context-providers/action-outputs.ts @@ -0,0 +1,12 @@ +import {ActionReference, ActionOutputs} from "@github/actions-languageservice/action"; +import {Octokit} from "@octokit/rest"; +import {fetchActionMetadata} from "../utils/action-metadata"; +import {TTLCache} from "../utils/cache"; + +export async function getActionOutputs( + octokit: Octokit, + cache: TTLCache, + action: ActionReference +): Promise { + return (await fetchActionMetadata(octokit, cache, action))?.outputs; +} diff --git a/actions-languageserver/src/context-providers/steps.ts b/actions-languageserver/src/context-providers/steps.ts new file mode 100644 index 0000000..85cba47 --- /dev/null +++ b/actions-languageserver/src/context-providers/steps.ts @@ -0,0 +1,75 @@ +import {data} from "@github/actions-expressions"; +import {isDictionary} from "@github/actions-expressions/data/dictionary"; +import {parseActionReference} from "@github/actions-languageservice/action"; +import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context"; +import {isActionStep} from "@github/actions-workflow-parser/model/type-guards"; +import {Octokit} from "@octokit/rest"; +import {TTLCache} from "../utils/cache"; +import {getActionOutputs} from "./action-outputs"; + +export async function getStepsContext( + octokit: Octokit, + cache: TTLCache, + defaultContext: data.Dictionary | undefined, + workflowContext: WorkflowContext +): Promise { + if (!defaultContext || !workflowContext.job) { + return defaultContext; + } + + // The default context includes the set of valid + // step ids that can be used in expressions + const contextSteps = new Set(); + for (const {key} of defaultContext.pairs()) { + contextSteps.add(key); + } + + // Copy the default context for each step + // If the step is an action, add the action outputs to the context + const stepsContext = new data.Dictionary(); + for (const step of workflowContext.job.steps) { + if (!contextSteps.has(step.id)) { + continue; + } + + const defaultStepContext = defaultContext.get(step.id); + if (!defaultStepContext) { + stepsContext.add(step.id, new data.Null()); + continue; + } + + if (!isActionStep(step) || !isDictionary(defaultStepContext)) { + stepsContext.add(step.id, defaultStepContext); + continue; + } + + const action = parseActionReference(step.uses.value); + if (!action) { + stepsContext.add(step.id, defaultStepContext); + continue; + } + + const stepContext = new data.Dictionary(); + for (const {key, value} of defaultStepContext.pairs()) { + switch (key) { + case "outputs": + const outputs = await getActionOutputs(octokit, cache, action); + if (!outputs) { + stepContext.add(key, value); + continue; + } + const outputsDict = new data.Dictionary(); + for (const [key, value] of Object.entries(outputs)) { + outputsDict.add(key, new data.StringData(value.description)); + } + stepContext.add("outputs", outputsDict); + break; + default: + stepContext.add(key, value); + } + } + stepsContext.add(step.id, stepContext); + } + + return stepsContext; +} diff --git a/actions-languageserver/src/utils/action-metadata.ts b/actions-languageserver/src/utils/action-metadata.ts new file mode 100644 index 0000000..708911f --- /dev/null +++ b/actions-languageserver/src/utils/action-metadata.ts @@ -0,0 +1,68 @@ +import {ActionReference, ActionInputs, ActionOutputs, actionIdentifier} from "@github/actions-languageservice/action"; +import {Octokit, RestEndpointMethodTypes} from "@octokit/rest"; +import {parse} from "yaml"; +import {TTLCache} from "./cache"; + +export type ActionMetadata = { + inputs?: ActionInputs; + outputs?: ActionOutputs; +}; + +export async function fetchActionMetadata( + client: Octokit, + cache: TTLCache, + action: ActionReference +): Promise { + const metadata = await cache.get(`${actionIdentifier(action)}/action-metadata`, undefined, () => + getActionMetadata(client, action) + ); + if (!metadata) { + return undefined; + } + + return parseActionMetadata(metadata); +} + +async function getActionMetadata(client: Octokit, action: ActionReference): Promise { + let resp: RestEndpointMethodTypes["repos"]["getContent"]["response"]; + try { + resp = await client.repos.getContent({ + owner: action.owner, + repo: action.name, + ref: action.ref, + path: action.path ? `${action.path}/action.yml` : "action.yml" + }); + } catch (e: any) { + // If action.yml doesn't exist, try action.yaml + if (e.status === 404) { + resp = await client.repos.getContent({ + owner: action.owner, + repo: action.name, + ref: action.ref, + path: action.path ? `${action.path}/action.yaml` : "action.yaml" + }); + } else { + throw e; + } + } + + // https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28 + // Ignore directories (array of files) and non-file content + if (resp.data === undefined || Array.isArray(resp.data) || resp.data.type !== "file") { + return undefined; + } + + if (resp.data.content === undefined) { + return undefined; + } + + const text = Buffer.from(resp.data.content, "base64").toString("utf8"); + // Remove any null bytes + return text.replace(/\0/g, ""); +} + +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions +async function parseActionMetadata(content: string): Promise { + const metadata: ActionMetadata = parse(content); + return metadata; +} diff --git a/actions-languageserver/src/value-providers/action-inputs.ts b/actions-languageserver/src/value-providers/action-inputs.ts index 63d3e94..b9383f0 100644 --- a/actions-languageserver/src/value-providers/action-inputs.ts +++ b/actions-languageserver/src/value-providers/action-inputs.ts @@ -1,14 +1,9 @@ -import { - actionIdentifier, - ActionInputs, - ActionReference, - parseActionReference -} from "@github/actions-languageservice/action"; +import {ActionInputs, ActionReference, parseActionReference} from "@github/actions-languageservice/action"; import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context"; import {Value} from "@github/actions-languageservice/value-providers/config"; import {isActionStep} from "@github/actions-workflow-parser/model/type-guards"; -import {Octokit, RestEndpointMethodTypes} from "@octokit/rest"; -import {parse} from "yaml"; +import {Octokit} from "@octokit/rest"; +import {fetchActionMetadata} from "../utils/action-metadata"; import {TTLCache} from "../utils/cache"; export async function getActionInputs( @@ -16,11 +11,7 @@ export async function getActionInputs( cache: TTLCache, action: ActionReference ): Promise { - const inputs = await cache.get(`${actionIdentifier(action)}/action-inputs`, undefined, () => - fetchActionInputs(client, action) - ); - - return inputs; + return (await fetchActionMetadata(client, cache, action))?.inputs; } export async function getActionInputValues( @@ -49,60 +40,3 @@ export async function getActionInputValues( }; }); } - -async function fetchActionInputs(client: Octokit, action: ActionReference): Promise { - const metadata = await getActionMetadata(client, action); - if (!metadata) { - return undefined; - } - - return parseActionMetadata(metadata); -} - -async function getActionMetadata(client: Octokit, action: ActionReference): Promise { - let resp: RestEndpointMethodTypes["repos"]["getContent"]["response"]; - try { - resp = await client.repos.getContent({ - owner: action.owner, - repo: action.name, - ref: action.ref, - path: action.path ? `${action.path}/action.yml` : "action.yml" - }); - } catch (e: any) { - // If action.yml doesn't exist, try action.yaml - if (e.status === 404) { - resp = await client.repos.getContent({ - owner: action.owner, - repo: action.name, - ref: action.ref, - path: action.path ? `${action.path}/action.yaml` : "action.yaml" - }); - } else { - throw e; - } - } - - // https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28 - // Ignore directories (array of files) and non-file content - if (resp.data === undefined || Array.isArray(resp.data) || resp.data.type !== "file") { - return undefined; - } - - if (resp.data.content === undefined) { - return undefined; - } - - const text = Buffer.from(resp.data.content, "base64").toString("utf8"); - // Remove any null bytes - return text.replace(/\0/g, ""); -} - -type ActionMetadata = { - inputs?: ActionInputs; -}; - -// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions -async function parseActionMetadata(content: string): Promise { - const metadata: ActionMetadata = parse(content); - return metadata.inputs ?? {}; -} diff --git a/actions-languageservice/src/action.ts b/actions-languageservice/src/action.ts index 2f92b5d..4c35918 100644 --- a/actions-languageservice/src/action.ts +++ b/actions-languageservice/src/action.ts @@ -8,6 +8,14 @@ export type ActionInput = { export type ActionInputs = Record; +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#outputs-for-docker-container-and-javascript-actions +export type ActionOutput = { + description: string; + value?: string; +}; + +export type ActionOutputs = Record; + export type ActionReference = { owner: string; name: string; diff --git a/actions-languageservice/src/context-providers/config.ts b/actions-languageservice/src/context-providers/config.ts index 6b3b49f..497b94b 100644 --- a/actions-languageservice/src/context-providers/config.ts +++ b/actions-languageservice/src/context-providers/config.ts @@ -1,9 +1,14 @@ import {data} from "@github/actions-expressions"; import {Dictionary} from "@github/actions-expressions/data/dictionary"; import {ExpressionData, Pair} from "@github/actions-expressions/data/expressiondata"; +import {WorkflowContext} from "../context/workflow-context"; export type ContextProviderConfig = { - getContext: (name: string, defaultContext: data.Dictionary | undefined) => Promise; + getContext: ( + name: string, + defaultContext: data.Dictionary | undefined, + workflowContext: WorkflowContext + ) => Promise; }; /** diff --git a/actions-languageservice/src/context-providers/default.ts b/actions-languageservice/src/context-providers/default.ts index 4e8e62d..6762985 100644 --- a/actions-languageservice/src/context-providers/default.ts +++ b/actions-languageservice/src/context-providers/default.ts @@ -36,7 +36,7 @@ export async function getContext( continue; } - value = (await config?.getContext(contextName, value)) || value; + value = (await config?.getContext(contextName, value, workflowContext)) || value; context.add(contextName, value); }