Skip to content

Commit

Permalink
feat: implement array input type (#1261)
Browse files Browse the repository at this point in the history
* refactor: better type safety

* refactor: escaping " logic for include:inputs

* refactor:

* refactor: add types for InputTypes

* refactor: helper function for exhaustive type checking in switch
statement

* feat: implement ci interpolation with arrays

- https://docs.gitlab.com/ee/ci/yaml/inputs.html#array-type
  • Loading branch information
ANGkeith authored Jun 18, 2024
1 parent 58fa3d7 commit dee52b9
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 35 deletions.
92 changes: 60 additions & 32 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import {WriteStreams} from "./write-streams";
import {init as initPredefinedVariables} from "./predefined-variables";

const MAX_FUNCTIONS = 3;
const INCLUDE_INPUTS_SUPPORTED_TYPES = ["string", "boolean", "number"];
const INCLUDE_INPUTS_SUPPORTED_TYPES = ["string", "boolean", "number", "array"] as const;
export type InputType = typeof INCLUDE_INPUTS_SUPPORTED_TYPES[number];

export class Parser {

Expand Down Expand Up @@ -293,8 +294,8 @@ export class Parser {

const interpolatedConfigurations = JSON.stringify(uninterpolatedConfigurations)
.replace(
/\$\[\[\s*inputs.(?<interpolationKey>[\w-]+)\s*\|?\s*(?<interpolationFunctions>.*?)\s*\]\]/g // regexr.com/7sh15
, (_: string, interpolationKey: string, interpolationFunctions: string) => {
/(?<firstChar>.)(?<secondChar>.)\$\[\[\s*inputs.(?<interpolationKey>[\w-]+)\s*\|?\s*(?<interpolationFunctions>.*?)\s*\]\](?<lastChar>.)/g // https://regexr.com/81c16
, (_: string, firstChar: string, secondChar: string, interpolationKey: string, interpolationFunctions: string, lastChar: string) => {
const configFilePath = path.relative(process.cwd(), filePath);
const context = {
interpolationKey,
Expand All @@ -304,17 +305,29 @@ export class Parser {
...ctx,
};

validateInterpolationKey(context);
validateInterpolationFunctions(context);
const inputValue = getInputValue(context);
validateInput(inputValue, context);

const jsonValue = JSON.stringify(inputValue);
// Unquote string if necessary
if (typeof(jsonValue) == "string" && jsonValue.startsWith("\"") && jsonValue.endsWith("\"")) {
return jsonValue.slice(1, -1);
const {inputValue, inputType} = parseIncludeInputs(context);
const firstTwoChar = firstChar + secondChar;
switch (inputType) {
case "array":
if ((secondChar == "\"" && lastChar == "\"") && firstChar != "\\") {
return firstChar + JSON.stringify(inputValue);
}

// NOTE: This behaves slightly differently from gitlab.com. I can't come up with practical use case so i don't think it's worth the effort to mimic this
return firstTwoChar + JSON.stringify(JSON.stringify(inputValue)).slice(1, -1) + lastChar;
case "string":
return firstTwoChar
+ JSON.stringify(inputValue) // ensure a valid json string
.slice(1, -1) // remove the surrounding "
+ lastChar;

case "number":
case "boolean":
return firstTwoChar + inputValue + lastChar;

default:
Utils.switchStatementExhaustiveCheck(inputType);
}
return jsonValue;
});
return JSON.parse(interpolatedConfigurations);
}
Expand All @@ -326,6 +339,12 @@ function isGitlabSpecFile (fileData: any) {
return "spec" in fileData;
}

function validateInterpolationKey (ctx: any) {
const {configFilePath, interpolationKey, inputsSpecification} = ctx;
const invalidInterpolationKeyErr = chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: unknown interpolation key: \`${interpolationKey}\`.`;
assert(inputsSpecification.spec.inputs?.[interpolationKey] !== undefined, invalidInterpolationKeyErr);
}

function validateInterpolationFunctions (ctx: any) {
const {interpolationFunctions, configFilePath} = ctx;
if (interpolationFunctions != "") {
Expand All @@ -334,36 +353,45 @@ function validateInterpolationFunctions (ctx: any) {
assert(interpolationFunctions.split("|").length <= MAX_FUNCTIONS, chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: too many functions in interpolation block.`);
}

function getInputValue (ctx: any) {
const {inputs, interpolationKey, configFilePath, inputsSpecification} = ctx;
const inputValue = inputs[interpolationKey] || inputsSpecification.spec.inputs[interpolationKey]?.default;
assert(inputValue !== undefined, chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: \`{blueBright ${interpolationKey}}\` input: required value has not been provided.`);
return inputValue;
}

function validateInterpolationKey (ctx: any): any {
const {configFilePath, interpolationKey, inputsSpecification} = ctx;
const invalidInterpolationKeyErr = chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: unknown interpolation key: \`${interpolationKey}\`.`;
assert(inputsSpecification.spec.inputs?.[interpolationKey] !== undefined, invalidInterpolationKeyErr);
}

function validateInput (inputValue: any, ctx: any): any {
function validateInput (ctx: any) {
const {configFilePath, interpolationKey, inputsSpecification} = ctx;
const inputValue = getInputValue(ctx);

const options = inputsSpecification.spec.inputs[interpolationKey]?.options;
if (options) {
assert(options.includes(inputValue),
chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: \`{blueBright ${interpolationKey}}\` input: \`{blueBright ${inputValue}}\` cannot be used because it is not in the list of allowed options.`);
}

const type = inputsSpecification.spec.inputs[interpolationKey]?.type || "string";
assert(INCLUDE_INPUTS_SUPPORTED_TYPES.includes(type),
chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: header:spec:inputs:{blueBright ${interpolationKey}} input type unknown value: {blueBright ${type}}.`);
assert(typeof inputValue == type,
chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: \`{blueBright ${interpolationKey}}\` input: provided value is not a {blueBright ${type}}.`);
const expectedInputType = getExpectedInputType(ctx);
assert(INCLUDE_INPUTS_SUPPORTED_TYPES.includes(expectedInputType),
chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: header:spec:inputs:{blueBright ${interpolationKey}} input type unknown value: {blueBright ${expectedInputType}}.`);

const inputType = Array.isArray(inputValue) ? "array" : typeof inputValue;
assert(inputType === expectedInputType,
chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: \`{blueBright ${interpolationKey}}\` input: provided value is not a {blueBright ${expectedInputType}}.`);

const regex = inputsSpecification.spec.inputs[interpolationKey]?.regex;
if (regex) {
console.log(chalk`{black.bgYellowBright WARN } spec:inputs:regex is currently not supported via gitlab-ci-local. This will just be a no-op.`);
}
}

function parseIncludeInputs (ctx: any): {inputValue: any; inputType: InputType} {
validateInterpolationKey(ctx);
validateInterpolationFunctions(ctx);
validateInput(ctx);
return {inputValue: getInputValue(ctx), inputType: getExpectedInputType(ctx)};
}

function getInputValue (ctx: any) {
const {inputs, interpolationKey, configFilePath, inputsSpecification} = ctx;
const inputValue = inputs[interpolationKey] || inputsSpecification.spec.inputs[interpolationKey]?.default;
assert(inputValue !== undefined, chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: \`{blueBright ${interpolationKey}}\` input: required value has not been provided.`);
return inputValue;
}

function getExpectedInputType (ctx: any): InputType {
const {interpolationKey, inputsSpecification} = ctx;
return inputsSpecification.spec.inputs[interpolationKey]?.type || "string";
}
9 changes: 7 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,12 +305,17 @@ export class Utils {
}
}
default: {
const _exhaustiveCheck: never = protocol;
throw new Error(`${_exhaustiveCheck} not supported!`);
Utils.switchStatementExhaustiveCheck(protocol);
}
}
}

static trimSuffix (str: string, suffix: string) {
return str.endsWith(suffix) ? str.slice(0, -suffix.length) : str;
}

static switchStatementExhaustiveCheck (param: never): never {
// https://dev.to/babak/exhaustive-type-checking-with-typescript-4l3f
throw new Error(`Unhandled case ${param}`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ spec:
type: boolean
default_can_be_overwritten:
default: default
default_input_array:
type: array
default:
- alice
- bob
---
scan-website:
script:
- echo $[[ inputs.default_input_string ]]
- echo $[[ inputs.default_input_number ]]
- echo $[[ inputs.default_input_boolean ]]
- echo $[[ inputs.default_can_be_overwritten ]]
- $[[ inputs.default_input_array ]]
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
spec:
inputs:
array_input:
type: array
---
scan-website:
script:
- echo $[[ inputs.array_input]]
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
include:
- local: '/.gitlab-ci-input-template.yml'
inputs:
array_input: "true"
stages:
- test
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
spec:
inputs:
rules-config:
type: array
default:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
- if: $CI_PIPELINE_SOURCE == "schedule"
---
test_job:
rules: $[[ inputs.rules-config ]]
script: ls
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
include:
- local: '/.gitlab-ci-input-template.yml'

stages:
- test
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ scan-website:
- echo string
- echo 1
- echo true
- echo overwrite default value`;
- echo overwrite default value
- alice
- bob`;

expect(writeStreams.stdoutLines[0]).toEqual(expected);
});
Expand All @@ -133,6 +135,21 @@ scan-website:
expect(writeStreams.stdoutLines[0]).toEqual(expected);
});

test("include-inputs inputs validation for array", async () => {
try {
const writeStreams = new WriteStreamsMock();
await handler({
cwd: "tests/test-cases/include-inputs/input-templates/type-validation/array",
preview: true,
}, writeStreams);
expect(true).toBe(false);
} catch (e) {
assert(e instanceof AssertionError, "e is not instanceof AssertionError");
expect(e.message).toContain("This GitLab CI configuration is invalid:");
expect(e.message).toContain(chalk`\`{blueBright array_input}\` input: provided value is not a {blueBright array}.`);
}
});

test("include-inputs inputs validation for string", async () => {
try {
const writeStreams = new WriteStreamsMock();
Expand Down Expand Up @@ -165,6 +182,29 @@ test("include-inputs inputs validation for number", async () => {
throw new Error("Error is expected but not thrown/caught");
});

test("include-inputs for type array", async () => {
const writeStreams = new WriteStreamsMock();
await handler({
cwd: "tests/test-cases/include-inputs/input-templates/types/array",
preview: true,
}, writeStreams);

const expected = `---
stages:
- .pre
- test
- .post
test_job:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
- if: $CI_PIPELINE_SOURCE == "schedule"
script:
- ls`;

expect(writeStreams.stdoutLines[0]).toEqual(expected);
});

test("include-inputs inputs validation for boolean", async () => {
try {
const writeStreams = new WriteStreamsMock();
Expand Down

0 comments on commit dee52b9

Please sign in to comment.