Merge branch 'main' into thyeggman/implement-job-context-provider

This commit is contained in:
Jacob Wallraff
2022-12-15 15:43:28 -08:00
22 changed files with 1043 additions and 119 deletions
+101
View File
@@ -0,0 +1,101 @@
import {actionIdentifier, parseActionReference as parse} from "./action";
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");
});
});
+51
View File
@@ -0,0 +1,51 @@
// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#inputs
export type ActionInput = {
description: string;
required?: boolean;
default?: string;
deprecationMessage?: string;
};
export type ActionInputs = Record<string, ActionInput>;
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}`;
}
@@ -1,4 +1,5 @@
import {data} from "@github/actions-expressions";
import {CompletionItemKind} from "vscode-languageserver-types";
import {complete, getExpressionInput} from "./complete";
import {ContextProviderConfig} from "./context-providers/config";
import {registerLogger} from "./log";
@@ -30,13 +31,17 @@ describe("expressions", () => {
expect(test("${{ gh |")).toBe(" gh ");
expect(test("${{ gh |}}")).toBe(" gh ");
expect(test("${{ vars| == 'test' }}")).toBe(" vars");
expect(test("${{ fromJso|('test').bar == 'test' }}")).toBe(" fromJso");
expect(test("${{ github.| == 'test' }}")).toBe(" github.");
expect(test("test ${{ github.| == 'test' }}")).toBe(" github.");
expect(test("${{ vars }} ${{ gh |}}")).toBe(" gh ");
});
describe("top-level auto-complete", () => {
it("single region", async () => {
const input = "run-name: ${{ | }}";
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
const result = await complete(...getPositionFromCursor(input), undefined, undefined);
expect(result.map(x => x.label)).toEqual([
"github",
@@ -70,6 +75,42 @@ describe("expressions", () => {
]);
});
it("single region with existing condition", async () => {
const input = "run-name: ${{ g| == 'test' }}";
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"contains",
"endsWith",
"format",
"fromJson",
"join",
"startsWith",
"toJson"
]);
});
it("multiple regions with partial function", async () => {
const input = "run-name: Run a ${{ inputs.test }} one-line script ${{ from|('test') == inputs.name }}";
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"contains",
"endsWith",
"format",
"fromJson",
"join",
"startsWith",
"toJson"
]);
});
it("multiple regions", async () => {
const input = "run-name: test-${{ github }}-${{ | }}";
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
@@ -320,6 +361,23 @@ jobs:
expect(result).toEqual([]);
});
it("github context includes expected keys", async () => {
const input = `
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: echo \${{ github.| }}
`;
const result = await complete(...getPositionFromCursor(input), undefined, undefined);
expect(result.map(x => x.label)).toContain("actor");
});
describe("steps context", () => {
it("includes defined step IDs", async () => {
const input = `
@@ -615,4 +673,53 @@ jobs:
expect(result.map(x => x.label)).toEqual(["color"]);
});
});
it("context completion items include kind and insert text", async () => {
const input = `
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: echo \${{ | }}.txt
`;
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
// Built-in function
const toJSON = result.find(x => x.label === "toJson");
expect(toJSON).toBeDefined();
expect(toJSON!.kind).toBe(CompletionItemKind.Function);
expect(toJSON!.insertText).toBe("toJson()");
// Function from context
const hashFiles = result.find(x => x.label === "hashFiles");
expect(hashFiles).toBeDefined();
expect(hashFiles!.kind).toBe(CompletionItemKind.Function);
expect(hashFiles!.insertText).toBe("hashFiles()");
// Not a function
const github = result.find(x => x.label === "github");
expect(github).toBeDefined();
expect(github!.kind).toBe(CompletionItemKind.Variable);
expect(github!.insertText).toBeUndefined();
});
it("function parentheses are not inserted when parentheses already exist", async () => {
const input = `
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo \${{ toJS|(github.event) }}
`;
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
expect(result.find(x => x.label === "toJson")!.insertText).toBe("toJson");
});
});
@@ -199,6 +199,24 @@ jobs:
expect(result[0].label).toEqual("my-custom-label");
});
it("custom value providers for sequences", async () => {
const input = "on: push\njobs:\n build:\n runs-on: [m|]";
const config: ValueProviderConfig = {
"runs-on": {
kind: ValueProviderKind.SuggestedValues,
get: async (_: WorkflowContext) => {
return [{label: "my-custom-label"}];
}
}
};
const result = await complete(...getPositionFromCursor(input), config);
expect(result).not.toBeUndefined();
expect(result.length).toEqual(1);
expect(result[0].label).toEqual("my-custom-label");
});
it("does not show parent mapping sibling keys", async () => {
const input = `on: push
jobs:
+40 -29
View File
@@ -1,20 +1,20 @@
import {complete as completeExpression} from "@github/actions-expressions";
import {CompletionItem as ExpressionCompletionItem} from "@github/actions-expressions/completion";
import {convertWorkflowTemplate, isSequence, isString, parseWorkflow} from "@github/actions-workflow-parser";
import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert";
import {DefinitionType} from "@github/actions-workflow-parser/templates/schema/definition-type";
import {StringDefinition} from "@github/actions-workflow-parser/templates/schema/string-definition";
import {CLOSE_EXPRESSION, OPEN_EXPRESSION} from "@github/actions-workflow-parser/templates/template-constants";
import {OPEN_EXPRESSION} from "@github/actions-workflow-parser/templates/template-constants";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/index";
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
import {TokenType} from "@github/actions-workflow-parser/templates/tokens/types";
import {File} from "@github/actions-workflow-parser/workflows/file";
import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {CompletionItem, Range, TextEdit} from "vscode-languageserver-types";
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
import {ContextProviderConfig} from "./context-providers/config";
import {getContext, Mode} from "./context-providers/default";
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
import {nullTrace} from "./nulltrace";
import {getAllowedContext} from "./utils/allowed-context";
import {findToken} from "./utils/find-token";
import {mapRange} from "./utils/range";
import {transform} from "./utils/transform";
@@ -29,14 +29,7 @@ export function getExpressionInput(input: string, pos: number): string {
return input;
}
// Find end marker after the cursor position
let endPos = input.indexOf(CLOSE_EXPRESSION, pos);
if (endPos === -1) {
// Assume an unfinished expression like "${{ someinput.|"
endPos = input.length;
}
return input.substring(startPos + OPEN_EXPRESSION.length, endPos);
return input.substring(startPos + OPEN_EXPRESSION.length, pos);
}
export async function complete(
@@ -85,17 +78,19 @@ export async function complete(
if (token.range!.start[0] !== token.range!.end[0]) {
const lines = currentInput.split("\n");
const linesBeforeCusor = lines.slice(0, lineDiff);
relCharPos = linesBeforeCusor.join("\n").length + newPos.character;
relCharPos = linesBeforeCusor.join("\n").length + 1 + newPos.character;
} else {
relCharPos = newPos.character - token.range!.start[1];
relCharPos = newPos.character - token.range!.start[1] + 1;
}
const expressionInput = (getExpressionInput(currentInput, relCharPos) || "").trim();
const allowedContext = getAllowedContext(token, parent);
const allowedContext = token.definitionInfo?.allowedContext || [];
const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion);
return completeExpression(expressionInput, context, []);
return completeExpression(expressionInput, context, []).map(item =>
mapExpressionCompletionItem(item, currentInput[relCharPos])
);
}
}
@@ -106,11 +101,12 @@ export async function complete(
}
return values.map(value => {
const item = CompletionItem.create(value.label);
item.detail = value.description;
if (replaceRange) {
item.textEdit = TextEdit.replace(replaceRange, value.label);
}
const item: CompletionItem = {
label: value.label,
detail: value.description,
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
textEdit: replaceRange ? TextEdit.replace(replaceRange, value.label) : undefined
};
return item;
});
@@ -129,21 +125,22 @@ async function getValues(
const existingValues = getExistingValues(token, parent);
if (keyToken?.definition?.key) {
const customValues = await valueProviderConfig?.[keyToken.definition.key]?.get(workflowContext);
// Use the value providers from the parent if the current key is null
const valueProviderToken = keyToken || parent;
const customValueProvider =
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
if (customValueProvider) {
const customValues = await customValueProvider.get(workflowContext);
if (customValues) {
return filterAndSortCompletionOptions(customValues, existingValues);
}
}
// Use the value provider from the parent if we don't have a value provider for the current key
const valueProvider =
(keyToken?.definition?.key && defaultValueProviders[keyToken.definition.key]) ||
(parent.definition?.key && defaultValueProviders[parent.definition.key]);
if (valueProvider) {
const values = await valueProvider.get(workflowContext);
const defaultValueProvider =
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
if (defaultValueProvider) {
const values = await defaultValueProvider.get(workflowContext);
return filterAndSortCompletionOptions(values, existingValues);
}
@@ -198,3 +195,17 @@ function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<s
options.sort((a, b) => a.label.localeCompare(b.label));
return options;
}
function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPos: string): CompletionItem {
let insertText: string | undefined;
// Insert parentheses if the cursor is after a function
// and the function does not have any parantheses already
if (item.function) {
insertText = charAfterPos === "(" ? item.label : item.label + "()";
}
return {
label: item.label,
insertText: insertText,
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
};
}
@@ -2,6 +2,7 @@ import {data} from "@github/actions-expressions";
import {Kind} from "@github/actions-expressions/data/expressiondata";
import {WorkflowContext} from "../context/workflow-context";
import {ContextProviderConfig} from "./config";
import {getGithubContext} from "./github";
import {getInputsContext} from "./inputs";
import {getJobContext} from "./job";
import {getMatrixContext} from "./matrix";
@@ -45,6 +46,18 @@ export async function getContext(
function getDefaultContext(name: string, workflowContext: WorkflowContext, mode: Mode): ContextValue | undefined {
switch (name) {
case "github":
return getGithubContext();
case "inputs":
return getInputsContext(workflowContext);
case "matrix":
return getMatrixContext(workflowContext, mode);
case "needs":
return getNeedsContext(workflowContext);
case "runner":
return objectToDictionary({
os: "Linux",
@@ -54,12 +67,6 @@ function getDefaultContext(name: string, workflowContext: WorkflowContext, mode:
temp: "/home/runner/work/_temp"
});
case "needs":
return getNeedsContext(workflowContext);
case "inputs":
return getInputsContext(workflowContext);
case "secrets":
return objectToDictionary({GITHUB_TOKEN: "***"});
@@ -0,0 +1,47 @@
import {data} from "@github/actions-expressions";
export function getGithubContext(): data.Dictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
const keys = [
"action",
"action_path",
"action_ref",
"action_repository",
"action_status",
"actor",
"api_url",
"base_ref",
"env",
"event",
"event_name",
"event_path",
"graphql_url",
"head_ref",
"job",
"ref",
"ref_name",
"ref_protected",
"ref_type",
"path",
"repository",
"repository_owner",
"repositoryUrl",
"retention_days",
"run_id",
"run_number",
"run_attempt",
"secret_source",
"server_url",
"sha",
"token",
"triggering_actor",
"workflow",
"workspace"
];
return new data.Dictionary(
...keys.map(key => {
return {key, value: new data.Null()};
})
);
}
@@ -1,15 +0,0 @@
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
export function getAllowedContext(token: TemplateToken, parent: TemplateToken | null | undefined): string[] {
// Workaround for https://github.com/github/c2c-actions-experience/issues/6876
// Context is inherited from the parent
const allowedContext = new Set<string>();
for (const t of [token, parent]) {
if (t?.definition?.readerContext) {
for (const context of t.definition.readerContext) {
allowedContext.add(context);
}
}
}
return Array.from(allowedContext);
}
@@ -0,0 +1,82 @@
import {isMapping} from "@github/actions-workflow-parser";
import {isActionStep} from "@github/actions-workflow-parser/model/type-guards";
import {Step} from "@github/actions-workflow-parser/model/workflow-template";
import {ScalarToken} from "@github/actions-workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {parseActionReference} from "./action";
import {mapRange} from "./utils/range";
import {ValidationConfig} from "./validate";
export async function validateAction(
diagnostics: Diagnostic[],
stepToken: TemplateToken,
step: Step | undefined,
config: ValidationConfig | undefined
): Promise<void> {
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.getActionInputs) {
return;
}
const action = parseActionReference(step.uses.value);
if (!action) {
return;
}
const actionInputs = await config.getActionInputs(action);
if (actionInputs === undefined) {
return;
}
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const {key, value} of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
break;
}
}
const stepInputs = new Map<string, ScalarToken>();
if (withToken && isMapping(withToken)) {
for (const {key} of withToken) {
stepInputs.set(key.toString(), key);
}
}
for (const [input, inputToken] of stepInputs) {
if (!actionInputs[input]) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(inputToken.range),
message: `Invalid action input '${input}'`
});
}
const deprecationMessage = actionInputs[input]?.deprecationMessage;
if (deprecationMessage) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
range: mapRange(inputToken.range),
message: deprecationMessage
});
}
}
const missingRequiredInputs = Object.entries(actionInputs).filter(
([inputName, input]) => input.required && !stepInputs.has(inputName)
);
if (missingRequiredInputs.length > 0) {
const message =
missingRequiredInputs.length === 1
? `Missing required input \`${missingRequiredInputs[0][0]}\``
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
message: message
});
}
}
@@ -0,0 +1,259 @@
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {ActionInput, ActionReference} from "./action";
import {registerLogger} from "./log";
import {createDocument} from "./test-utils/document";
import {TestLogger} from "./test-utils/logger";
import {validate, ValidationConfig} from "./validate";
import {ValueProviderKind} from "./value-providers/config";
registerLogger(new TestLogger());
const validationConfig: ValidationConfig = {
getActionInputs: async (ref: ActionReference) => {
let inputs: Record<string, ActionInput> = {};
switch (ref.owner + "/" + ref.name + "@" + ref.ref) {
case "actions/checkout@v3":
inputs = {
repository: {
description: "Repository name with owner",
default: "${{ github.repository }}"
}
};
break;
case "actions/setup-node@v1":
inputs = {
version: {
description: "Deprecated. Use node-version instead. Will not be supported after October 1, 2019",
deprecationMessage:
"The version property will not be supported after October 1, 2019. Use node-version instead"
}
};
break;
case "actions/cache@v1":
inputs = {
path: {
description: "A directory to store and save the cache",
required: true
},
key: {
description: "An explicit key for restoring and saving the cache",
required: true
},
"restore-keys": {
description: "An ordered list of keys to use for restoring the cache if no cache hit occurred for key",
required: false
}
};
break;
}
return inputs;
}
};
describe("validate action steps", () => {
it("valid action reference", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([]);
});
it("invalid input", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
notanoption: true
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Invalid action input 'notanoption'",
range: {
end: {
character: 19,
line: 8
},
start: {
character: 8,
line: 8
}
},
severity: DiagnosticSeverity.Error
}
]);
});
it("deprecated input", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v1
with:
version: 10
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "The version property will not be supported after October 1, 2019. Use node-version instead",
range: {
end: {
character: 15,
line: 8
},
start: {
character: 8,
line: 8
}
},
severity: DiagnosticSeverity.Warning
}
]);
});
it("missing required input", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
key: \${{ runner.os }}-node-\${{ hashFiles('**/package-lock.json') }}
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Missing required input `path`",
range: {
end: {
character: 10,
line: 7
},
start: {
character: 6,
line: 7
}
},
severity: DiagnosticSeverity.Error
}
]);
});
it("multiple missing required inputs", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
restore-keys: \${{ runner.os }}-node-
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Missing required inputs: `path`, `key`",
range: {
end: {
character: 10,
line: 7
},
start: {
character: 6,
line: 7
}
},
severity: DiagnosticSeverity.Error
}
]);
});
it("missing required inputs without a `with` key", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Missing required inputs: `path`, `key`",
range: {
end: {
character: 0,
line: 7
},
start: {
character: 6,
line: 6
}
},
severity: DiagnosticSeverity.Error
}
]);
});
it("skips extra validation from custom value provider", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
notanoption: true
`;
const config = validationConfig;
config.valueProviderConfig = {
"step-with": {
kind: ValueProviderKind.AllowedValues,
get: async () => {
return [{label: "repository", description: "Repository name with owner."}];
}
}
};
const result = await validate(createDocument("wf.yaml", input), config);
expect(result).toEqual([
{
message: "Invalid action input 'notanoption'",
range: {
end: {
character: 19,
line: 8
},
start: {
character: 8,
line: 8
}
},
severity: DiagnosticSeverity.Error
}
]);
});
});
@@ -367,7 +367,7 @@ jobs:
});
});
describe("multi-line strings", () => {
describe("multi-line strings warnings", () => {
it("indented |", async () => {
const input = `on: push
jobs:
@@ -489,6 +489,124 @@ jobs:
});
});
describe("multi-line strings errors", () => {
it("indented |", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
first line
test \${{ fromJSON2('') }}
test2`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Unrecognized function: 'fromJSON2'",
range: {
end: {
character: 35,
line: 7
},
start: {
character: 15,
line: 7
}
}
}
]);
});
it("indented |+", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |+
first line
test \${{ fromJSON2('') }}
test2`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Unrecognized function: 'fromJSON2'",
range: {
end: {
character: 35,
line: 7
},
start: {
character: 15,
line: 7
}
}
}
]);
});
it("indented >", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: >
first line
test \${{ fromJSON2('') }}
test2`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Unrecognized function: 'fromJSON2'",
range: {
end: {
character: 35,
line: 7
},
start: {
character: 15,
line: 7
}
}
}
]);
});
it("indented >+", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: >+
first line
test \${{ fromJSON2('') }}
test2`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Unrecognized function: 'fromJSON2'",
range: {
end: {
character: 35,
line: 7
},
start: {
character: 15,
line: 7
}
}
}
]);
});
});
describe("matrix context", () => {
it("reference within a matrix job", async () => {
const input = `
+4 -4
View File
@@ -70,7 +70,7 @@ jobs:
steps:
- run: echo`
),
defaultValueProviders
{valueProviderConfig: defaultValueProviders}
);
expect(result.length).toBe(1);
@@ -103,7 +103,7 @@ jobs:
steps:
- run: echo`
),
defaultValueProviders
{valueProviderConfig: defaultValueProviders}
);
expect(result.length).toBe(1);
@@ -139,7 +139,7 @@ jobs:
steps:
- run: echo`
),
defaultValueProviders
{valueProviderConfig: defaultValueProviders}
);
expect(result[0]).toEqual({
@@ -168,7 +168,7 @@ jobs:
runs-on:
- ubuntu-latest`
),
defaultValueProviders
{valueProviderConfig: defaultValueProviders}
);
expect(result.length).toBe(1);
+24 -21
View File
@@ -16,6 +16,7 @@ import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/te
import {File} from "@github/actions-workflow-parser/workflows/file";
import {TextDocument} from "vscode-languageserver-textdocument";
import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types";
import {ActionInputs, ActionReference} from "./action";
import {ContextProviderConfig} from "./context-providers/config";
import {getContext, Mode} from "./context-providers/default";
@@ -23,12 +24,18 @@ import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
import {AccessError, wrapDictionary} from "./expression-validation/error-dictionary";
import {error} from "./log";
import {nullTrace} from "./nulltrace";
import {getAllowedContext} from "./utils/allowed-context";
import {findToken} from "./utils/find-token";
import {mapRange} from "./utils/range";
import {validateAction} from "./validate-action";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
import {defaultValueProviders} from "./value-providers/default";
export type ValidationConfig = {
valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig;
getActionInputs?(action: ActionReference): Promise<ActionInputs | undefined>;
};
/**
* Validates a workflow file
*
@@ -37,9 +44,8 @@ import {defaultValueProviders} from "./value-providers/default";
*/
export async function validate(
textDocument: TextDocument,
config?: ValidationConfig
// TODO: Support multiple files, context for API calls
valueProviderConfig?: ValueProviderConfig,
contextProviderConfig?: ContextProviderConfig
): Promise<Diagnostic[]> {
const file: File = {
name: textDocument.uri,
@@ -55,14 +61,7 @@ export async function validate(
const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion);
// Validate expressions and value providers
await additionalValidations(
diagnostics,
textDocument.uri,
template,
result.value,
valueProviderConfig,
contextProviderConfig
);
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config);
}
// For now map parser errors directly to diagnostics
@@ -86,8 +85,7 @@ async function additionalValidations(
documentUri: URI,
template: WorkflowTemplate,
root: TemplateToken,
valueProviderConfig: ValueProviderConfig | undefined,
contextProviderConfig: ContextProviderConfig | undefined
config?: ValidationConfig
) {
for (const [parent, token, key] of TemplateToken.traverse(root)) {
// If the token is a value in a pair, use the key definition for validation
@@ -95,26 +93,33 @@ async function additionalValidations(
const validationToken = key || parent || token;
const validationDefinition = validationToken.definition;
const allowedContext = getAllowedContext(validationToken, parent);
// If this is an expression, validate it
if (isBasicExpression(token)) {
await validateExpression(
diagnostics,
token,
allowedContext,
contextProviderConfig,
validationToken.definitionInfo?.allowedContext || [],
config?.contextProviderConfig,
getProviderContext(documentUri, template, root, token)
);
}
if (token.definition?.key === "regular-step") {
const context = getProviderContext(documentUri, template, root, token);
await validateAction(diagnostics, token, context.step, config);
}
// Allowed values coming from the schema have already been validated. Only check if
// a value provider is defined for a token and if it is, validate the values match.
if (valueProviderConfig && token.range && validationDefinition) {
if (config?.valueProviderConfig && token.range && validationDefinition) {
const defKey = validationDefinition.key;
if (defKey === "step-with") {
// Action inputs should be validated already in validateAction
continue;
}
// Try a custom value provider first
let valueProvider = valueProviderConfig[defKey];
let valueProvider = config.valueProviderConfig[defKey];
if (!valueProvider) {
// fall back to default
valueProvider = defaultValueProviders[defKey];
@@ -209,8 +214,6 @@ async function validateExpression(
severity: DiagnosticSeverity.Warning,
range: mapRange(expression.range)
});
} else {
throw e;
}
}
}
@@ -3,6 +3,7 @@ import {WorkflowContext} from "../context/workflow-context";
export interface Value {
label: string;
description?: string;
deprecated?: boolean;
}
export enum ValueProviderKind {