diff --git a/src/utils/actions/action-input.ts b/src/utils/actions/action-input.ts new file mode 100644 index 0000000..82f1b03 --- /dev/null +++ b/src/utils/actions/action-input.ts @@ -0,0 +1,367 @@ +import { runSafely } from "@/utils/async-utils"; +import { $i, asArray } from "@/utils/collections"; +import { Converter, toType } from "@/utils/convert"; +import { getAllEnvironmentVariables, getEnvironmentVariable, setEnvironmentVariable } from "@/utils/environment"; +import { QueryString } from "@/utils/net"; +import { ModuleLoader, NODE_MODULE_LOADER, defineNestedProperty, executeImportDirective } from "@/utils/reflection"; +import { split, stringEquals } from "@/utils/string-utils"; +import { ActionInputDescriptor, getActionInputDescriptors } from "./action-input-descriptor"; +import { ActionMetadata } from "./action-metadata"; +import { ActionParameter, normalizeActionParameterName } from "./action-parameter"; +import { ActionParameterDescriptorExtractionOptions } from "./action-parameter-descriptor"; +import { ActionParameterFactoryOptions } from "./action-parameter-factory-options"; +import { ActionParameterTypeDescriptor } from "./action-parameter-type-descriptor"; + +/** + * Input parameters allow you to specify data that the action expects to use during runtime. + * + * GitHub stores input parameters as environment variables. + * Input ids with uppercase letters are converted to lowercase during runtime. + */ +export interface ActionInput extends ActionParameter { + /** + * A boolean indicating whether the input parameter is required. + * If `true`, the action will fail if the parameter is not provided. + */ + required?: boolean; + + /** + * The default value to use when the input parameter is not specified in the workflow file. + * This value is used if `required` is `false`. + */ + default?: string | number | boolean; + + /** + * A message to display if the input parameter is used, indicating that it is deprecated and suggesting alternatives. + */ + deprecationMessage?: string; +} + +/** + * Configures the way input retrieving works. + */ +export interface InputOptions { + /** + * Indicates whether the input is required. + * If required and not present, will throw. + * + * Defaults to `false`. + */ + required?: boolean; + + /** + * Indicates whether leading/trailing whitespace will be trimmed for the input. + * + * Defaults to `true`. + */ + trimWhitespace?: boolean; +} + +/** + * Configures the way input object retrieving works. + */ +export interface InputObjectOptions extends InputOptions { + /** + * The module loader to use for loading modules. + */ + moduleLoader?: ModuleLoader; + + /** + * The converter to use for converting values. + */ + converter?: Converter; +} + +/** + * A synthetic string used to represent an undefined input value in the context of GitHub Actions. + * + * This value is used because inputs with an empty string value and inputs that were not supplied + * are indistinguishable in the context of GitHub Actions. Therefore, this synthetic value is used + * to represent undefined input values, allowing for a clear distinction between empty and undefined + * values. + * + * @remarks + * + * Yeah, it seems that Microsoft didn't think that 2 already existing values that + * represent absence of any object value in slightly different ways quite cut it, + * so for their GitHub Actions they invented a brand new one! + * Rejoice and greet an, I'm sorry, THE empty string! + * + * @remarks + * + * Someone at Microsoft was like: + * + * - undefined === null == "" // true + * - Hm, seems legit + * + */ +export const SYNTHETIC_UNDEFINED = "${undefined}"; + +/** + * The prefix used to identify GitHub Action inputs in the environment variables. + */ +const INPUT_PREFIX = "INPUT_"; + +/** + * Sets the value of a GitHub Action input by setting an environment variable. + * + * @param name - The name of the input to set. + * @param value - The value to set for the input. + * @param env - An optional set of the environment variables to update. Defaults to `process.env`. + */ +export function setActionInput(name: string, value: unknown, env?: Record): void { + const normalizedName = normalizeActionParameterName(name); + const environmentVariableName = INPUT_PREFIX + normalizedName; + const stringifiedValue = value === undefined || value === SYNTHETIC_UNDEFINED + ? undefined + : typeof value === "string" + ? value + : JSON.stringify(value); + + setEnvironmentVariable(environmentVariableName, stringifiedValue, env); +} + +/** + * Sets the values of multiple GitHub Action inputs by setting their environment variables. + * + * @param inputs - An iterable object of pairs, where the first item is the input parameter name, and the second item is the input parameter value. + * @param env - An optional set of the environment variables to update. Defaults to `process.env`. + */ +export function setActionInputs(inputs: Iterable, env?: Record): void { + for (const [name, value] of inputs) { + setActionInput(name, value, env); + } +} + +/** + * Gets the value of an input. + * + * @param name - Name of the input to get. + * @param options - Options to configure the way input retrieving works. + * @param env - An optional set of the environment variables to search within. Defaults to `process.env`. + * + * @returns The value of the input, or `undefined` if it was not provided. + * + * @throws An error if the `options.required` flag is set to `true` and the input is not defined. + */ +export function getActionInput(name: string, options?: InputOptions, env?: Record): string | undefined { + const normalizedName = normalizeActionParameterName(name); + const environmentVariableName = INPUT_PREFIX + normalizedName; + const brokenValue = getEnvironmentVariable(environmentVariableName, env); + const value = isActionInputDefined(brokenValue) ? brokenValue : undefined; + const trimmedValue = (options?.trimWhitespace ?? true) ? value?.trim() : value + + if (options?.required && value === undefined) { + throw new Error(`Input required and not supplied: ${name}.`); + } + + return trimmedValue; +} + +/** + * Gets the values of multiple inputs. + * + * @param names - Names of the inputs to get. + * @param options - Options to configure the way input retrieving works. + * @param env - An optional set of the environment variables to search within. Defaults to `process.env`. + * + * @returns An array of the values of the inputs. The order of the values matches the order of the input names in the `names` parameter. + * @throws An error if the `options.required` flag is set to `true` and one of the inputs is not defined. + */ +export function getActionInputs(names: Iterable, options?: InputOptions, env?: Record): string[] { + return $i(names).map(name => getActionInput(name, options, env)).toArray(); +} + +/** + * Returns a map containing all inputs provided to the action. + * + * @param options - Options to configure the way input retrieving works. + * @param env - An optional set of the environment variables to search within. Defaults to `process.env`. + * + * @returns A map of input names and their corresponding values. + * @throws An error if the `options.required` flag is set to `true` and one of the inputs is not defined. + */ +export function getAllActionInputs(options?: InputOptions, env?: Record): Map { + const inputs = new Map(); + const required = options?.required; + const trimWhitespace = options?.trimWhitespace ?? true; + + for (const [name, value] of getAllEnvironmentVariables(env)) { + if (!name.startsWith(INPUT_PREFIX)) { + continue; + } + + const inputName = name.substring(INPUT_PREFIX.length); + const isValueDefined = isActionInputDefined(value); + + if (required && !isValueDefined) { + throw new Error(`Input required and not supplied: ${inputName}.`); + } + + if (!isValueDefined) { + continue; + } + + const inputValue = trimWhitespace ? value.trim() : value; + inputs.set(inputName, inputValue); + } + return inputs; +} + +/** + * Checks whether the provided value is a defined input value. + * + * @param value - The value to check. + * + * @returns `true` if the value is a defined input value; otherwise, `false`. + */ +function isActionInputDefined(value: unknown): value is string { + return typeof value === "string" && value !== SYNTHETIC_UNDEFINED; +} + +/** + * Retrieves all action inputs, converts them to the specified types, and returns them as an object. + * + * @template T - The expected type of the resulting object. + * + * @param descriptors - An iterable of action input descriptors. + * @param options - Options for customizing the input object creation. + * @param env - An optional set of the environment variables to search within. Defaults to `process.env`. + * + * @returns A promise that resolves to an object containing the processed inputs. + */ +export async function getAllActionInputsAsObject(descriptors: Iterable, options?: InputObjectOptions, env?: Record): Promise { + const moduleLoader = options?.moduleLoader || NODE_MODULE_LOADER; + const converter = options?.converter || toType; + + const descriptorArray = asArray(descriptors); + const inputs = getAllActionInputs(options, env); + const inputObject = { } as T; + + for (const [name, value] of inputs) { + const descriptor = descriptorArray.find(d => stringEquals(d.name, name, { ignoreCase: true })); + const targetDescriptor = descriptor?.redirect ? descriptorArray.find(d => d.name === descriptor.redirect) : descriptor; + if (!targetDescriptor) { + continue; + } + + const parsedValue = await parseInput(value, descriptor.type, moduleLoader, converter); + if (parsedValue === undefined) { + throw new Error(`Cannot convert "${descriptor.name}" to "${descriptor.type.name}".`); + } + + defineNestedProperty(inputObject, targetDescriptor.path, { value: parsedValue, writable: true, configurable: true, enumerable: true }); + } + + return inputObject; +} + +/** + * Retrieves all action inputs using metadata, converts them to the specified types, and returns them as an object. + * + * @template T - The expected type of the resulting object. + * + * @param metadata - The metadata of the action. + * @param options - Options for customizing the input object creation and descriptor extraction. + * @param env - An optional set of the environment variables to search within. Defaults to `process.env`. + * + * @returns A promise that resolves to an object containing the processed inputs. + */ +export async function getAllActionInputsAsObjectUsingMetadata(metadata: ActionMetadata, options?: InputObjectOptions & ActionParameterDescriptorExtractionOptions, env?: Record): Promise { + const descriptors = getActionInputDescriptors(metadata, options); + return await getAllActionInputsAsObject(descriptors, options, env); +} + +/** + * Parses an input value using the specified type descriptor, module loader, and converter function. + * + * @param value - The input value to parse. + * @param type - The type descriptor for the input. + * @param moduleLoader - The module loader to use when loading modules. + * @param converter - The converter function to use when converting the input value. + * + * @returns A promise that resolves to the parsed input value. + */ +async function parseInput(value: string, type: ActionParameterTypeDescriptor, moduleLoader: ModuleLoader, converter: Converter): Promise { + const shouldSplit = type.options?.getBoolean(ActionParameterFactoryOptions.SPLIT) ?? type.isArray; + const parse = shouldSplit ? parseMultipleInputs : parseSingleInput; + + return await parse(value, type, moduleLoader, converter); +} + +/** + * Parses multiple input values using the specified type descriptor, module loader, and converter function. + * + * @param value - The input value to parse. + * @param type - The type descriptor for the input. + * @param moduleLoader - The module loader to use when loading modules. + * @param converter - The converter function to use when converting the input value. + * + * @returns A promise that resolves to the parsed input values. + */ +async function parseMultipleInputs(value: string, type: ActionParameterTypeDescriptor, moduleLoader: ModuleLoader, converter: Converter): Promise { + const separator = type.options?.getRegExp(ActionParameterFactoryOptions.SEPARATOR) ?? /\r?\n/g; + const processSeparately = type.options?.getBoolean(ActionParameterFactoryOptions.PROCESS_SEPARATELY) ?? true; + const trimEntries = type.options?.getBoolean(ActionParameterFactoryOptions.TRIM_ENTRIES) ?? true; + const removeEmptyEntries = type.options?.getBoolean(ActionParameterFactoryOptions.REMOVE_EMPTY_ENTRIES) ?? true; + const flatDepth = type.options?.getNumber(ActionParameterFactoryOptions.FLAT_DEPTH) ?? 1; + + const values = split(value, separator, { trimEntries, removeEmptyEntries }); + + if (!processSeparately) { + return await parseSingleInput(values, type, moduleLoader, converter); + } + + const processedValues = await Promise.all(values.map(v => parseSingleInput(v, type, moduleLoader, converter))); + const flattenedValues = processedValues.flat(flatDepth); + return flattenedValues; +} + +/** + * Parses a single input value using the specified type descriptor, module loader, and converter function. + * + * @param value - The input value to parse. + * @param type - The type descriptor for the input. + * @param moduleLoader - The module loader to use when loading modules. + * @param converter - The converter function to use when converting the input value. + * + * @returns A promise that resolves to the parsed input value. + */ +async function parseSingleInput(value: string | string[], type: ActionParameterTypeDescriptor, moduleLoader: ModuleLoader, converter: Converter): Promise { + // Simple cases like "string", "number", "Date". + // Should be handled by the `converter` function. + if (!type.factory && !type.module) { + return await converter(value, type.name); + } + + const typeImport = await executeImportDirective(type, { moduleLoader, required: false }); + + // The `factory` function was specified. + // Therefore, it should be used to process the input. + if (type.factory) { + const factoryImport = await executeImportDirective<(v: string | string[], o?: QueryString) => unknown>(type.factory, { + moduleLoader, + defaultModuleProvider: d => Promise.resolve(d.isDefault ? (typeImport?.value ?? globalThis) : globalThis), + required: true, + }); + + return await factoryImport.value(value, type.options); + } + + // The only hope we have is that `converter` function will be able to process the input + // using the target type or its module themselves. + // + // This is usually the case when a type has a dedicated `parse`- or `convert`-like module, + // or one those is specified on the module itself. + const conversionMethodContainers = [typeImport?.value, typeImport?.module].filter(x => x); + for (const target of conversionMethodContainers) { + const [convertedValue] = await runSafely(() => converter(value, target)); + if (convertedValue !== undefined) { + return convertedValue; + } + } + + // None of the above strategies worked. + // Let the caller deal with it. + return undefined; +} diff --git a/src/utils/actions/input.ts b/src/utils/actions/input.ts deleted file mode 100644 index 1f64623..0000000 --- a/src/utils/actions/input.ts +++ /dev/null @@ -1,142 +0,0 @@ -import process from "process"; - -interface EnumLike { - [i: string | number | symbol]: T | string; -} - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -interface InputObject extends Record { } - -const undefinedValue = "${undefined}"; - -export function getInputAsObject(): Record { - const inputs = Object.entries(process.env).filter(([key, _]) => key.startsWith("INPUT_")); - const input = {}; - for (const [name, value] of inputs) { - const words = name.substring(6).toLowerCase().split(/[\W_]/).filter(x => x); - init(input, words, value); - } - return input; -} - -function init(root: InputObject, path: string[], value: string): void { - if (value === undefinedValue) { - return; - } - - const name = path.reduce((a, b, i) => a + (i === 0 ? b : (b.substring(0, 1).toUpperCase() + b.substring(1))), ""); - root[name] = value; - if (path.length === 1) { - return; - } - - const innerPath = path[0]; - const inner = root[innerPath] ? root[innerPath] : (root[innerPath] = {}); - if (typeof inner === "object") { - init(inner, path.slice(1), value); - } -} - -export function mapStringInput(value: any, defaultValue = ""): string { - return mapInput(value, defaultValue, null, "string"); -} - -export function mapObjectInput(value: any, defaultValue: object = null): object { - return mapInput(value, defaultValue, null, "object"); -} - -export function mapNumberInput(value: any, defaultValue = 0): number { - return mapInput(value, defaultValue, { - string: x => { - const num = +x; - return isNaN(num) ? undefined : num; - } - }, "number"); -} - -export function mapBooleanInput(value: any, defaultValue = false): boolean { - return mapInput(value, defaultValue, { - string: x => { - const strValue = x.trim().toLowerCase(); - return ( - strValue === "true" ? true : - strValue === "false" ? false : - undefined - ); - } - }, "boolean"); -} - -function findEnumValueByName, U>(enumClass: T, name: string): U | undefined { - if (typeof enumClass[+name] === "string") { - return +name; - } - - if (enumClass[name] !== undefined) { - return enumClass[name]; - } - - const entries = Object.entries(enumClass); - for (const [key, value] of entries) { - if (key.localeCompare(name, undefined, { sensitivity: "base" }) === 0) { - return value; - } - } - for (const [key, value] of entries) { - if (key.trim().replace(/[-_]/g, "").localeCompare(name.trim().replace(/[-_]/g, ""), undefined, { sensitivity: "base" }) === 0) { - return value; - } - } - return undefined; -} - -export function mapEnumInput(value: any, enumClass: any, defaultValue?: T): T; - -export function mapEnumInput, U>(value: any, enumClass: T, defaultValue?: U): U; - -export function mapEnumInput, U>(value: any, enumClass: T, defaultValue: U = null): U | null { - return mapInput(value, defaultValue, { - string: (x: string) => { - let result: U = undefined; - - let i = 0; - while (i < x.length) { - let separatorIndex = x.indexOf("|", i); - if (separatorIndex === -1) { - separatorIndex = x.length; - } - - const currentValue = findEnumValueByName(enumClass, x.substring(i, separatorIndex)); - if (result === undefined || currentValue !== undefined && typeof currentValue !== "number") { - result = currentValue; - } else { - result = (result | currentValue); - } - - i = separatorIndex + 1; - } - - return result; - } - }, "number"); -} - -export function mapInput(value: any, fallbackValue?: T, mappers?: Record T | undefined>, valueType?: string): T { - if (value === undefinedValue || value === undefined || value === null) { - return fallbackValue; - } - - valueType ??= typeof fallbackValue; - if (typeof value === valueType) { - return value; - } - - const mapper = mappers?.[typeof value]; - if (mapper) { - const mappedValue = mapper(value); - if (typeof mappedValue === valueType) { - return mappedValue; - } - } - return fallbackValue; -}