From d5ef6aefffd3e0d6e13f88c342e60641b0309347 Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Mon, 12 Dec 2022 18:31:34 -0500 Subject: [PATCH] Add a value provider for action inputs --- actions-languageserver/package.json | 6 +- .../src/utils/action-reference.test.ts | 101 +++++++++++++++++ .../src/utils/action-reference.ts | 41 +++++++ actions-languageserver/src/value-providers.ts | 5 + .../src/value-providers/action-inputs.ts | 102 ++++++++++++++++++ package-lock.json | 10 +- 6 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 actions-languageserver/src/utils/action-reference.test.ts create mode 100644 actions-languageserver/src/utils/action-reference.ts create mode 100644 actions-languageserver/src/value-providers/action-inputs.ts diff --git a/actions-languageserver/package.json b/actions-languageserver/package.json index 93b14b3..f9635a8 100644 --- a/actions-languageserver/package.json +++ b/actions-languageserver/package.json @@ -39,9 +39,11 @@ }, "dependencies": { "@github/actions-languageservice": "^0.1.49", + "@github/actions-workflow-parser": "^0.0.33", "@octokit/rest": "^19.0.5", "vscode-languageserver": "^8.0.2", - "vscode-languageserver-textdocument": "^1.0.7" + "vscode-languageserver-textdocument": "^1.0.7", + "yaml": "^2.1.3" }, "engines": { "node": ">= 16" @@ -57,4 +59,4 @@ "ts-jest": "^29.0.3", "typescript": "^4.8.4" } -} +} \ No newline at end of file diff --git a/actions-languageserver/src/utils/action-reference.test.ts b/actions-languageserver/src/utils/action-reference.test.ts new file mode 100644 index 0000000..eff9ef7 --- /dev/null +++ b/actions-languageserver/src/utils/action-reference.test.ts @@ -0,0 +1,101 @@ +import {actionIdentifier, parseActionReference as parse} from "./action-reference"; + +describe("parseActionReference", () => { + it("basic action", () => { + expect(parse("actions/checkout@v2")).toEqual({ + owner: "actions", + name: "checkout", + ref: "v2" + }); + }); + + it("action with reference to branch", () => { + expect(parse("actions/checkout@main")).toEqual({ + owner: "actions", + name: "checkout", + ref: "main" + }); + }); + + it("action with reference to branch with slashes", () => { + expect(parse("actions/checkout@features/a")).toEqual({ + owner: "actions", + name: "checkout", + ref: "features/a" + }); + }); + + it("action with reference to commit sha", () => { + expect(parse("actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b")).toEqual({ + owner: "actions", + name: "checkout", + ref: "755da8c3cf115ac066823e79a1e1788f8940201b" + }); + }); + + it("valid action with path", () => { + expect(parse("actions/checkout/path/to/action@v2")).toEqual({ + owner: "actions", + name: "checkout", + ref: "v2", + path: "path/to/action" + }); + }); + + it("valid action with path and trailing slash", () => { + expect(parse("actions/checkout/path/to/action/@v2")).toEqual({ + owner: "actions", + name: "checkout", + ref: "v2", + path: "path/to/action" + }); + }); + + it("local action", () => { + expect(parse("./")).toBeUndefined(); + }); + + it("local action with path", () => { + expect(parse("./directory/")).toBeUndefined(); + }); + + it("Docker Hub action", () => { + expect(parse("docker://alpine:3.8")).toBeUndefined(); + }); + + it("GitHub Packages Container action", () => { + expect(parse("docker://ghcr.io/OWNER/IMAGE_NAME")).toBeUndefined(); + }); + + it("action with backslashes", () => { + expect(parse("actions\\checkout\\path\\to\\action@v2")).toEqual({ + owner: "actions", + name: "checkout", + ref: "v2", + path: "path/to/action" + }); + }); +}); + +describe("actionIdentifier", () => { + it("basic action", () => { + expect( + actionIdentifier({ + owner: "actions", + name: "checkout", + ref: "v2" + }) + ).toEqual("actions/checkout/v2"); + }); + + it("action with path", () => { + expect( + actionIdentifier({ + owner: "actions", + name: "checkout", + ref: "v2", + path: "path/to/action" + }) + ).toEqual("actions/checkout/v2/path/to/action"); + }); +}); diff --git a/actions-languageserver/src/utils/action-reference.ts b/actions-languageserver/src/utils/action-reference.ts new file mode 100644 index 0000000..408984b --- /dev/null +++ b/actions-languageserver/src/utils/action-reference.ts @@ -0,0 +1,41 @@ +export type ActionReference = { + owner: string; + name: string; + ref: string; + path?: string; +}; + +// https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsuses +export function parseActionReference(uses: string): ActionReference | undefined { + if (!uses || uses.startsWith("docker://") || uses.startsWith("./") || uses.startsWith(".\\")) { + return undefined; + } + + const [action, ref] = uses.split("@"); + const [owner, name, ...pathSegments] = action.split(/[\\/]/).filter(s => s.length > 0); + if (!owner || !name) { + return undefined; + } + + if (pathSegments.length === 0) { + return { + owner, + name, + ref + }; + } + + return { + owner, + name, + ref, + path: pathSegments.join("/") + }; +} + +export function actionIdentifier(ref: ActionReference): string { + if (ref.path) { + return `${ref.owner}/${ref.name}/${ref.ref}/${ref.path}`; + } + return `${ref.owner}/${ref.name}/${ref.ref}`; +} diff --git a/actions-languageserver/src/value-providers.ts b/actions-languageserver/src/value-providers.ts index ff706a5..3ac95d5 100644 --- a/actions-languageserver/src/value-providers.ts +++ b/actions-languageserver/src/value-providers.ts @@ -4,6 +4,7 @@ import {ValueProviderKind} from "@github/actions-languageservice/value-providers import {Octokit} from "@octokit/rest"; import {RepositoryContext} from "./initializationOptions"; import {TTLCache} from "./utils/cache"; +import {getActionInputs} from "./value-providers/action-inputs"; import {getEnvironments} from "./value-providers/job-environment"; import {getRunnerLabels} from "./value-providers/runs-on"; @@ -32,6 +33,10 @@ export function valueProviders( "runs-on": { kind: ValueProviderKind.SuggestedValues, get: (_: WorkflowContext) => getRunnerLabels(octokit, cache, repo.owner, repo.name) + }, + "step-with": { + kind: ValueProviderKind.AllowedValues, + get: (context: WorkflowContext) => getActionInputs(octokit, cache, context) } }; } diff --git a/actions-languageserver/src/value-providers/action-inputs.ts b/actions-languageserver/src/value-providers/action-inputs.ts new file mode 100644 index 0000000..4d0af91 --- /dev/null +++ b/actions-languageserver/src/value-providers/action-inputs.ts @@ -0,0 +1,102 @@ +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 {actionIdentifier, ActionReference, parseActionReference} from "../utils/action-reference"; +import {TTLCache} from "../utils/cache"; + +export async function getActionInputs(client: Octokit, cache: TTLCache, context: WorkflowContext): Promise { + if (!context.step || !isActionStep(context.step)) { + return []; + } + + const action = parseActionReference(context.step.uses.value); + if (!action) { + return []; + } + + const inputs = await cache.get(`${actionIdentifier(action)}/action-inputs`, undefined, () => + fetchActionInputs(client, action) + ); + + return inputs; +} + +async function fetchActionInputs(client: Octokit, action: ActionReference): Promise { + const metadata = await getActionMetadata(client, action); + if (!metadata) { + return []; + } + + 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?: Record; +}; + +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#inputs +type ActionInput = { + description: string; + required?: boolean; + default?: string; + deprecationMessage?: string; +}; + +// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions +async function parseActionMetadata(content: string): Promise { + const inputs = new Array(); + + const metadata: ActionMetadata = parse(content); + if (metadata.inputs === undefined) { + return inputs; + } + + for (const [name, input] of Object.entries(metadata.inputs)) { + inputs.push({ + label: name, + description: input.description + }); + } + + return inputs; +} diff --git a/package-lock.json b/package-lock.json index 09e052b..42b3ff8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,9 +20,11 @@ "license": "MIT", "dependencies": { "@github/actions-languageservice": "^0.1.49", + "@github/actions-workflow-parser": "^0.0.33", "@octokit/rest": "^19.0.5", "vscode-languageserver": "^8.0.2", - "vscode-languageserver-textdocument": "^1.0.7" + "vscode-languageserver-textdocument": "^1.0.7", + "yaml": "^2.1.3" }, "devDependencies": { "@types/jest": "^29.0.3", @@ -13506,6 +13508,7 @@ "version": "file:actions-languageserver", "requires": { "@github/actions-languageservice": "^0.1.49", + "@github/actions-workflow-parser": "0.0.33", "@octokit/rest": "^19.0.5", "@types/jest": "^29.0.3", "jest": "^29.0.3", @@ -13514,7 +13517,8 @@ "ts-jest": "^29.0.3", "typescript": "^4.8.4", "vscode-languageserver": "^8.0.2", - "vscode-languageserver-textdocument": "^1.0.7" + "vscode-languageserver-textdocument": "^1.0.7", + "yaml": "^2.1.3" } }, "@github/actions-languageservice": { @@ -23078,4 +23082,4 @@ "dev": true } } -} +} \ No newline at end of file