Add a value provider for action inputs

This commit is contained in:
Josh Gross
2022-12-12 18:31:34 -05:00
parent b43b55fc03
commit d5ef6aefff
6 changed files with 260 additions and 5 deletions
@@ -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");
});
});
@@ -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}`;
}
@@ -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)
}
};
}
@@ -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<Value[]> {
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<Value[]> {
const metadata = await getActionMetadata(client, action);
if (!metadata) {
return [];
}
return parseActionMetadata(metadata);
}
async function getActionMetadata(client: Octokit, action: ActionReference): Promise<string | undefined> {
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<string, ActionInput>;
};
// 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<Value[]> {
const inputs = new Array<Value>();
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;
}