diff --git a/actions-languageserver/package.json b/actions-languageserver/package.json index 729006e..368fe81 100644 --- a/actions-languageserver/package.json +++ b/actions-languageserver/package.json @@ -54,6 +54,7 @@ "devDependencies": { "@types/jest": "^29.0.3", "jest": "^29.0.3", + "nock": "^13.2.9", "prettier": "^2.7.1", "rimraf": "^3.0.2", "ts-jest": "^29.0.3", 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.test.ts b/actions-languageserver/src/context-providers/steps.test.ts new file mode 100644 index 0000000..9c883d9 --- /dev/null +++ b/actions-languageserver/src/context-providers/steps.test.ts @@ -0,0 +1,98 @@ +import {data} from "@github/actions-expressions"; +import {getStepsContext as getDefaultStepsContext} from "@github/actions-languageservice/context-providers/steps"; +import {Octokit} from "@octokit/rest"; +import nock from "nock"; +import {createWorkflowContext} from "../test-utils/workflow-context"; +import {TTLCache} from "../utils/cache"; +import {getStepsContext} from "./steps"; + +const workflow = ` +name: Caching Primes + +on: push + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Cache Primes + id: cache-primes + uses: actions/cache@v3 + with: + path: prime-numbers + key: \${{ runner.os }}-primes + + - name: Generate Prime Numbers + if: steps.cache-primes.outputs.cache-hit != 'true' + run: /generate-primes.sh -d prime-numbers + + - name: Use Prime Numbers + run: /primes.sh -d prime-numbers +`; + +// https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3 +const actionMetadata = { + name: "action.yml", + path: "action.yml", + sha: "3e158e3ee33f7e597ccf45c369a6e830dc8d43e3", + size: 946, + url: "https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", + html_url: "https://github.com/actions/cache/blob/v3/action.yml", + git_url: "https://api.github.com/repos/actions/cache/git/blobs/3e158e3ee33f7e597ccf45c369a6e830dc8d43e3", + download_url: "https://raw.githubusercontent.com/actions/cache/v3/action.yml", + type: "file", + content: + "bmFtZTogJ0NhY2hlJwpkZXNjcmlwdGlvbjogJ0NhY2hlIGFydGlmYWN0cyBs\naWtlIGRlcGVuZGVuY2llcyBhbmQgYnVpbGQgb3V0cHV0cyB0byBpbXByb3Zl\nIHdvcmtmbG93IGV4ZWN1dGlvbiB0aW1lJwphdXRob3I6ICdHaXRIdWInCmlu\ncHV0czoKICBwYXRoOgogICAgZGVzY3JpcHRpb246ICdBIGxpc3Qgb2YgZmls\nZXMsIGRpcmVjdG9yaWVzLCBhbmQgd2lsZGNhcmQgcGF0dGVybnMgdG8gY2Fj\naGUgYW5kIHJlc3RvcmUnCiAgICByZXF1aXJlZDogdHJ1ZQogIGtleToKICAg\nIGRlc2NyaXB0aW9uOiAnQW4gZXhwbGljaXQga2V5IGZvciByZXN0b3Jpbmcg\nYW5kIHNhdmluZyB0aGUgY2FjaGUnCiAgICByZXF1aXJlZDogdHJ1ZQogIHJl\nc3RvcmUta2V5czoKICAgIGRlc2NyaXB0aW9uOiAnQW4gb3JkZXJlZCBsaXN0\nIG9mIGtleXMgdG8gdXNlIGZvciByZXN0b3Jpbmcgc3RhbGUgY2FjaGUgaWYg\nbm8gY2FjaGUgaGl0IG9jY3VycmVkIGZvciBrZXkuIE5vdGUgYGNhY2hlLWhp\ndGAgcmV0dXJucyBmYWxzZSBpbiB0aGlzIGNhc2UuJwogICAgcmVxdWlyZWQ6\nIGZhbHNlCiAgdXBsb2FkLWNodW5rLXNpemU6CiAgICBkZXNjcmlwdGlvbjog\nJ1RoZSBjaHVuayBzaXplIHVzZWQgdG8gc3BsaXQgdXAgbGFyZ2UgZmlsZXMg\nZHVyaW5nIHVwbG9hZCwgaW4gYnl0ZXMnCiAgICByZXF1aXJlZDogZmFsc2UK\nb3V0cHV0czoKICBjYWNoZS1oaXQ6CiAgICBkZXNjcmlwdGlvbjogJ0EgYm9v\nbGVhbiB2YWx1ZSB0byBpbmRpY2F0ZSBhbiBleGFjdCBtYXRjaCB3YXMgZm91\nbmQgZm9yIHRoZSBwcmltYXJ5IGtleScKcnVuczoKICB1c2luZzogJ25vZGUx\nNicKICBtYWluOiAnZGlzdC9yZXN0b3JlL2luZGV4LmpzJwogIHBvc3Q6ICdk\naXN0L3NhdmUvaW5kZXguanMnCiAgcG9zdC1pZjogJ3N1Y2Nlc3MoKScKYnJh\nbmRpbmc6CiAgaWNvbjogJ2FyY2hpdmUnCiAgY29sb3I6ICdncmF5LWRhcmsn\nCg==\n", + encoding: "base64", + _links: { + self: "https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", + git: "https://api.github.com/repos/actions/cache/git/blobs/3e158e3ee33f7e597ccf45c369a6e830dc8d43e3", + html: "https://github.com/actions/cache/blob/v3/action.yml" + } +}; + +beforeEach(() => { + nock.disableNetConnect(); +}); + +afterEach(() => { + nock.cleanAll(); +}); + +it("returns default context when job is undefined", async () => { + const workflowContext = createWorkflowContext(workflow, undefined); + const defaultContext = getDefaultStepsContext(workflowContext); + + const stepsContext = await getStepsContext(new Octokit(), new TTLCache(), defaultContext, workflowContext); + expect(stepsContext).toEqual(defaultContext); +}); + +it("adds action outputs", async () => { + nock("https://api.github.com").get("/repos/actions/cache/contents/action.yml?ref=v3").reply(200, actionMetadata); + + const workflowContext = createWorkflowContext(workflow, "build"); + const defaultContext = getDefaultStepsContext(workflowContext); + + const stepsContext = await getStepsContext(new Octokit(), new TTLCache(), defaultContext, workflowContext); + expect(stepsContext).toBeDefined(); + + expect(stepsContext).toEqual( + new data.Dictionary({ + key: "cache-primes", + value: new data.Dictionary( + { + key: "outputs", + value: new data.Dictionary({ + key: "cache-hit", + value: new data.StringData("A boolean value to indicate an exact match was found for the primary key") + }) + }, + {key: "conclusion", value: new data.Null()}, + {key: "outcome", value: new data.Null()} + ) + }) + ); +}); 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/test-utils/workflow-context.ts b/actions-languageserver/src/test-utils/workflow-context.ts new file mode 100644 index 0000000..63841a6 --- /dev/null +++ b/actions-languageserver/src/test-utils/workflow-context.ts @@ -0,0 +1,27 @@ +import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context"; +import {convertWorkflowTemplate, parseWorkflow, TraceWriter} from "@github/actions-workflow-parser"; + +const nullTrace: TraceWriter = { + info: x => {}, + verbose: x => {}, + error: x => {} +}; + +export function createWorkflowContext(workflow: string, job?: string, stepIndex?: number): WorkflowContext { + const parsed = parseWorkflow("test.yaml", [{name: "test.yaml", content: workflow}], nullTrace); + if (!parsed.value) { + throw new Error("Failed to parse workflow"); + } + const template = convertWorkflowTemplate(parsed.context, parsed.value); + const context: WorkflowContext = {uri: "test.yaml", template}; + + if (job) { + context.job = template.jobs.find(j => j.id.value === job); + } + + if (stepIndex) { + context.step = context.job?.steps[stepIndex]; + } + + return context; +} 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-languageserver/tsconfig.build.json b/actions-languageserver/tsconfig.build.json index 88bdfa8..61c74a3 100644 --- a/actions-languageserver/tsconfig.build.json +++ b/actions-languageserver/tsconfig.build.json @@ -1,5 +1,5 @@ { - "exclude": ["./src/**/*.test.ts"], + "exclude": ["./src/**/*.test.ts", "./src/test-utils"], "extends": "./tsconfig.json", "compilerOptions": { "declaration": true, 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); } diff --git a/package-lock.json b/package-lock.json index 925f416..709dbac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "devDependencies": { "@types/jest": "^29.0.3", "jest": "^29.0.3", + "nock": "^13.2.9", "prettier": "^2.7.1", "rimraf": "^3.0.2", "ts-jest": "^29.0.3", @@ -8814,6 +8815,21 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node_modules/nock": { + "version": "13.2.9", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.2.9.tgz", + "integrity": "sha512-1+XfJNYF1cjGB+TKMWi29eZ0b82QOvQs2YoLNzbpWGqFMtRQHTa57osqdGj4FrFPgkO4D4AZinzUJR9VvW3QUA==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.21", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 10.13" + } + }, "node_modules/node-addon-api": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", @@ -10271,6 +10287,15 @@ "read": "1" } }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -13513,6 +13538,7 @@ "@octokit/rest": "^19.0.5", "@types/jest": "^29.0.3", "jest": "^29.0.3", + "nock": "*", "prettier": "^2.7.1", "rimraf": "^3.0.2", "ts-jest": "^29.0.3", @@ -19949,6 +19975,18 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "nock": { + "version": "13.2.9", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.2.9.tgz", + "integrity": "sha512-1+XfJNYF1cjGB+TKMWi29eZ0b82QOvQs2YoLNzbpWGqFMtRQHTa57osqdGj4FrFPgkO4D4AZinzUJR9VvW3QUA==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.21", + "propagate": "^2.0.0" + } + }, "node-addon-api": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", @@ -21020,6 +21058,12 @@ "read": "1" } }, + "propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true + }, "proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",