Rename folders

This commit is contained in:
Christopher Schleiden
2023-02-22 15:52:40 -08:00
parent 16cc4d9bda
commit 2a3d63551f
469 changed files with 0 additions and 0 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");
});
});
+65
View File
@@ -0,0 +1,65 @@
// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions
export type ActionMetadata = {
inputs?: ActionInputs;
outputs?: ActionOutputs;
};
// 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>;
// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#outputs-for-docker-container-and-javascript-actions
export type ActionOutput = {
description: string;
value?: string;
};
export type ActionOutputs = Record<string, ActionOutput>;
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}`;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
import {CompletionItem, MarkupContent} from "vscode-languageserver-types";
import {complete} from "./complete";
import {getPositionFromCursor} from "./test-utils/cursor-position";
import {testFileProvider} from "./test-utils/test-file-provider";
function mapResult(result: CompletionItem[]) {
return result.map(x => {
return {label: x.label, description: (x.documentation as MarkupContent).value};
});
}
describe("completion with reusable workflows", () => {
it("completes job inputs", async () => {
const input = `
on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
with:
|
`;
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
expect(result).not.toBeUndefined();
expect(mapResult(result)).toEqual([
{
label: "name",
description: "An optional name"
},
{
label: "username",
description: "A username passed from the caller workflow"
}
]);
});
it("filters out existing job inputs", async () => {
const input = `
on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
with:
username: monalisa
|
`;
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
expect(result).not.toBeUndefined();
expect(mapResult(result)).toEqual([
{
label: "name",
description: "An optional name"
}
]);
});
});
+441
View File
@@ -0,0 +1,441 @@
import {MarkupContent, TextEdit} from "vscode-languageserver-types";
import {complete} from "./complete";
import {WorkflowContext} from "./context/workflow-context";
import {registerLogger} from "./log";
import {getPositionFromCursor} from "./test-utils/cursor-position";
import {TestLogger} from "./test-utils/logger";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
registerLogger(new TestLogger());
describe("completion", () => {
it("runs-on", async () => {
const input = "on: push\njobs:\n build:\n runs-on: |";
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(12);
const labels = result.map(x => x.label);
expect(labels).toContain("macos-latest");
});
it("needs", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
build2:
runs-on: ubuntu-latest
needs: bu|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(1);
expect(result[0].label).toEqual("build");
});
it("empty workflow", async () => {
const input = "|";
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(8);
expect(result[0].label).toEqual("concurrency");
});
it("completion within a sequence", async () => {
const input = `on: push
jobs:
build:
runs-on: [ubuntu-latest, u|]`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(11);
const labels = result.map(x => x.label);
expect(labels).toContain("macos-latest");
expect(labels).not.toContain("ubuntu-latest");
});
it("one-of definition completion", async () => {
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(20);
});
it("string definition completion in sequence", async () => {
const input = `on:
release:
types:
- |`;
const result = await complete(...getPositionFromCursor(input));
expect(result.map(x => x.label)).toEqual([
"created",
"deleted",
"edited",
"prereleased",
"published",
"released",
"unpublished"
]);
});
it("string definition completion", async () => {
const input = `on:
release:
types: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result.map(x => x.label)).toEqual([
"created",
"deleted",
"edited",
"prereleased",
"published",
"released",
"unpublished"
]);
});
it("map keys filter out existing values", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.map(x => x.label)).not.toContain("runs-on");
});
it("one-of narrows down to a specific type", async () => {
// A job could be a job-factory or a workflow-job (callable workflow with uses)
// If we have `runs-on`, we should be able to identify that we're in a job-factory
const jobFactory = `on: push
jobs:
build:
runs-on: ubuntu-latest
|`;
const jobFactoryResult = await complete(...getPositionFromCursor(jobFactory));
expect(jobFactoryResult).not.toBeUndefined();
expect(jobFactoryResult.map(x => x.label)).not.toContain("uses");
const workflowJob = `on: push
jobs:
build:
uses: octo-org/this-repo/.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89
|`;
const workflowJobResult = await complete(...getPositionFromCursor(workflowJob));
expect(workflowJobResult).not.toBeUndefined();
expect(workflowJobResult.map(x => x.label)).not.toContain("runs-on");
});
it("completes boolean values", async () => {
const input = `on: push
jobs:
build:
continue-on-error: t|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(2);
expect(result.map(x => x.label).sort()).toEqual(["false", "true"]);
});
it("completes for empty map values", async () => {
const input = `on: push
jobs:
build:
continue-on-error: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(2);
expect(result.map(x => x.label).sort()).toEqual(["false", "true"]);
});
it("does not complete empty map values when cursor is immediately after the position", async () => {
const input = `on: push
jobs:
build:
continue-on-error:|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(0);
});
it("custom value providers override defaults", async () => {
const input = "on: push\njobs:\n build:\n runs-on: |";
const config: ValueProviderConfig = {
"runs-on": {
kind: ValueProviderKind.SuggestedValues,
get: async (_: WorkflowContext) => {
return [{label: "my-custom-label"}];
}
}
};
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
expect(result).not.toBeUndefined();
expect(result.length).toEqual(1);
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), {valueProviderConfig: 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:
build:
container: |
runs-on: ubuntu-latest`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(6);
// Should not contain other top-level job keys like `if` and `runs-on`
expect(result.map(x => x.label)).not.toContain("if");
expect(result.map(x => x.label)).not.toContain("runs-on");
});
it("shows mapping keys within a new map ", async () => {
const input = `on: push
jobs:
build:
concurrency: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.map(x => x.label).sort()).toEqual(["cancel-in-progress", "group"]);
});
it("job key", async () => {
const input = `on: push
jobs:
build:
runs-|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(20);
});
it("job key with comment afterwards", async () => {
const input = `on: push
jobs:
build:
runs-|
#`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(20);
});
it("job key with other values afterwards", async () => {
const input = `on: push
jobs:
build:
runs-|
concurrency: 'group-name'`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(19);
});
it("step key without space after colon", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- env:|
run: echo`;
const result = await complete(...getPositionFromCursor(input));
expect(result).toHaveLength(0);
});
it("empty step", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo
- |`;
const result = await complete(...getPositionFromCursor(input));
expect(result).toHaveLength(11);
expect(result.map(x => x.label)).toEqual([
"continue-on-error",
"env",
"id",
"if",
"name",
"run",
"shell",
"timeout-minutes",
"uses",
"with",
"working-directory"
]);
// Includes detail when available. Using continue-on-error as a sample here.
expect(result.map(x => (x.documentation as MarkupContent)?.value)).toContain(
"Prevents a job from failing when a step fails. Set to `true` to allow a job to pass when this step fails."
);
});
it("loose mapping keys have no completion suggestions", async () => {
const input = `
on:
workflow_dispatch:
inputs:
name:
type: string
description: "hello"
|
`;
const result = await complete(...getPositionFromCursor(input));
expect(result).toHaveLength(0);
});
it("well known mapping keys have descriptions", async () => {
const input = `
o|
`;
const result = await complete(...getPositionFromCursor(input));
const onResult = result.find(x => x.label === "on");
expect(onResult).not.toBeUndefined();
expect((onResult!.documentation as MarkupContent).value).toContain("The GitHub event that triggers the workflow.");
});
it("event list includes descriptions when available ", async () => {
const input = `
on: [check_run, |]`;
const result = await complete(...getPositionFromCursor(input));
const dispatchResult = result.find(x => x.label === "workflow_dispatch");
expect(dispatchResult).not.toBeUndefined();
expect((dispatchResult!.documentation as MarkupContent).value).toContain(
"The `workflow_dispatch` event allows you to manually trigger a workflow run."
);
});
it("sets range when completing token", async () => {
const input = `on: push
jobs:
pre-build:
runs-on: ubuntu-latest
build:
runs-on: ubuntu-latest
needs: pre-bu|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(1);
let textEdit = result[0].textEdit as TextEdit;
expect(textEdit.newText).toEqual("pre-build");
expect(textEdit.range).toEqual({
start: {line: 6, character: 11},
end: {line: 6, character: 17}
});
});
it("sets a range for token key", async () => {
const input = "on: push\njobs:\n build:\n runs-o|";
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.map(e => e.label)).toContain("runs-on");
let textEdit = result.filter(e => e.label === "runs-on")[0].textEdit as TextEdit;
expect(textEdit.newText).toEqual("runs-on");
expect(textEdit.range).toEqual({
start: {line: 3, character: 4},
end: {line: 3, character: 10}
});
});
it("sets a 0-length range while no initial token key", async () => {
const input = "on: push\njobs:\n build:\n |";
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.map(e => e.label)).toContain("runs-on");
let textEdit = result.filter(e => e.label === "runs-on")[0].textEdit as TextEdit;
expect(textEdit.newText).toEqual("runs-on");
expect(textEdit.range).toEqual({
start: {line: 3, character: 4},
end: {line: 3, character: 4}
});
});
describe("completes with indentation", () => {
it("default indentation", async () => {
const input = `on: push
jobs:
build:
step|`;
const result = await complete(...getPositionFromCursor(input));
// Sequence
expect(result.filter(x => x.label === "steps").map(x => x.textEdit?.newText)).toEqual(["steps:\n - "]);
// Mapping
expect(result.filter(x => x.label === "env").map(x => x.textEdit?.newText)).toEqual(["env:\n "]);
// Value
expect(result.filter(x => x.label === "timeout-minutes").map(x => x.textEdit?.newText)).toEqual([
"timeout-minutes: "
]);
// One-of
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency"]);
});
it("custom indentation", async () => {
// Use 3 spaces to indent
const input = `on: push
jobs:
build:
step|`;
const result = await complete(...getPositionFromCursor(input));
// Sequence
expect(result.filter(x => x.label === "steps").map(x => x.textEdit?.newText)).toEqual(["steps:\n - "]);
// Mapping
expect(result.filter(x => x.label === "env").map(x => x.textEdit?.newText)).toEqual(["env:\n "]);
// Value
expect(result.filter(x => x.label === "timeout-minutes").map(x => x.textEdit?.newText)).toEqual([
"timeout-minutes: "
]);
// One-of
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency"]);
});
});
});
+259
View File
@@ -0,0 +1,259 @@
import {complete as completeExpression, DescriptionDictionary} from "@github/actions-expressions";
import {CompletionItem as ExpressionCompletionItem} from "@github/actions-expressions/completion";
import {
convertWorkflowTemplate,
isBasicExpression,
isSequence,
isString,
parseWorkflow
} from "@github/actions-workflow-parser";
import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert";
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 {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider";
import {Position, TextDocument} from "vscode-languageserver-textdocument";
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 {validatorFunctions} from "./expression-validation/functions";
import {error} from "./log";
import {nullTrace} from "./nulltrace";
import {isPotentiallyExpression} from "./utils/expression-detection";
import {findToken} from "./utils/find-token";
import {guessIndentation} from "./utils/indentation-guesser";
import {mapRange} from "./utils/range";
import {getRelCharOffset} from "./utils/rel-char-pos";
import {transform} from "./utils/transform";
import {Value, ValueProviderConfig} from "./value-providers/config";
import {defaultValueProviders} from "./value-providers/default";
import {definitionValues} from "./value-providers/definition";
export function getExpressionInput(input: string, pos: number): string {
// Find start marker around the cursor position
let startPos = input.lastIndexOf(OPEN_EXPRESSION, pos);
if (startPos === -1) {
startPos = 0;
} else {
startPos += OPEN_EXPRESSION.length;
}
return input.substring(startPos, pos);
}
export type CompletionConfig = {
valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig;
fileProvider?: FileProvider;
};
export async function complete(
textDocument: TextDocument,
position: Position,
config?: CompletionConfig
): Promise<CompletionItem[]> {
// Edge case: when completing a key like `foo:|`, do not calculate auto-completions
const charBeforePos = textDocument.getText({
start: {line: position.line, character: position.character - 1},
end: {line: position.line, character: position.character}
});
if (charBeforePos === ":") {
return [];
}
// Fix the input to work around YAML parsing issues
const [newDoc, newPos] = transform(textDocument, position);
const file: File = {
name: textDocument.uri,
content: newDoc.getText()
};
const result = parseWorkflow(file, nullTrace);
if (!result.value) {
return [];
}
const {token, keyToken, parent, path} = findToken(newPos, result.value);
const template = await convertWorkflowTemplate(result.context, result.value, config?.fileProvider, {
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
errorPolicy: ErrorPolicy.TryConversion
});
const workflowContext = getWorkflowContext(textDocument.uri, template, path);
// If we are inside an expression, take a different code-path. The workflow parser does not correctly create
// expression nodes for invalid expressions and during editing expressions are invalid most of the time.
if (token) {
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
const allowedContext = token.definitionInfo?.allowedContext || [];
const context = await getContext(allowedContext, config?.contextProviderConfig, workflowContext, Mode.Completion);
return getExpressionCompletionItems(token, context, newPos);
}
}
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
const indentString = " ".repeat(indentation.tabSize);
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
let replaceRange: Range | undefined;
if (token?.range) {
replaceRange = mapRange(token.range);
} else if (!token) {
// Not a valid token, create a range from the current position
const line = newDoc.getText({start: {line: position.line, character: 0}, end: position});
// Get the length of the current word
const val = line.match(/[\w_-]*$/)?.[0].length || 0;
replaceRange = Range.create({line: position.line, character: position.character - val}, position);
}
return values.map(value => {
const newText = value.insertText || value.label;
const item: CompletionItem = {
label: value.label,
documentation: value.description && {
kind: "markdown",
value: value.description
},
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText)
};
return item;
});
}
async function getValues(
token: TemplateToken | null,
keyToken: TemplateToken | null,
parent: TemplateToken | null,
valueProviderConfig: ValueProviderConfig | undefined,
workflowContext: WorkflowContext,
indentation: string
): Promise<Value[]> {
if (!parent) {
return [];
}
const existingValues = getExistingValues(token, parent);
// 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);
}
}
const defaultValueProvider =
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
if (defaultValueProvider) {
const values = await defaultValueProvider.get(workflowContext);
return filterAndSortCompletionOptions(values, existingValues);
}
// Use the definition if there are no value providers
const def = keyToken?.definition || parent.definition;
if (!def) {
return [];
}
const values = definitionValues(def, indentation);
return filterAndSortCompletionOptions(values, existingValues);
}
export function getExistingValues(token: TemplateToken | null, parent: TemplateToken) {
// For incomplete YAML, we may only have a parent token
if (token) {
if (!isString(token)) {
return;
}
if (isSequence(parent)) {
const sequenceValues = new Set<string>();
for (const t of parent) {
if (isString(t)) {
// Should we support other literal values here?
sequenceValues.add(t.value);
}
}
return sequenceValues;
}
}
if (parent.templateTokenType === TokenType.Mapping) {
// No token and parent is a mapping, so we're completing a key
const mapKeys = new Set<string>();
const mapToken = parent as MappingToken;
for (const {key} of mapToken) {
if (isString(key)) {
mapKeys.add(key.value);
}
}
return mapKeys;
}
}
function getExpressionCompletionItems(
token: TemplateToken,
context: DescriptionDictionary,
pos: Position
): CompletionItem[] {
let currentInput = "";
if (isBasicExpression(token)) {
currentInput = token.source || token.expression;
} else {
const stringToken = token.assertString("Expected string token for expression completion");
currentInput = stringToken.source || stringToken.value;
}
const relCharOffset = getRelCharOffset(token.range!, currentInput, pos);
const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim();
try {
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
mapExpressionCompletionItem(item, currentInput[relCharOffset])
);
} catch (e: any) {
error(`Error while completing expression: '${e?.message || "<no details>"}'`);
return [];
}
}
function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<string>) {
options = options.filter(x => !existingValues?.has(x.label));
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,
documentation: item.description && {
kind: "markdown",
value: item.description
},
insertText: insertText,
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
};
}
@@ -0,0 +1,10 @@
import {DescriptionDictionary} from "@github/actions-expressions";
import {WorkflowContext} from "../context/workflow-context";
export type ContextProviderConfig = {
getContext: (
name: string,
defaultContext: DescriptionDictionary | undefined,
workflowContext: WorkflowContext
) => Promise<DescriptionDictionary | undefined>;
};
@@ -0,0 +1,119 @@
import {data, DescriptionDictionary} from "@github/actions-expressions";
import {Kind} from "@github/actions-expressions/data/expressiondata";
import {WorkflowContext} from "../context/workflow-context";
import {ContextProviderConfig} from "./config";
import {getDescription, RootContext} from "./descriptions";
import {getEnvContext} from "./env";
import {getGithubContext} from "./github";
import {getInputsContext} from "./inputs";
import {getJobContext} from "./job";
import {getMatrixContext} from "./matrix";
import {getNeedsContext} from "./needs";
import {getStepsContext} from "./steps";
import {getStrategyContext} from "./strategy";
// ContextValue is the type of the value returned by a context provider
// Null indicates that the context provider doesn't have any value to provide
export type ContextValue = DescriptionDictionary | data.Null;
export enum Mode {
Completion,
Validation,
Hover
}
export async function getContext(
names: string[],
config: ContextProviderConfig | undefined,
workflowContext: WorkflowContext,
mode: Mode
): Promise<DescriptionDictionary> {
const context = new DescriptionDictionary();
const filteredNames = filterContextNames(names, workflowContext);
for (const contextName of filteredNames) {
let value = getDefaultContext(contextName, workflowContext, mode) || new DescriptionDictionary();
if (value.kind === Kind.Null) {
context.add(contextName, value);
continue;
}
value = (await config?.getContext(contextName, value, workflowContext)) || value;
context.add(contextName, value, getDescription(RootContext, contextName));
}
return context;
}
function getDefaultContext(name: string, workflowContext: WorkflowContext, mode: Mode): ContextValue | undefined {
switch (name) {
case "env":
return getEnvContext(workflowContext);
case "github":
return getGithubContext(workflowContext, mode);
case "inputs":
return getInputsContext(workflowContext);
case "reusableWorkflowJob":
case "job":
return getJobContext(workflowContext);
case "matrix":
return getMatrixContext(workflowContext, mode);
case "needs":
return getNeedsContext(workflowContext);
case "runner":
return objectToDictionary({
os: "Linux",
arch: "X64",
name: "GitHub Actions 2",
tool_cache: "/opt/hostedtoolcache",
temp: "/home/runner/work/_temp"
});
case "secrets":
return new DescriptionDictionary({
key: "GITHUB_TOKEN",
value: new data.StringData("***"),
description: getDescription("secrets", "GITHUB_TOKEN")
});
case "steps":
return getStepsContext(workflowContext);
case "strategy":
return getStrategyContext(workflowContext);
}
return undefined;
}
function objectToDictionary(object: {[key: string]: string}): DescriptionDictionary {
const dictionary = new DescriptionDictionary();
for (const key in object) {
dictionary.add(key, new data.StringData(object[key]));
}
return dictionary;
}
function filterContextNames(contextNames: string[], workflowContext: WorkflowContext): string[] {
return contextNames.filter(name => {
switch (name) {
case "matrix":
case "strategy":
return hasStrategy(workflowContext);
}
return true;
});
}
function hasStrategy(workflowContext: WorkflowContext): boolean {
return workflowContext.job?.strategy !== undefined || workflowContext.reusableWorkflowJob?.strategy !== undefined;
}
@@ -0,0 +1,220 @@
{
"$schema": "./descriptionsSchema.json",
"root": {
"github": {
"description": "Information about the workflow run. For more information, see [`github` context](https://docs.github.com/actions/learn-github-actions/contexts#github-context)."
},
"env": {
"description": "Contains variables set in a workflow, job, or step. For more information, see [`env` context](https://docs.github.com/actions/learn-github-actions/contexts#env-context)."
},
"vars": {
"description": "Contains variables set at the repository, organization, or environment levels. For more information, see [`vars` context](https://docs.github.com/actions/learn-github-actions/contexts#vars-context)."
},
"job": {
"description": "Information about the currently running job. For more information, see [`job` context](https://docs.github.com/actions/learn-github-actions/contexts#job-context)."
},
"jobs": {
"description": "For reusable workflows only, contains outputs of jobs from the reusable workflow. For more information, see [`jobs` context](https://docs.github.com/actions/learn-github-actions/contexts#jobs-context)."
},
"steps": {
"description": "Information about the steps that have been run in the current job. For more information, see [`steps` context](https://docs.github.com/actions/learn-github-actions/contexts#steps-context)."
},
"runner": {
"description": "Information about the runner that is running the current job. For more information, see [`runner` context](https://docs.github.com/actions/learn-github-actions/contexts#runner-context)."
},
"secrets": {
"description": "Contains the names and values of secrets that are available to a workflow run. For more information, see [`secrets` context](https://docs.github.com/actions/learn-github-actions/contexts#secrets-context)."
},
"strategy": {
"description": "Information about the matrix execution strategy for the current job. For more information, see [`strategy` context](https://docs.github.com/actions/learn-github-actions/contexts#strategy-context)."
},
"matrix": {
"description": "Contains the matrix properties defined in the workflow that apply to the current job. For more information, see [`matrix` context](https://docs.github.com/actions/learn-github-actions/contexts#matrix-context)."
},
"needs": {
"description": "Contains the outputs of all jobs that are defined as a dependency of the current job. For more information, see [`needs` context](https://docs.github.com/actions/learn-github-actions/contexts#needs-context)."
},
"inputs": {
"description": "Contains the inputs of a reusable or manually triggered workflow. For more information, see [`inputs` context](https://docs.github.com/actions/learn-github-actions/contexts#inputs-context)."
}
},
"github": {
"action": {
"description": "The name of the action currently running, or the [`id`](https://docs.github.com/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idstepsid) of a step. GitHub Actions removes special characters, and uses the name `__run` when the current step runs a script without an `id`. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name `__run`, and the second script will be named `__run_2`. Similarly, the second invocation of `actions/checkout` will be `actionscheckout2`."
},
"action_path": {
"description": "The path where an action is located. This property is only supported in composite actions. You can use this path to access files located in the same repository as the action."
},
"action_ref": {
"description": "For a step executing an action, this is the ref of the action being executed. For example, `v2`."
},
"action_repository": {
"description": "For a step executing an action, this is the owner and repository name of the action. For example, `actions/checkout`."
},
"action_status": {
"description": "For a composite action, the current result of the composite action."
},
"actor": {
"description": "The username of the user that triggered the initial workflow run. If the workflow run is a re-run, this value may differ from `github.triggering_actor`. Any workflow re-runs will use the privileges of `github.actor`, even if the actor initiating the re-run (`github.triggering_actor`) has different privileges."
},
"api_url": {
"description": "The URL of the GitHub Actions REST API."
},
"base_ref": {
"description": "The `base_ref` or target branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either `pull_request` or `pull_request_target`."
},
"env": {
"description": "Path on the runner to the file that sets environment variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see [Workflow commands](https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#setting-an-environment-variable)."
},
"event": {
"description": "The full event webhook payload. You can access individual properties of the event using this context. This object is identical to the webhook payload of the event that triggered the workflow run, and is different for each event. The webhooks for each GitHub Actions event is linked in [Event that trigger workflows](/articles/events-that-trigger-workflows/). For example, for a workflow run triggered by the [`push` event](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows#push), this object contains the contents of the [push webhook payload](https://docs.github.com/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#push)."
},
"event_name": {
"description": "The name of the event that triggered the workflow run."
},
"event_path": {
"description": "The path to the file on the runner that contains the full event webhook payload."
},
"graphql_url": {
"description": "The URL of the GitHub Actions GraphQL API."
},
"head_ref": {
"description": "The `head_ref` or source branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either `pull_request` or `pull_request_target`."
},
"job": {
"description": "The [`job_id`](/actions/reference/workflow-syntax-for-github-actions#jobsjob_id) of the current job. <br /> Note: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
},
"ref": {
"description": "The fully-formed ref of the branch or tag that triggered the workflow run. For workflows triggered by `push`, this is the branch or tag ref that was pushed. For workflows triggered by `pull_request`, this is the pull request merge branch. For workflows triggered by `release`, this is the release tag created. For other triggers, this is the branch or tag ref that triggered the workflow run. This is only set if a branch or tag is available for the event type. The ref given is fully-formed, meaning that for branches the format is `refs/heads/<branch_name>`, for pull requests it is `refs/pull/<pr_number>/merge`, and for tags it is `refs/tags/<tag_name>`. For example, `refs/heads/feature-branch-1`.",
"versions": {
"ghes": "3.3",
"ghae": "3.3"
}
},
"ref_name": {
"description": "The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown on GitHub. For example, `feature-branch-1`.",
"versions": {
"ghes": "3.3",
"ghae": "3.3"
}
},
"ref_protected": {
"description": "`true` if branch protections are configured for the ref that triggered the workflow run.",
"versions": {
"ghes": "3.3",
"ghae": "3.3"
}
},
"ref_type": {
"description": "The type of ref that triggered the workflow run. Valid values are `branch` or `tag`.",
"versions": {
"ghes": "3.3",
"ghae": "3.3"
}
},
"path": {
"description": "Path on the runner to the file that sets system `PATH` variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see [Workflow commands](https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#adding-a-system-path)."
},
"repository": {
"description": "The owner and repository name. For example, `Codertocat/Hello-World`."
},
"repository_owner": {
"description": "The repository owner's name. For example, `Codertocat`."
},
"repositoryUrl": {
"description": "The Git URL to the repository. For example, `git://github.com/codertocat/hello-world.git`."
},
"retention_days": {
"description": "The number of days that workflow run logs and artifacts are kept."
},
"run_id": {
"description": "A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run."
},
"run_number": {
"description": "A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run."
},
"run_attempt": {
"description": "A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run.",
"versions": {
"ghes": "3.5",
"ghae": "3.4"
}
},
"secret_source": {
"description": "The source of a secret used in a workflow. Possible values are `None`, `Actions`, `Dependabot`, or `Codespaces`.",
"versions": {
"ghes": "3.3",
"ghae": "3.3"
}
},
"server_url": {
"description": "The URL of the GitHub server. For example: `https://github.com`."
},
"sha": {
"description": "The commit SHA that triggered the workflow. The value of this commit SHA depends on the event that triggered the workflow. For more information, see [Events that trigger workflows.](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows) For example, `ffac537e6cbbf934b08745a378932722df287a53`."
},
"token": {
"description": "A token to authenticate on behalf of the GitHub App installed on your repository. This is functionally equivalent to the `GITHUB_TOKEN` secret. For more information, see [Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication).\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
},
"triggering_actor": {
"description": "The username of the user that initiated the workflow run. If the workflow run is a re-run, this value may differ from `github.actor`. Any workflow re-runs will use the privileges of `github.actor`, even if the actor initiating the re-run (`github.triggering_actor`) has different privileges."
},
"workflow": {
"description": "The name of the workflow. If the workflow file doesn't specify a `name`, the value of this property is the full path of the workflow file in the repository."
},
"workspace": {
"description": "The default working directory on the runner for steps, and the default location of your repository when using the [`checkout`](https://github.com/actions/checkout) action."
}
},
"secrets": {
"GITHUB_TOKEN": {
"description": "`GITHUB_TOKEN` is a secret that is automatically created for every workflow run, and is always included in the secrets context. For more information, see [Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication)."
}
},
"steps": {
"outputs": {
"description": "The set of outputs defined for the step."
},
"conclusion": {
"description": "The result of a completed step after `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
},
"outcome": {
"description": "The result of a completed step before `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
}
},
"runner": {
"name": {
"description": "The name of the runner executing the job."
},
"os": {
"description": "The operating system of the runner executing the job. Possible values are `Linux`, `Windows`, or `macOS`."
},
"arch": {
"description": "The architecture of the runner executing the job. Possible values are `X86`, `X64`, `ARM`, or `ARM64`."
},
"temp": {
"description": "The path to a temporary directory on the runner. This directory is emptied at the beginning and end of each job. Note that files will not be removed if the runner's user account does not have permission to delete them."
},
"tool_cache": {
"description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software)\"."
},
"debug": {
"description": "This is set only if [debug logging](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of 1. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
}
},
"strategy": {
"fail-fast": {
"description": "The `fail-fast` setting for the job. Possible values are `true` or `false`. For more information, see [Workflow syntax for GitHub Actions: `jobs.<job_id>.strategy.fail-fast`](https://docs.github.com/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategyfail-fast)."
},
"max-parallel": {
"description": "The `max-parallel` setting for the job. For more information, see [Workflow syntax for GitHub Actions: `jobs.<job_id>.strategy.max-parallel`](https://docs.github.com/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategymax-parallel)."
},
"job-index": {
"description": "The index of the current job in the matrix. **Note:** This number is a zero-based number. The first job's index in the matrix is `0`."
},
"job-total": {
"description": "The total number of jobs in the matrix. **Note:** This number **is not** a zero-based number. For example, for a matrix with four jobs, the value of `job-total` is `4`."
}
}
}
@@ -0,0 +1,14 @@
import descriptions from "./descriptions.json" assert {type: "json"};
export const RootContext = "root";
/**
* Get a description for a built-in context
* @param context Name of the context, for example `github`
* @param key Key of the context, for example `actor`
* @returns Description if one is found, otherwise undefined
*/
export function getDescription(context: string, key: string): string | undefined {
// The inferred type doesn't quite match the actual type, use any to work around that
return (descriptions as any)[context]?.[key]?.description;
}
@@ -0,0 +1,30 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"properties": {
"$schema": {
"type": "string",
"$comment": "Ignore this, just to make VS Code happy"
}
},
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"versions": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"description"
]
}
}
}
@@ -0,0 +1,37 @@
import {data, DescriptionDictionary} from "@github/actions-expressions";
import {isScalar, isString} from "@github/actions-workflow-parser";
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
import {WorkflowContext} from "../context/workflow-context";
export function getEnvContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary();
//step env
if (workflowContext.step?.env) {
envContext(workflowContext.step.env, d);
}
//job env
if (workflowContext.job && workflowContext.job.env) {
envContext(workflowContext.job.env, d);
}
//workflow env
if (workflowContext.template && workflowContext.template.env) {
const wfEnv = workflowContext.template.env.assertMapping("workflow env");
envContext(wfEnv, d);
}
return d;
}
function envContext(envMap: MappingToken, d: data.Dictionary) {
for (const env of envMap) {
if (!isString(env.key)) {
continue;
}
const value = isScalar(env.value) ? new data.StringData(env.value.toDisplayString()) : new data.Null();
d.add(env.key.value, value);
}
}
@@ -0,0 +1,116 @@
import {data, DescriptionDictionary} from "@github/actions-expressions";
import webhooks from "./webhooks.json";
import schedule from "./schedule.json" assert {type: "json"};
import workflow_call from "./workflow_call.json" assert {type: "json"};
const customEventPayloads: {[name: string]: unknown} = {
schedule,
workflow_call
};
type ParamType =
| "array of objects or null"
| "array of objects"
| "array of strings or null"
| "array of strings"
| "array"
| "boolean or null"
| "boolean or string or integer or object"
| "boolean"
| "integer or null"
| "integer or string or null"
| "integer or string"
| "integer"
| "null"
| "number"
| "object or null"
| "object or object or object or object"
| "object or object"
| "object or string"
| "object"
| "string or null"
| "string or number"
| "string or object or integer or null"
| "string or object or null"
| "string or object"
| "string";
type Param = {
type: ParamType;
name: string;
in: "body";
isRequired: boolean;
description: string;
childParamsGroups?: Param[];
enum?: string[];
};
type Webhooks = {
[name: string]: {
[action: string]: {
descriptionHtml: string;
summaryHtml: string;
bodyParameters: Param[];
};
};
};
const webhookPayloads: Webhooks = webhooks as any;
//
// Manual work-arounds for webhook issues
//
const inputs = webhookPayloads?.["workflow_dispatch"]?.["default"].bodyParameters.find(p => p.name === "inputs");
if (inputs) {
delete inputs.childParamsGroups;
}
export function getEventPayload(event: string, action: string = "default"): DescriptionDictionary | undefined {
const payload = webhookPayloads?.[event]?.[action];
if (!payload) {
// Not all events are real webhooks. Check if there is a custom payload for this event
const customPayload = customEventPayloads[event];
if (customPayload) {
return mergeObject(new DescriptionDictionary(), customPayload);
}
return undefined;
}
const d = new DescriptionDictionary();
payload.bodyParameters.forEach(p => mergeParam(d, p));
return d;
}
function mergeParam(target: DescriptionDictionary, param: Param) {
if (param.childParamsGroups?.length || 0 > 0) {
// If there are any child params, add this param as an object
const d = new DescriptionDictionary();
param.childParamsGroups?.forEach(p => mergeParam(d, p));
target.add(param.name, d, param.description);
} else {
// Otherwise add as a null value. We do not care about the actual content for validation
// auto-completion. Possible existence and the description are enough.
target.add(param.name, new data.Null(), param.description);
}
}
function mergeObject(d: DescriptionDictionary, toAdd: Object): DescriptionDictionary {
for (const [key, value] of Object.entries(toAdd)) {
if (value && typeof value === "object" && !d.get(key)) {
if (!Array.isArray(value) && Object.entries(value).length === 0) {
// Allow an empty object to be any value
d.add(key, new data.Null());
continue;
}
d.add(key, mergeObject(new DescriptionDictionary(), value));
} else {
d.add(key, new data.Null());
}
}
return d;
}
@@ -0,0 +1,102 @@
{
"repository": {
"id": 186853002,
"node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=",
"name": "Hello-World",
"full_name": "Codertocat/Hello-World",
"private": false,
"owner": {
"name": "Codertocat",
"email": "[email protected]",
"login": "Codertocat",
"id": 21031067,
"node_id": "MDQ6VXNlcjIxMDMxMDY3",
"avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/Codertocat",
"html_url": "https://github.com/Codertocat",
"followers_url": "https://api.github.com/users/Codertocat/followers",
"following_url": "https://api.github.com/users/Codertocat/following{/other_user}",
"gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}",
"starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions",
"organizations_url": "https://api.github.com/users/Codertocat/orgs",
"repos_url": "https://api.github.com/users/Codertocat/repos",
"events_url": "https://api.github.com/users/Codertocat/events{/privacy}",
"received_events_url": "https://api.github.com/users/Codertocat/received_events",
"type": "User",
"site_admin": false
},
"html_url": "https://github.com/Codertocat/Hello-World",
"description": null,
"fork": false,
"url": "https://github.com/Codertocat/Hello-World",
"forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks",
"keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams",
"hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks",
"issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}",
"events_url": "https://api.github.com/repos/Codertocat/Hello-World/events",
"assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}",
"branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}",
"tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags",
"blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}",
"languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages",
"stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers",
"contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors",
"subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers",
"subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription",
"commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}",
"compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges",
"archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads",
"issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}",
"pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}",
"milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}",
"notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}",
"releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}",
"deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments",
"created_at": 1557933565,
"updated_at": "2019-05-15T15:20:41Z",
"pushed_at": 1557933657,
"git_url": "git://github.com/Codertocat/Hello-World.git",
"ssh_url": "[email protected]:Codertocat/Hello-World.git",
"clone_url": "https://github.com/Codertocat/Hello-World.git",
"svn_url": "https://github.com/Codertocat/Hello-World",
"homepage": null,
"size": 0,
"stargazers_count": 0,
"watchers_count": 0,
"language": "Ruby",
"has_issues": true,
"has_projects": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": true,
"forks_count": 1,
"mirror_url": null,
"archived": false,
"disabled": false,
"open_issues_count": 2,
"license": null,
"forks": 1,
"open_issues": 2,
"watchers": 0,
"default_branch": "master",
"stargazers": 0,
"master_branch": "master"
},
"schedule": "* * * * *",
"workflow": ".github/workflows/hello-world-workflow.yml"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,134 @@
{
"action": "on-demand-test",
"repository": {
"id": 17273051,
"node_id": "MDEwOlJlcG9zaXRvcnkxNzI3MzA1MQ==",
"name": "octo-repo",
"full_name": "octo-org/octo-repo",
"private": true,
"owner": {
"login": "octo-org",
"id": 6811672,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjY4MTE2NzI=",
"avatar_url": "https://avatars3.githubusercontent.com/u/6811672?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/octo-org",
"html_url": "https://github.com/octo-org",
"followers_url": "https://api.github.com/users/octo-org/followers",
"following_url": "https://api.github.com/users/octo-org/following{/other_user}",
"gists_url": "https://api.github.com/users/octo-org/gists{/gist_id}",
"starred_url": "https://api.github.com/users/octo-org/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/octo-org/subscriptions",
"organizations_url": "https://api.github.com/users/octo-org/orgs",
"repos_url": "https://api.github.com/users/octo-org/repos",
"events_url": "https://api.github.com/users/octo-org/events{/privacy}",
"received_events_url": "https://api.github.com/users/octo-org/received_events",
"type": "Organization",
"site_admin": false
},
"html_url": "https://github.com/octo-org/octo-repo",
"description": "My first repo on GitHub!",
"fork": false,
"url": "https://api.github.com/repos/octo-org/octo-repo",
"forks_url": "https://api.github.com/repos/octo-org/octo-repo/forks",
"keys_url": "https://api.github.com/repos/octo-org/octo-repo/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/octo-org/octo-repo/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/octo-org/octo-repo/teams",
"hooks_url": "https://api.github.com/repos/octo-org/octo-repo/hooks",
"issue_events_url": "https://api.github.com/repos/octo-org/octo-repo/issues/events{/number}",
"events_url": "https://api.github.com/repos/octo-org/octo-repo/events",
"assignees_url": "https://api.github.com/repos/octo-org/octo-repo/assignees{/user}",
"branches_url": "https://api.github.com/repos/octo-org/octo-repo/branches{/branch}",
"tags_url": "https://api.github.com/repos/octo-org/octo-repo/tags",
"blobs_url": "https://api.github.com/repos/octo-org/octo-repo/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/octo-org/octo-repo/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/octo-org/octo-repo/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/octo-org/octo-repo/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/octo-org/octo-repo/statuses/{sha}",
"languages_url": "https://api.github.com/repos/octo-org/octo-repo/languages",
"stargazers_url": "https://api.github.com/repos/octo-org/octo-repo/stargazers",
"contributors_url": "https://api.github.com/repos/octo-org/octo-repo/contributors",
"subscribers_url": "https://api.github.com/repos/octo-org/octo-repo/subscribers",
"subscription_url": "https://api.github.com/repos/octo-org/octo-repo/subscription",
"commits_url": "https://api.github.com/repos/octo-org/octo-repo/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/octo-org/octo-repo/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/octo-org/octo-repo/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/octo-org/octo-repo/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/octo-org/octo-repo/contents/{+path}",
"compare_url": "https://api.github.com/repos/octo-org/octo-repo/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/octo-org/octo-repo/merges",
"archive_url": "https://api.github.com/repos/octo-org/octo-repo/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/octo-org/octo-repo/downloads",
"issues_url": "https://api.github.com/repos/octo-org/octo-repo/issues{/number}",
"pulls_url": "https://api.github.com/repos/octo-org/octo-repo/pulls{/number}",
"milestones_url": "https://api.github.com/repos/octo-org/octo-repo/milestones{/number}",
"notifications_url": "https://api.github.com/repos/octo-org/octo-repo/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/octo-org/octo-repo/labels{/name}",
"releases_url": "https://api.github.com/repos/octo-org/octo-repo/releases{/id}",
"deployments_url": "https://api.github.com/repos/octo-org/octo-repo/deployments",
"created_at": "2014-02-28T02:42:51Z",
"updated_at": "2018-10-10T15:58:51Z",
"pushed_at": "2018-10-10T15:58:47Z",
"git_url": "git://github.com/octo-org/octo-repo.git",
"ssh_url": "[email protected]:octo-org/octo-repo.git",
"clone_url": "https://github.com/octo-org/octo-repo.git",
"svn_url": "https://github.com/octo-org/octo-repo",
"homepage": "",
"size": 59,
"stargazers_count": 0,
"watchers_count": 0,
"language": "JavaScript",
"has_issues": true,
"has_projects": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": false,
"forks_count": 1,
"mirror_url": null,
"archived": false,
"open_issues_count": 23,
"license": null,
"forks": 1,
"open_issues": 23,
"watchers": 0,
"default_branch": "master"
},
"organization": {
"login": "octo-org",
"id": 6811672,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjY4MTE2NzI=",
"url": "https://api.github.com/orgs/octo-org",
"repos_url": "https://api.github.com/orgs/octo-org/repos",
"events_url": "https://api.github.com/orgs/octo-org/events",
"hooks_url": "https://api.github.com/orgs/octo-org/hooks",
"issues_url": "https://api.github.com/orgs/octo-org/issues",
"members_url": "https://api.github.com/orgs/octo-org/members{/member}",
"public_members_url": "https://api.github.com/orgs/octo-org/public_members{/member}",
"avatar_url": "https://avatars3.githubusercontent.com/u/6811672?v=4",
"description": "Working better together!"
},
"sender": {
"login": "Codertocat",
"id": 21031067,
"node_id": "MDQ6VXNlcjIxMDMxMDY3",
"avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/Codertocat",
"html_url": "https://github.com/Codertocat",
"followers_url": "https://api.github.com/users/Codertocat/followers",
"following_url": "https://api.github.com/users/Codertocat/following{/other_user}",
"gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}",
"starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions",
"organizations_url": "https://api.github.com/users/Codertocat/orgs",
"repos_url": "https://api.github.com/users/Codertocat/repos",
"events_url": "https://api.github.com/users/Codertocat/events{/privacy}",
"received_events_url": "https://api.github.com/users/Codertocat/received_events",
"type": "User",
"site_admin": false
},
"installation": {
"id": 375706,
"node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uMzc1NzA2"
}
}
@@ -0,0 +1,126 @@
import {DescriptionDictionary, isDescriptionDictionary} from "@github/actions-expressions/.";
import {testGetWorkflowContext} from "../test-utils/test-workflow-context";
import {Mode} from "./default";
import {getGithubContext} from "./github";
describe("github context", () => {
it("single event", async () => {
const workflowContext = await testGetWorkflowContext(`on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: ec|ho`);
const g = getGithubContext(workflowContext, Mode.Completion);
if (!isDescriptionDictionary(g)) {
fail();
}
const e = g.get("event") as DescriptionDictionary;
expect(e.pairs().map(p => p.key)).toEqual([
"after",
"base_ref",
"before",
"commits",
"compare",
"created",
"deleted",
"enterprise",
"forced",
"head_commit",
"installation",
"organization",
"pusher",
"ref",
"repository",
"sender"
]);
});
it("single event - multiple types", async () => {
const workflowContext = await testGetWorkflowContext(`
on:
pull_request:
types:
- 'synchronize'
- 'ready_for_review'
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: ec|ho`);
const g = getGithubContext(workflowContext, Mode.Completion);
if (!isDescriptionDictionary(g)) {
fail();
}
const e = g.get("event") as DescriptionDictionary;
expect(e.pairs().map(p => p.key)).toEqual([
"action",
"after",
"before",
"enterprise",
"installation",
"number",
"organization",
"pull_request",
"repository",
"sender"
]);
});
it("multiple events - multiple types", async () => {
const workflowContext = await testGetWorkflowContext(`
on:
push:
pull_request:
types:
- 'synchronize'
- 'ready_for_review'
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: ec|ho`);
const g = getGithubContext(workflowContext, Mode.Completion);
if (!isDescriptionDictionary(g)) {
fail();
}
const e = g.get("event") as DescriptionDictionary;
expect(
e
.pairs()
.map(p => p.key)
.sort()
).toEqual([
"action",
"after",
"base_ref",
"before",
"commits",
"compare",
"created",
"deleted",
"enterprise",
"forced",
"head_commit",
"installation",
"number",
"organization",
"pull_request",
"pusher",
"ref",
"repository",
"sender"
]);
});
});
@@ -0,0 +1,167 @@
import {data, DescriptionDictionary, isDescriptionDictionary} from "@github/actions-expressions";
import {ExpressionData} from "@github/actions-expressions/data/expressiondata";
import {TypesFilterConfig} from "@github/actions-workflow-parser/model/workflow-template";
import {WorkflowContext} from "../context/workflow-context";
import {Mode} from "./default";
import {getDescription} from "./descriptions";
import {getEventPayload} from "./events/eventPayloads";
import {getInputsContext} from "./inputs";
export function getGithubContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-cwontext
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 DescriptionDictionary(
...keys.map(key => {
const description = getDescription("github", key);
if (key == "event") {
return {
key,
value: getEventContext(workflowContext, mode),
description
};
}
return {
key,
value: new data.Null(),
description
};
})
);
}
function getEventContext(workflowContext: WorkflowContext, mode: Mode): ExpressionData {
const d = new DescriptionDictionary();
const eventsConfig = workflowContext?.template?.events;
if (!eventsConfig) {
return d;
}
// For callable workflows, the event is inherited from the calling workflow
// Allow any value for this case
// This includes github.event.inputs, which is only available via the inputs context
if (eventsConfig.workflow_call && mode == Mode.Validation) {
return new data.Null();
}
const inputs = getInputsContext(workflowContext);
if (inputs.values().length > 0) {
d.add("inputs", inputs);
}
const schedule = eventsConfig["schedule"];
if (schedule && schedule.length > 0) {
const default_cron = schedule[0].cron;
// For now, default to the first cron expression only
d.add("schedule", new data.StringData(default_cron));
}
const events = Object.keys(eventsConfig);
for (const eventName of events) {
const event = eventsConfig[eventName] as TypesFilterConfig;
const types = getTypes(eventName, event.types);
for (const type of types) {
const payloadEventName = getPayloadEventName(eventName);
const eventPayload = getEventPayload(payloadEventName, type);
if (!eventPayload) {
continue;
}
// Merge the event payload into the event context
merge(d, eventPayload);
}
}
return d;
}
function getPayloadEventName(eventName: string): string {
switch (eventName) {
// Some events are aliases for other webhooks
case "pull_request_target":
return "pull_request";
default:
return eventName;
}
}
function getTypes(event: string, types: string[] | undefined): string[] {
const typesOrDefault = (types: string[] | undefined, defaultTypes: string[]): string[] =>
!types || types.length === 0 ? defaultTypes : types;
switch (event) {
case "merge_group":
return typesOrDefault(types, ["checks_requested"]);
case "pull_request":
case "pull_request_target":
return typesOrDefault(types, ["opened", "reopened", "synchronize"]);
case "repository_dispatch":
// Types can be used for custom filtering for repository_dispatch events. Always use default
return ["default"];
default:
return typesOrDefault(types, ["default"]);
}
}
function merge(target: DescriptionDictionary, toMerge: DescriptionDictionary): DescriptionDictionary {
for (const p of toMerge.pairs()) {
if (isDescriptionDictionary(p.value)) {
const existingValue = target.get(p.key);
if (existingValue && isDescriptionDictionary(existingValue)) {
// Merge the dictionaries, do not overwrite existing values
merge(existingValue, p.value);
continue;
}
}
target.add(p.key, p.value, p.description);
}
return target;
}
@@ -0,0 +1,60 @@
import {data, DescriptionDictionary} from "@github/actions-expressions";
import {InputConfig} from "@github/actions-workflow-parser/model/workflow-template";
import {WorkflowContext} from "../context/workflow-context";
export function getInputsContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary();
const events = workflowContext?.template?.events;
if (!events) {
return d;
}
const dispatch = events["workflow_dispatch"];
if (dispatch?.inputs) {
addInputs(d, dispatch.inputs);
}
const call = events["workflow_call"];
if (call?.inputs) {
addInputs(d, call.inputs);
}
return d;
}
function addInputs(d: DescriptionDictionary, inputs: {[inputName: string]: InputConfig}) {
for (const inputName of Object.keys(inputs)) {
const input = inputs[inputName];
switch (input.type) {
case "choice":
if (input.default) {
d.add(inputName, new data.StringData(input.default as string), input.description);
} else {
// Default to the first input or an empty string
d.add(inputName, new data.StringData((input.options || [""])[0]), input.description);
}
break;
case "environment":
if (input.default) {
d.add(inputName, new data.StringData(input.default as string), input.description);
} else {
// For now default to an empty value if there is no default value. This will always be an environment, so
// we could also dynamically look up environments and default to the first one, but leaving this as a
// future enhancement for now.
d.add(inputName, new data.StringData(""));
}
break;
case "boolean":
d.add(inputName, new data.BooleanData((input.default as boolean) || false), input.description);
break;
case "string":
default:
d.add(inputName, new data.StringData((input.default as string) || inputName), input.description);
break;
}
}
}
@@ -0,0 +1,67 @@
import {data, DescriptionDictionary} from "@github/actions-expressions";
import {isMapping, isSequence} from "@github/actions-workflow-parser";
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
import {WorkflowContext} from "../context/workflow-context";
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
const jobContext = new DescriptionDictionary();
const job = workflowContext.job;
if (!job) {
return jobContext;
}
// Container
const jobContainer = job.container;
if (jobContainer && isMapping(jobContainer)) {
const containerContext = createContainerContext(jobContainer, false);
jobContext.add("container", containerContext);
}
// Services
const jobServices = job.services;
if (jobServices && isMapping(jobServices)) {
const servicesContext = new DescriptionDictionary();
for (const service of jobServices) {
if (!isMapping(service.value)) {
continue;
}
const serviceContext = createContainerContext(service.value, true);
servicesContext.add(service.key.toString(), serviceContext);
}
jobContext.add("services", servicesContext);
}
// Status
jobContext.add("status", new data.Null());
return jobContext;
}
function createContainerContext(container: MappingToken, isServices: boolean): data.Dictionary {
const containerContext = new data.Dictionary();
for (const {key, value} of container) {
if (isSequence(value)) {
// service ports are the only thing that is part of the job context
if (key.toString() !== "ports") {
continue;
}
const ports = new data.Dictionary();
for (const item of value) {
// We can determine the context mapping fully only if the port is defined
// as a mapping (i.e. <port1>:<port2>), single ports are assigned randomly
const portParts = item.toString().split(":");
if (isServices && portParts.length === 2) {
ports.add(portParts[1], new data.StringData(portParts[0]));
} else {
// If the port isn't a mapping, just use null
ports.add(portParts[0], new data.Null());
}
}
containerContext.add(key.toString(), ports);
}
}
containerContext.add("id", new data.Null());
containerContext.add("network", new data.Null());
return containerContext;
}
@@ -0,0 +1,345 @@
import {data, DescriptionDictionary} from "@github/actions-expressions";
import {Job} from "@github/actions-workflow-parser/model/workflow-template";
import {BasicExpressionToken} from "@github/actions-workflow-parser/templates/tokens/basic-expression-token";
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token";
import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
import {WorkflowContext} from "../context/workflow-context";
import {Mode} from "./default";
import {getMatrixContext} from "./matrix";
type MatrixMap = {
[key: string]: Array<string> | Array<{[key: string]: string}>;
};
function createMatrix(map: MatrixMap): WorkflowContext {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), mapToToken(map));
return contextFromStrategy(strategy);
}
function mapToToken(map: MatrixMap) {
const token = new MappingToken(undefined, undefined, undefined);
for (const key in map) {
const arr = map[key];
const seqToken = new SequenceToken(undefined, undefined, undefined);
for (const item of arr) {
if (typeof item === "string") {
seqToken.add(new StringToken(undefined, undefined, item, undefined));
} else {
const mapToken = new MappingToken(undefined, undefined, undefined);
for (const key in item) {
mapToken.add(stringToToken(key), stringToToken(item[key]));
}
seqToken.add(mapToken);
}
}
token.add(stringToToken(key), seqToken);
}
return token;
}
function stringToToken(value: string) {
return new StringToken(undefined, undefined, value, undefined);
}
function expressionToToken(expr: string) {
return new BasicExpressionToken(undefined, undefined, expr, undefined, undefined, undefined);
}
function contextFromStrategy(strategy?: TemplateToken) {
return {
job: {
strategy: strategy
}
} as WorkflowContext;
}
describe("matrix context", () => {
describe("invalid workflow context", () => {
it("job not defined", () => {
const workflowContext = {} as WorkflowContext;
expect(workflowContext.job).toBeUndefined();
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new DescriptionDictionary());
});
it("strategy not defined", () => {
const job = {} as Job;
const workflowContext = {job} as WorkflowContext;
expect(workflowContext.job!.strategy).toBeUndefined();
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new DescriptionDictionary());
});
it("strategy is not a mapping token", () => {
const workflowContext = contextFromStrategy(stringToToken("hello"));
expect(workflowContext.job!.strategy).toBeDefined();
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new DescriptionDictionary());
});
it("matrix is not defined", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
const workflowContext = contextFromStrategy(strategy);
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new data.Null());
});
it("matrix is not a mapping token", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), stringToToken("hello"));
const workflowContext = contextFromStrategy(strategy);
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new data.Null());
});
it("empty matrix", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), new MappingToken(undefined, undefined, undefined));
const workflowContext = contextFromStrategy(strategy);
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new DescriptionDictionary());
});
});
describe("matrix with expressions", () => {
it("matrix from expression", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), expressionToToken("${{ fromJSON(needs.job1.outputs.matrix) }}"));
const workflowContext = contextFromStrategy(strategy);
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new data.Null());
});
it("matrix with include expression", () => {
const include = expressionToToken("${{ fromJSON(needs.job1.outputs.matrix) }}");
const nodeSequence = new SequenceToken(undefined, undefined, undefined);
nodeSequence.add(stringToToken("12"));
nodeSequence.add(stringToToken("14"));
const matrix = new MappingToken(undefined, undefined, undefined);
matrix.add(stringToToken("node"), nodeSequence);
matrix.add(stringToToken("include"), include);
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), matrix);
const workflowContext = contextFromStrategy(strategy);
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new data.Null());
});
it("matrix with include expression during completion", () => {
const include = expressionToToken("${{ fromJSON(needs.job1.outputs.matrix) }}");
const nodeSequence = new SequenceToken(undefined, undefined, undefined);
nodeSequence.add(stringToToken("12"));
nodeSequence.add(stringToToken("14"));
const matrix = new MappingToken(undefined, undefined, undefined);
matrix.add(stringToToken("node"), nodeSequence);
matrix.add(stringToToken("include"), include);
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), matrix);
const workflowContext = contextFromStrategy(strategy);
const context = getMatrixContext(workflowContext, Mode.Completion);
expect(context).toEqual(
new DescriptionDictionary({
key: "node",
value: new data.Array(new data.StringData("12"), new data.StringData("14"))
})
);
});
it("matrix with expression within property", () => {
const version = expressionToToken("${{ github.event.client_payload.versions }}");
const matrix = new MappingToken(undefined, undefined, undefined);
matrix.add(stringToToken("version"), version);
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), matrix);
const workflowContext = contextFromStrategy(strategy);
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(
new DescriptionDictionary({
key: "version",
value: new data.Null()
})
);
});
});
describe("valid matrices", () => {
it("basic matrix", () => {
const workflowContext = createMatrix({os: ["ubuntu-latest", "windows-latest"]});
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(
new DescriptionDictionary({
key: "os",
value: new data.Array(new data.StringData("ubuntu-latest"), new data.StringData("windows-latest"))
})
);
});
it("matrix with multiple properties", () => {
const workflowContext = createMatrix({
os: ["ubuntu-latest", "windows-latest"],
node: ["12", "14"]
});
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(
new DescriptionDictionary(
{
key: "os",
value: new data.Array(new data.StringData("ubuntu-latest"), new data.StringData("windows-latest"))
},
{
key: "node",
value: new data.Array(new data.StringData("12"), new data.StringData("14"))
}
)
);
});
it("matrix with include", () => {
const workflowContext = createMatrix({
os: ["ubuntu-latest", "windows-latest"],
node: ["12", "14"],
include: [
{
os: "macos-latest",
node: "12"
}
]
});
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(
new DescriptionDictionary(
{
key: "os",
value: new data.Array(
new data.StringData("ubuntu-latest"),
new data.StringData("windows-latest"),
new data.StringData("macos-latest")
)
},
{
key: "node",
value: new data.Array(new data.StringData("12"), new data.StringData("14"))
}
)
);
});
it("matrix with only include", () => {
const workflowContext = createMatrix({
include: [
{
site: "production",
datacenter: "site-a"
},
{
site: "staging",
datacenter: "site-b"
}
]
});
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(
new DescriptionDictionary(
{
key: "site",
value: new data.Array(new data.StringData("production"), new data.StringData("staging"))
},
{
key: "datacenter",
value: new data.Array(new data.StringData("site-a"), new data.StringData("site-b"))
}
)
);
});
it("matrix with exclude", () => {
const workflowContext = createMatrix({
os: ["macos-latest", "windows-latest"],
node: ["12", "14", "16"],
environment: ["staging", "production"],
exclude: [
{
os: "macos-latest",
node: "12",
environment: "production"
},
{
os: "windows-latest",
node: "16"
}
]
});
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(
new DescriptionDictionary(
{
key: "os",
value: new data.Array(new data.StringData("macos-latest"), new data.StringData("windows-latest"))
},
{
key: "node",
value: new data.Array(new data.StringData("12"), new data.StringData("14"), new data.StringData("16"))
},
{
key: "environment",
value: new data.Array(new data.StringData("staging"), new data.StringData("production"))
}
)
);
});
it("matrix with only exclude", () => {
const workflowContext = createMatrix({
exclude: [
{
os: "macos-latest",
node: "12",
environment: "production"
},
{
os: "windows-latest",
node: "16"
}
]
});
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new DescriptionDictionary());
});
});
});
@@ -0,0 +1,186 @@
import {data, DescriptionDictionary} from "@github/actions-expressions";
import {isBasicExpression, isMapping, isSequence, isString} from "@github/actions-workflow-parser";
import {KeyValuePair} from "@github/actions-workflow-parser/templates/tokens/key-value-pair";
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token";
import {WorkflowContext} from "../context/workflow-context";
import {ContextValue, Mode} from "./default";
export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode): ContextValue {
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
if (!strategy || !isMapping(strategy)) {
return new DescriptionDictionary();
}
const matrix = strategy.find("matrix");
if (!matrix || !isMapping(matrix)) {
// Matrix could be an expression, so there's no context we can provide
return new data.Null();
}
const properties = matrixProperties(matrix, mode);
if (!properties) {
// Matrix included an expression, so there's no context we can provide
return new data.Null();
}
const d = new DescriptionDictionary();
for (const [key, value] of properties) {
if (value === undefined) {
d.add(key, new data.Null());
continue;
}
const a = new data.Array();
for (const v of value) {
a.add(new data.StringData(v));
}
d.add(key, a);
}
return d;
}
/**
* https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymatrix
* A matrix property can come from:
* - An explicit matrix property key
* - A configuration included via the "include" property
*
* By definition, "exclude" can't add new keys to the matrix.
* Additionally, "include" and "exclude are not properties of the matrix
* If the matrix or "include" is an expression, we can't know the keys
*
* Examples:
* 1. Basic matrix
* matrix:
* version: [10, 12, 14]
* os: [ubuntu-latest, windows-latest]
*
* Keys: version, os
*
* 2. Matrix with "include"
* matrix:
* version: [10, 12, 14]
* os: [ubuntu-latest, windows-latest]
* include:
* - version: 10
* os: macos-latest
*
* Keys: version, os
*
* 3. Matrix with new properties in "include"
* matrix:
* include:
* - site: "production"
* datacenter: "site-a"
* - site: "staging"
* datacenter: "site-b"
*
* Keys: site, datacenter
*
* 4. Matrix with "exclude"
* matrix:
* os: [macos-latest, windows-latest]
* version: [12, 14, 16]
* environment: [staging, production]
* exclude:
* - os: macos-latest
* version: 12
* environment: production
* - os: windows-latest
* version: 16
*
* Keys: os, version, environment
*/
function matrixProperties(matrix: MappingToken, mode: Mode): Map<string, Set<string> | undefined> | undefined {
const properties = new Map<string, Set<string> | undefined>();
let include: SequenceToken | undefined;
for (const pair of matrix) {
if (!isString(pair.key)) {
continue;
}
const key = pair.key.value;
switch (key) {
case "include":
// If "include" is an expression, we can't know the full properties of the matrix
if (isBasicExpression(pair.value) || !isSequence(pair.value)) {
// Without the full properties of the matrix, we shouldn't validate anything
if (mode === Mode.Validation) {
return;
} else {
continue;
}
}
include = pair.value;
break;
case "exclude":
break;
default:
if (!isSequence(pair.value)) {
properties.set(key, undefined);
continue;
}
const values = new Set<string>();
for (const value of pair.value) {
// The parser should coerce matrix values to strings, ignore expressions
if (isString(value)) {
values.add(value.value);
}
}
properties.set(key, values);
break;
}
}
if (include) {
for (const item of include) {
if (!isMapping(item)) {
continue;
}
for (const pair of item) {
addValueToProperties(properties, pair);
}
}
}
return properties;
}
function addValueToProperties(properties: Map<string, Set<string> | undefined>, pair: KeyValuePair): void {
if (!isString(pair.key)) {
return;
}
const key = pair.key.value;
const value = isString(pair.value) ? pair.value.value : undefined;
if (!properties.has(key)) {
if (value === undefined) {
properties.set(key, undefined);
return;
}
properties.set(key, new Set<string>([value]));
return;
}
if (value === undefined) {
return;
}
const property = properties.get(key);
if (property !== undefined) {
property.add(value);
return;
}
properties.set(key, new Set<string>([value]));
}
@@ -0,0 +1,141 @@
import {DescriptionDictionary} from "@github/actions-expressions";
import {StringData} from "@github/actions-expressions/data/string";
import {WorkflowContext} from "../context/workflow-context";
import {testGetWorkflowContext} from "../test-utils/test-workflow-context";
import {getNeedsContext} from "./needs";
describe("needs context", () => {
describe("invalid workflow context", () => {
it("jobs not defined", () => {
const workflowContext = {} as WorkflowContext;
expect(workflowContext.job).toBeUndefined();
expect(workflowContext.reusableWorkflowJob).toBeUndefined();
const context = getNeedsContext(workflowContext);
expect(context).toEqual(new DescriptionDictionary());
});
});
it("job without needs", async () => {
const workflowContext = await testGetWorkflowContext(`on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: ec|ho`);
const context = getNeedsContext(workflowContext);
expect(context).toEqual(new DescriptionDictionary());
});
it("job with needs", async () => {
const workflowContext = await testGetWorkflowContext(`on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo
build:
runs-on: ubuntu-latest
needs: [test]
steps:
- run: ec|ho`);
const context = getNeedsContext(workflowContext);
expect(context.pairs().map(x => x.key)).toEqual(["test"]);
});
it("reusable job without needs", async () => {
const workflowContext = await testGetWorkflowContext(`on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo
build:
uses: ./.github/workflows/some-reusable-wor|kflow.yml`);
const context = getNeedsContext(workflowContext);
expect(context).toEqual(new DescriptionDictionary());
});
it("reusable job with needs", async () => {
const workflowContext = await testGetWorkflowContext(`on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo
build:
uses: ./.github/workflows/some-reusable-wor|kflow.yml
needs: [test]`);
const context = getNeedsContext(workflowContext);
expect(context.pairs().map(x => x.key)).toEqual(["test"]);
});
describe("outputs", () => {
it("regular job with outputs", async () => {
const workflowContext = await testGetWorkflowContext(`
on: push
jobs:
a:
outputs:
build_id: my-build-id
runs-on: ubuntu-latest
steps:
- run: echo
b:
uses: ./.github/workflows/some-reusable-wor|kflow.yml
needs: [a]
`);
const context = getNeedsContext(workflowContext);
const needs = context.get("a") as DescriptionDictionary;
expect(needs).toBeDefined();
const outputs = needs.get("outputs") as DescriptionDictionary;
expect(outputs).toBeDefined();
expect(outputs.pairs()).toEqual([
{
key: "build_id",
value: new StringData("my-build-id"),
description: undefined
}
]);
});
it("reusable job with outputs", async () => {
const workflowContext = await testGetWorkflowContext(`
on: push
jobs:
a:
uses: ./reusable-workflow-with-outputs.yaml
b:
needs: [a]
runs-on: ubuntu-latest
steps:
- run: ec|ho
`);
const context = getNeedsContext(workflowContext);
const needs = context.get("a") as DescriptionDictionary;
expect(needs).toBeDefined();
const outputs = needs.get("outputs") as DescriptionDictionary;
expect(outputs).toBeDefined();
expect(outputs.pairs().map(x => x.key)).toEqual(["build_id"]);
expect(outputs.pairs()).toEqual([
{
key: "build_id",
value: new StringData("123"),
description: "The resulting build ID"
}
]);
});
});
});
@@ -0,0 +1,73 @@
import {data, DescriptionDictionary} from "@github/actions-expressions";
import {isMapping, isScalar, isString} from "@github/actions-workflow-parser";
import {isJob} from "@github/actions-workflow-parser/model/type-guards";
import {WorkflowJob} from "@github/actions-workflow-parser/model/workflow-template";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
import {WorkflowContext} from "../context/workflow-context";
export function getNeedsContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary();
const job = workflowContext.job || workflowContext.reusableWorkflowJob;
if (!job?.needs) {
return d;
}
for (const jobID of job.needs) {
const job = workflowContext.template?.jobs.find(job => job.id.value === jobID.value);
d.add(jobID.value, needsJobContext(job));
}
return d;
}
function needsJobContext(job?: WorkflowJob): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#needs-context
const d = new DescriptionDictionary();
if (job) {
d.add("outputs", jobOutputs(job));
}
// Can be "success", "failure", "cancelled", or "skipped"
d.add("result", new data.Null());
return d;
}
function jobOutputs(job?: WorkflowJob): DescriptionDictionary {
const d = new DescriptionDictionary();
if (!job?.outputs) {
return d;
}
for (const output of job.outputs) {
if (!isString(output.key)) {
continue;
}
d.add(output.key.value, ...jobOutput(job, output.value));
}
return d;
}
function jobOutput(job: WorkflowJob, outputValue: TemplateToken): [data.ExpressionData, string | undefined] {
if (isJob(job)) {
// A regular workflow job won't have a description
return isScalar(outputValue)
? [new data.StringData(outputValue.toDisplayString()), undefined]
: [new data.Null(), undefined];
}
if (!isMapping(outputValue)) {
return [new data.Null(), undefined];
}
const description = outputValue.find("description");
const value = outputValue.find("value");
return [
value && isScalar(value) ? new data.StringData(value.toDisplayString()) : new data.Null(),
description && isString(description) ? description.value : undefined
];
}
@@ -0,0 +1,47 @@
import {data, DescriptionDictionary} from "@github/actions-expressions";
import {Step} from "@github/actions-workflow-parser/model/workflow-template";
import {WorkflowContext} from "../context/workflow-context";
import {getDescription} from "./descriptions";
export function getStepsContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary();
if (!workflowContext.job?.steps) {
return d;
}
const currentStep = workflowContext.step?.id;
for (const step of workflowContext.job.steps) {
// We can't reference context from the current step or later steps
if (currentStep && step.id === currentStep) {
break;
}
if (isGenerated(step)) {
continue;
}
d.add(step.id, stepContext());
}
return d;
}
function stepContext(): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#steps-context
const d = new DescriptionDictionary();
d.add("outputs", new data.Null(), getDescription("steps", "outputs"));
// Can be "success", "failure", "cancelled", or "skipped"
d.add("conclusion", new data.Null(), getDescription("steps", "conclusion"));
d.add("outcome", new data.Null(), getDescription("steps", "outcome"));
return d;
}
function isGenerated(step: Step): boolean {
// Steps need to explicitly set an ID to be referenced in the context
// Generated IDs always start with "__", which is not allowed by user-defined IDs
return step.id.startsWith("__");
}
@@ -0,0 +1,39 @@
import {data, DescriptionDictionary} from "@github/actions-expressions";
import {isMapping, isScalar, isString} from "@github/actions-workflow-parser";
import {WorkflowContext} from "../context/workflow-context";
import {scalarToData} from "../utils/scalar-to-data";
export function getStrategyContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
const keys = ["fail-fast", "job-index", "job-total", "max-parallel"];
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
if (!strategy || !isMapping(strategy)) {
return new DescriptionDictionary(
...keys.map(key => {
return {key, value: new data.Null()};
})
);
}
const strategyContext = new DescriptionDictionary();
for (const pair of strategy) {
if (!isString(pair.key)) {
continue;
}
if (!keys.includes(pair.key.value)) {
continue;
}
const value = isScalar(pair.value) ? scalarToData(pair.value) : new data.Null();
strategyContext.add(pair.key.value, value);
}
for (const key of keys) {
if (!strategyContext.get(key)) {
strategyContext.add(key, new data.Null());
}
}
return strategyContext;
}
@@ -0,0 +1,65 @@
import {ActionStep, RunStep} from "@github/actions-workflow-parser/model/workflow-template";
import {testGetWorkflowContext} from "../test-utils/test-workflow-context";
describe("getWorkflowContext", () => {
it("context for workflow", async () => {
const context = await testGetWorkflowContext(`on: push
name: te|st
jobs:
build:
runs-on: ubuntu-latest
steps:
- echo Hello`);
expect(context.uri).not.toBe("");
expect(context.template).not.toBeUndefined();
expect(context.job).toBeUndefined();
expect(context.step).toBeUndefined();
});
it("context for workflow job", async () => {
const context = await testGetWorkflowContext(`on: push
jobs:
build:
runs-on: ubuntu-lat|est
steps:
- run: echo Hello`);
expect(context.uri).not.toBe("");
expect(context.template).not.toBeUndefined();
expect(context.job).not.toBeUndefined();
expect(context.step).toBeUndefined();
});
it("context for workflow run step", async () => {
const context = await testGetWorkflowContext(`on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo |Hello
- uses: actions/checkout@v2`);
expect(context.uri).not.toBe("");
expect(context.template).not.toBeUndefined();
expect(context.job).not.toBeUndefined();
const step = context.step as RunStep;
expect(step).not.toBeUndefined();
expect(step.run.toDisplayString()).toBe("echo Hello");
});
it("context for workflow uses step", async () => {
const context = await testGetWorkflowContext(`on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo Hello
- uses: actions/checkout@v2|`);
expect(context.uri).not.toBe("");
expect(context.template).not.toBeUndefined();
expect(context.job).not.toBeUndefined();
const step = context.step as ActionStep;
expect(step).not.toBeUndefined();
expect(step.uses.value).toBe("actions/checkout@v2");
});
});
@@ -0,0 +1,95 @@
import {isMapping, isSequence, WorkflowTemplate} from "@github/actions-workflow-parser";
import {isJob, isReusableWorkflowJob} from "@github/actions-workflow-parser/model/type-guards";
import {Step, Job, ReusableWorkflowJob} from "@github/actions-workflow-parser/model/workflow-template";
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token";
import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
export interface WorkflowContext {
uri: string;
template: WorkflowTemplate | undefined;
/** If the context is for a position within a regular job, this will be the job */
job?: Job;
/** If the context is for a position within a reusable workflow job, this will be the reusable workflow job */
reusableWorkflowJob?: ReusableWorkflowJob;
/** If the context is for a position within a step, this will be the step */
step?: Step;
}
export function getWorkflowContext(
uri: string,
template: WorkflowTemplate | undefined,
tokenPath: TemplateToken[]
): WorkflowContext {
const context: WorkflowContext = {uri: uri, template};
if (!template) {
return context;
}
let stepsSequence: SequenceToken | undefined = undefined;
let stepToken: MappingToken | undefined = undefined;
// Iterate through the token path to find the job and step
for (const token of tokenPath) {
switch (token.definition?.key) {
case "job": {
const jobID = (token as StringToken).value;
const job = template.jobs.find(job => job.id.value === jobID);
if (!job) {
break;
}
if (isJob(job)) {
context.job = job;
} else if (isReusableWorkflowJob(job)) {
context.reusableWorkflowJob = job;
}
break;
}
case "steps": {
if (isSequence(token)) {
stepsSequence = token;
}
break;
}
case "regular-step":
case "run-step": {
if (isMapping(token)) {
stepToken = token;
}
break;
}
}
}
if (context.job && isJob(context.job)) {
context.step = findStep(context.job.steps, stepsSequence, stepToken);
}
return context;
}
function findStep(steps?: Step[], stepSequence?: SequenceToken, stepToken?: MappingToken): Step | undefined {
if (!steps || !stepSequence || !stepToken) {
return undefined;
}
// Steps may not define an ID, so find the step by index
let stepIndex = -1;
for (let i = 0; i < stepSequence.count; i++) {
if (stepSequence.get(i) === stepToken) {
stepIndex = i;
break;
}
}
if (stepIndex === -1 || stepIndex >= steps.length) {
return undefined;
}
return steps[stepIndex];
}
@@ -0,0 +1,37 @@
import {isMapping, isString} from "@github/actions-workflow-parser";
import {DESCRIPTION} from "@github/actions-workflow-parser/templates/template-constants";
import {WorkflowContext} from "../context/workflow-context";
import {TokenResult} from "../utils/find-token";
export function isReusableWorkflowJobInput(tokenResult: TokenResult): boolean {
return tokenResult.parent?.definition?.key === "workflow-job-with" && isString(tokenResult.token!);
}
export function getReusableWorkflowInputDescription(
workflowContext: WorkflowContext,
tokenResult: TokenResult
): string {
const reusableWorkflowJob = workflowContext.reusableWorkflowJob;
if (!reusableWorkflowJob) {
return "";
}
const inputName = tokenResult.token && isString(tokenResult.token) && tokenResult.token.value;
if (!inputName) {
return "";
}
// Find the input description in the template, if any
if (reusableWorkflowJob["input-definitions"]) {
const definition = reusableWorkflowJob["input-definitions"].find(inputName);
if (definition && isMapping(definition)) {
const description = definition.find(DESCRIPTION);
if (description && isString(description)) {
return description.value;
}
}
}
return "";
}
@@ -0,0 +1,80 @@
import {documentLinks} from "./document-links";
import {createDocument} from "./test-utils/document";
describe("documentLinks", () => {
it("no links without actions", async () => {
const input = `on: push
jobs:
build:
runs-on: [self-hosted]
steps:
- run: echo "Hello World"`;
const result = await documentLinks(createDocument("test.yaml", input));
expect(result).toHaveLength(0);
});
it("no links for invalid workflow", async () => {
const input = `onFOO: push
jobs:
build:
runs-on: [self-hosted]`;
const result = await documentLinks(createDocument("test.yaml", input));
expect(result).toHaveLength(0);
});
it("links for actions in workflow", async () => {
const input = `on: push
jobs:
build:
runs-on: [self-hosted]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: github/codeql-action/init@v2`;
const result = await documentLinks(createDocument("test.yaml", input));
expect(result).toEqual([
{
range: {
end: {
character: 31,
line: 5
},
start: {
character: 12,
line: 5
}
},
target: "https://www.github.com/actions/checkout/tree/v2/",
tooltip: "Open action on GitHub"
},
{
range: {
end: {
character: 31,
line: 6
},
start: {
character: 12,
line: 6
}
},
target: "https://www.github.com/actions/checkout/tree/v3/",
tooltip: "Open action on GitHub"
},
{
range: {
end: {
character: 40,
line: 7
},
start: {
character: 12,
line: 7
}
},
target: "https://www.github.com/github/codeql-action/tree/v2/init",
tooltip: "Open action on GitHub"
}
]);
});
});
+57
View File
@@ -0,0 +1,57 @@
import {convertWorkflowTemplate, parseWorkflow} from "@github/actions-workflow-parser";
import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert";
import {isJob} from "@github/actions-workflow-parser/model/type-guards";
import {File} from "@github/actions-workflow-parser/workflows/file";
import {TextDocument} from "vscode-languageserver-textdocument";
import {DocumentLink} from "vscode-languageserver-types";
import {parseActionReference} from "./action";
import {nullTrace} from "./nulltrace";
import {mapRange} from "./utils/range";
export async function documentLinks(document: TextDocument): Promise<DocumentLink[]> {
const file: File = {
name: document.uri,
content: document.getText()
};
const result = parseWorkflow(file, nullTrace);
if (!result.value) {
return [];
}
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Add links to referenced actions
const actionLinks: DocumentLink[] = [];
// TODO: Support base uri for GHES
const gitHubBaseUri = "https://www.github.com/";
for (const job of template?.jobs || []) {
if (!job || !isJob(job)) {
continue;
}
for (const step of job.steps || []) {
if ("uses" in step) {
const actionRef = parseActionReference(step.uses.value);
if (!actionRef) {
continue;
}
const url = `${gitHubBaseUri}${actionRef.owner}/${actionRef.name}/tree/${actionRef.ref}/${
actionRef.path || ""
}`;
actionLinks.push({
range: mapRange(step.uses.range),
target: url,
tooltip: `Open action on GitHub`
});
}
}
}
return [...actionLinks];
}
@@ -0,0 +1,92 @@
import {parseWorkflow} from "@github/actions-workflow-parser";
import {File} from "@github/actions-workflow-parser/workflows/file";
import {nullTrace} from "../nulltrace";
import {getPositionFromCursor} from "../test-utils/cursor-position";
import {findToken} from "../utils/find-token";
import {ExpressionPos, mapToExpressionPos} from "./expression-pos";
describe("mapToExpressionPos", () => {
it("simple expression", () => {
expect(
testMapToExpressionPos(`on: push
run-name: \${{ git|hub.event }}`)
).toEqual<ExpressionPos>({
expression: "github.event",
position: {line: 0, column: 3},
documentRange: {
start: {line: 1, character: 14},
end: {line: 1, character: 26}
}
});
});
it("implicit format expression", () => {
expect(
testMapToExpressionPos(`on: push
run-name: hello \${{ git|hub.event }}`)
).toEqual<ExpressionPos>({
expression: "github.event",
position: {line: 0, column: 3},
documentRange: {
start: {line: 1, character: 20},
end: {line: 1, character: 32}
}
});
});
it("implicit complex format expression", () => {
expect(
testMapToExpressionPos(`on: push
run-name: hello \${{ github.test }}-\${{ git|hub.event }}`)
).toEqual<ExpressionPos>({
expression: "github.event",
position: {line: 0, column: 3},
documentRange: {
start: {line: 1, character: 39},
end: {line: 1, character: 51}
}
});
});
it("multi-line expression", () => {
expect(
testMapToExpressionPos(`on: push
jobs:
build:
runs-on: [self-hosted]
steps:
- run: >
echo 'hello'
echo '\${{ github.event.te|st }}
echo 'world'
echo '\${{ github.event.test }}`)
).toEqual<ExpressionPos>({
expression: "github.event.test",
position: {line: 0, column: 15},
documentRange: {
start: {line: 7, character: 18},
end: {line: 7, character: 35}
}
});
});
});
function testMapToExpressionPos(input: string) {
const [td, pos] = getPositionFromCursor(input);
const file: File = {
name: td.uri,
content: td.getText()
};
const result = parseWorkflow(file, nullTrace);
if (!result.value) {
throw new Error("Invalid workflow");
}
const {token} = findToken(pos, result.value);
if (!token) {
throw new Error("No token found");
}
return mapToExpressionPos(token, pos);
}
@@ -0,0 +1,60 @@
import {Pos} from "@github/actions-expressions/lexer";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
import {isBasicExpression} from "@github/actions-workflow-parser/templates/tokens/type-guards";
import {Position, Range as LSPRange} from "vscode-languageserver-textdocument";
import {mapRange} from "../utils/range";
import {posWithinRange} from "./pos-range";
export type ExpressionPos = {
/** The expression that includes the position */
expression: string;
/** Adjusted position, pointing into the expression */
position: Pos;
/** Range of the expression in the document */
documentRange: LSPRange;
};
export function mapToExpressionPos(token: TemplateToken, position: Position): ExpressionPos | undefined {
const pos: Pos = {
line: position.line + 1,
column: position.character + 1
};
if (!isBasicExpression(token)) {
return undefined;
}
if (token.originalExpressions?.length) {
for (const originalExp of token.originalExpressions) {
// Find the original expression that contains the position
if (posWithinRange(pos, originalExp.expressionRange!)) {
const exprRange = mapRange(originalExp.expressionRange);
return {
expression: originalExp.expression,
// Adjust the position to point into the expression
position: {
line: pos.line - exprRange.start.line - 1,
column: pos.column - exprRange.start.character - 1
},
documentRange: exprRange
};
}
}
return undefined;
}
const exprRange = mapRange(token.expressionRange!);
return {
expression: token.expression,
// Adjust the position to point into the expression
position: {
line: pos.line - exprRange.start.line - 1,
column: pos.column - exprRange.start.character - 1
},
documentRange: exprRange
};
}
@@ -0,0 +1,10 @@
import {Pos, Range} from "@github/actions-expressions/lexer";
export function posWithinRange(pos: Pos, range: Range): boolean {
return (
pos.line >= range.start.line &&
pos.line <= range.end.line &&
pos.column >= range.start.column &&
pos.column <= range.end.column
);
}
@@ -0,0 +1,135 @@
import {data, DescriptionDictionary, Lexer, Parser} from "@github/actions-expressions";
import {convertWorkflowTemplate, parseWorkflow} from "@github/actions-workflow-parser";
import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert";
import {File} from "@github/actions-workflow-parser/workflows/file";
import {ContextProviderConfig} from "../context-providers/config";
import {getContext, Mode} from "../context-providers/default";
import {getWorkflowContext} from "../context/workflow-context";
import {validatorFunctions} from "../expression-validation/functions";
import {nullTrace} from "../nulltrace";
import {getPositionFromCursor} from "../test-utils/cursor-position";
import {HoverVisitor} from "./visitor";
const contextProviderConfig: ContextProviderConfig = {
getContext: async (context: string) => {
switch (context) {
case "github":
return new DescriptionDictionary(
{
key: "event",
value: new data.StringData("push"),
description: "The event that triggered the workflow"
},
{
key: "test",
value: new DescriptionDictionary({
key: "name",
value: new data.StringData("push"),
description: "Name for the test"
}),
description: "Test dictionary"
}
);
}
return undefined;
}
};
describe("visitor", () => {
describe("unsupported hover positions", () => {
["1 =|= 2", "12|3", "1 == |(2)", "'ab|c'"].forEach(x =>
it(x, async () => expect(await hoverExpression(x)).toBeUndefined())
);
});
it("top-level context access", async () => {
expect(await hoverExpression("githu|b")).toEqual({
label: "github",
description:
"Information about the workflow run. For more information, see [`github` context](https://docs.github.com/actions/learn-github-actions/contexts#github-context).",
function: false,
range: {
start: {line: 0, column: 0},
end: {line: 0, column: 6}
}
});
});
it("nested context access", async () => {
expect(await hoverExpression("github.test.na|me")).toEqual({
label: "name",
description: "Name for the test",
function: false,
range: {
start: {line: 0, column: 0},
end: {line: 0, column: 16}
}
});
});
it("nested context access with string key", async () => {
expect(await hoverExpression("github['te|st']")).toEqual({
label: "test",
description: "Test dictionary",
function: false,
range: {
start: {line: 0, column: 0},
end: {line: 0, column: 13}
}
});
});
it("function call", async () => {
expect(await hoverExpression("cont|ains(github, 'github')")).toEqual({
label: "contains",
description:
"`contains( search, item )`\n\nReturns `true` if `search` contains `item`. If `search`" +
" is an array, this function returns `true` if the `item` is an element in the array. If `search`" +
" is a string, this function returns `true` if the `item` is a substring of `search`. This function" +
" is not case sensitive. Casts values to a string.",
function: true,
range: {
start: {line: 0, column: 0},
end: {line: 0, column: 8}
}
});
});
});
async function hoverExpression(input: string) {
const [td, pos] = getPositionFromCursor(input);
const allowedContext = ["github"];
const file: File = {
name: td.uri,
content: td.getText()
};
const result = parseWorkflow(file, nullTrace);
if (!result.value) {
return undefined;
}
const template = await convertWorkflowTemplate(result.context, result.value, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
const workflowContext = getWorkflowContext(td.uri, template, []);
const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion);
const l = new Lexer(td.getText());
const lr = l.lex();
const p = new Parser(lr.tokens, ["github"], []);
const expr = p.parse();
const hv = new HoverVisitor(
{
line: pos.line,
column: pos.character
},
context,
[],
validatorFunctions
);
return hv.hover(expr);
}
@@ -0,0 +1,166 @@
import {
DescriptionDictionary,
Evaluator,
isDescriptionDictionary,
wellKnownFunctions
} from "@github/actions-expressions";
import {
Binary,
ContextAccess,
Expr,
ExprVisitor,
FunctionCall,
Grouping,
IndexAccess,
Literal,
Logical,
Unary
} from "@github/actions-expressions/ast";
import {FunctionDefinition, FunctionInfo} from "@github/actions-expressions/funcs/info";
import {Pos, Range} from "@github/actions-expressions/lexer";
import {posWithinRange} from "./pos-range";
export type HoverResult =
| undefined
| {
label: string;
description?: string;
function: boolean;
range: Range;
};
export class HoverVisitor implements ExprVisitor<HoverResult> {
private ignorePosCheck = false;
constructor(
private pos: Pos,
private context: DescriptionDictionary,
private extensionFunctions: FunctionInfo[],
private functions: Map<string, FunctionDefinition>
) {}
hover(n: Expr): HoverResult {
return n.accept(this);
}
visitLiteral(literal: Literal): HoverResult {
return undefined;
}
visitUnary(unary: Unary): HoverResult {
return this.hover(unary.expr);
}
visitBinary(binary: Binary): HoverResult {
return this.hover(binary.left) || this.hover(binary.right);
}
visitLogical(logical: Logical): HoverResult {
for (const arg of logical.args) {
const result = this.hover(arg);
if (result) {
return result;
}
}
return undefined;
}
visitGrouping(grouping: Grouping): HoverResult {
return this.hover(grouping.group);
}
visitContextAccess(contextAccess: ContextAccess): HoverResult {
if (this.ignorePosCheck || posWithinRange(this.pos, contextAccess.name.range)) {
const contextName = contextAccess.name.lexeme;
return {
label: contextName,
description: this.context.getDescription(contextName),
function: false,
range: contextAccess.name.range
};
}
return undefined;
}
visitIndexAccess(indexAccess: IndexAccess): HoverResult {
// Is the position within the index, so for example:
// github.event.test
// ^ - pos
if (!(indexAccess.index instanceof Literal)) {
// No support for context access of the form github[github.event]
return undefined;
}
if (!posWithinRange(this.pos, indexAccess.index.token.range)) {
// Try to get hover from the rest of the expression
return this.hover(indexAccess.expr);
}
const ev = new Evaluator(indexAccess.expr, this.context, this.functions);
const result = ev.evaluate();
if (!isDescriptionDictionary(result)) {
// No description to show
return undefined;
}
const key = indexAccess.index.literal.coerceString();
const description = result.getDescription(key);
if (!description) {
return undefined;
}
// Calculate context access range for whole expression. For example:
// github.event.test
// ^ - pos
// should return the range:
// github.event.test
// ^^^^^^^^^^^^
this.ignorePosCheck = true;
try {
const contextHover = this.hover(indexAccess.expr);
if (!contextHover) {
throw new Error("Expected context hover to be defined");
}
return {
label: key,
description: description,
function: false,
range: {
start: contextHover.range.start,
end: indexAccess.index.token.range.end
}
};
} finally {
this.ignorePosCheck = false;
}
}
visitFunctionCall(functionCall: FunctionCall): HoverResult {
if (posWithinRange(this.pos, functionCall.functionName.range)) {
const functionName = functionCall.functionName.lexeme.toLowerCase();
const f = this.functions.get(functionName) || wellKnownFunctions[functionName];
return {
label: f.name,
description: f.description,
function: true,
range: functionCall.functionName.range
};
}
for (const args of functionCall.args) {
const result = this.hover(args);
if (result) {
return result;
}
}
return undefined;
}
}
@@ -0,0 +1,44 @@
import {data, isDescriptionDictionary} from "@github/actions-expressions";
import {isDictionary} from "@github/actions-expressions/data/dictionary";
import {ExpressionData, Pair} from "@github/actions-expressions/data/expressiondata";
export class AccessError extends Error {
constructor(message: string, public readonly keyName: string) {
super(message);
}
}
export class ErrorDictionary extends data.Dictionary {
constructor(...pairs: Pair[]) {
super(...pairs);
}
public complete: boolean = true;
get(key: string): ExpressionData | undefined {
const value = super.get(key);
if (value) {
return value;
}
if (this.complete) {
throw new AccessError(`Invalid context access: ${key}`, key);
}
}
}
export function wrapDictionary(d: data.Dictionary): ErrorDictionary {
const e = new ErrorDictionary();
if (isDescriptionDictionary(d)) {
e.complete = d.complete;
}
for (const {key, value} of d.pairs()) {
if (isDictionary(value)) {
e.add(key, wrapDictionary(value));
} else {
e.add(key, value);
}
}
return e;
}
@@ -0,0 +1,13 @@
import {data, wellKnownFunctions} from "@github/actions-expressions";
// Custom implementations for standard actions-expression functions used during validation and auto-completion.
// For example, for fromJson we'll most likely not have a valid input. In order to not throw, we'll always
// return an empty dictionary.
export const validatorFunctions = new Map(
Object.entries({
fromjson: {
...wellKnownFunctions.fromjson,
call: () => new data.Dictionary()
}
})
);
@@ -0,0 +1,116 @@
import {data, DescriptionDictionary} from "@github/actions-expressions";
import {Hover} from "vscode-languageserver-types";
import {ContextProviderConfig} from "./context-providers/config";
import {hover} from "./hover";
import {registerLogger} from "./log";
import {getPositionFromCursor} from "./test-utils/cursor-position";
import {TestLogger} from "./test-utils/logger";
const contextProviderConfig: ContextProviderConfig = {
getContext: async (context: string) => {
switch (context) {
case "github":
return new DescriptionDictionary(
{
key: "event",
value: new data.StringData("push"),
description: "The event that triggered the workflow"
},
{
key: "test",
value: new DescriptionDictionary({
key: "name",
value: new data.StringData("push"),
description: "Name for the test"
}),
description: "Test dictionary"
}
);
}
return undefined;
}
};
registerLogger(new TestLogger());
describe("hover.expressions", () => {
it("context access", async () => {
const input = `on: push
run-name: \${{ github.even|t }}
jobs:
build:
runs-on: [self-hosted]`;
const result = await hover(...getPositionFromCursor(input), {
contextProviderConfig
});
expect(result).toEqual<Hover>({
contents: "The event that triggered the workflow",
range: {
start: {line: 1, character: 14},
end: {line: 1, character: 26}
}
});
});
it("context", async () => {
const input = `on: push
run-name: \${{ git|hub.event }}
jobs:
build:
runs-on: [self-hosted]`;
const result = await hover(...getPositionFromCursor(input), {
contextProviderConfig
});
expect(result).toEqual<Hover>({
contents:
"Information about the workflow run. For more information, see [`github` context](https://docs.github.com/actions/learn-github-actions/contexts#github-context).",
range: {
start: {line: 1, character: 14},
end: {line: 1, character: 20}
}
});
});
it("multiple expressions", async () => {
const input = `on: push
run-name: \${{ git|hub.event }}-\${{ github.event }}
jobs:
build:
runs-on: [self-hosted]`;
const result = await hover(...getPositionFromCursor(input), {
contextProviderConfig
});
expect(result).toEqual<Hover>({
contents:
"Information about the workflow run. For more information, see [`github` context](https://docs.github.com/actions/learn-github-actions/contexts#github-context).",
range: {
start: {line: 1, character: 14},
end: {line: 1, character: 20}
}
});
});
it("multi-line expression", async () => {
const input = `on: push
jobs:
build:
runs-on: [self-hosted]
steps:
- run: |
echo 'hello'
echo '\${{ github.test.na|me }}
echo 'world'
echo '\${{ github.event.test }}`;
const result = await hover(...getPositionFromCursor(input, 1), {
contextProviderConfig
});
expect(result).toEqual<Hover>({
contents: "Name for the test",
range: {
start: {line: 7, character: 18},
end: {line: 7, character: 34}
}
});
});
});
@@ -0,0 +1,56 @@
import {hover} from "./hover";
import {testHoverConfig} from "./hover.test";
import {getPositionFromCursor} from "./test-utils/cursor-position";
describe("hover.reusable-workflow", () => {
it("hover on job input with description", async () => {
const input = `
on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
with:
us|ername:
`;
const result = await hover(...getPositionFromCursor(input), testHoverConfig("username", "scalar-needs-context"));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual(
"A username passed from the caller workflow\n\n**Context:** github, inputs, vars, needs, strategy, matrix"
);
});
it("hover on job input without description", async () => {
const input = `
on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs-no-description.yaml
with:
us|ername:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual("**Context:** github, inputs, vars, needs, strategy, matrix");
});
it("hover on job output with description", async () => {
const input = `
on: push
jobs:
build:
uses: ./reusable-workflow-with-outputs.yaml
echo_outputs:
runs-on: ubuntu-latest
needs: build
steps:
- run: echo \${{ needs.build.outputs.bu|ild_id }}
`;
const result = await hover(...getPositionFromCursor(input), testHoverConfig("", "string-steps-context"));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual("The resulting build ID");
});
});
+198
View File
@@ -0,0 +1,198 @@
import {isString} from "@github/actions-workflow-parser";
import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token";
import {DescriptionProvider, hover, HoverConfig} from "./hover";
import {getPositionFromCursor} from "./test-utils/cursor-position";
import {testFileProvider} from "./test-utils/test-file-provider";
export function testHoverConfig(tokenValue: string, tokenKey: string, description?: string) {
return {
descriptionProvider: {
getDescription: async (_, token, __) => {
if (!isString(token)) {
throw new Error("Test provider only supports string tokens");
}
expect((token as StringToken).value).toEqual(tokenValue);
expect(token.definition!.key).toEqual(tokenKey);
return description;
}
} satisfies DescriptionProvider,
fileProvider: testFileProvider
} satisfies HoverConfig;
}
describe("hover", () => {
it("on a key", async () => {
const input = `o|n: push
jobs:
build:
runs-on: [self-hosted]`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toContain("The GitHub event that triggers the workflow.");
});
it("on a value", async () => {
const input = `on: pu|sh
jobs:
build:
runs-on: [self-hosted]`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual("Runs your workflow when you push a commit or tag.");
});
it("on a parameter with a description", async () => {
const input = `on: push
jobs:
build:
co|ntinue-on-error: false`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual(
"Prevents a workflow run from failing when a job fails. Set to true to allow a workflow run to pass when this job fails.\n\n" +
"**Context:** github, inputs, vars, needs, strategy, matrix"
);
});
it("on a parameter with its own type", async () => {
const input = `on: push
jobs:
build:
pe|rmissions: read-all`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toContain(
"You can use `permissions` to modify the default permissions granted to the `GITHUB_TOKEN`"
);
});
it("property values are not overwritten", async () => {
const input1 = `on: push
jobs:
build:
ti|meout-minutes: 10
cancel-timeout-minutes: 10`;
const result1 = await hover(...getPositionFromCursor(input1));
expect(result1).not.toBeUndefined();
const input2 = `on: push
jobs:
build:
timeout-minutes: 10
ca|ncel-timeout-minutes: 10`;
const result2 = await hover(...getPositionFromCursor(input2));
expect(result2).not.toBeUndefined();
expect(result1?.contents).not.toEqual(result2?.contents);
});
it("on a value in a sequence", async () => {
const input = `on: [pull_request,
pu|sh]
jobs:
build:
runs-on: [self-hosted]`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual("Runs your workflow when you push a commit or tag.");
});
it("on a cron schedule", async () => {
const input = `on:
schedule:
- cron: '0,30 0|,12 * * *'
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual(
"Runs at 0 and 30 minutes past the hour, at 00:00 and 12:00\n\n" +
"Actions schedules run at most every 5 minutes. " +
"[Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
);
});
it("on a cron mapping key", async () => {
const input = `on:
schedule:
- c|ron: '0 0 * * *'
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual("");
});
it("on an invalid cron schedule", async () => {
const input = `on:
schedule:
- cron: '0 0 |* * * * *'
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual("");
});
it("shows context inherited from parent nodes", async () => {
const input = `
on: push
jobs:
build:
runs-on: [self-hosted]
steps:
- uses: actions/checkout@v2
with:
ref|: main
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// The `ref` is a `string` definition and inherits the context from `step-with`
const expected =
"**Context:** github, inputs, vars, needs, strategy, matrix, secrets, steps, job, runner, env, hashFiles(1,255)";
expect(result?.contents).toEqual(expected);
});
});
describe("hover with description provider", () => {
it("uses the description provider", async () => {
const input = `
on: push
jobs:
build:
runs-on: [self-hosted]
steps:
- uses: actions/checkout@v2
with:
ref|: main
`;
const result = await hover(
...getPositionFromCursor(input),
testHoverConfig("ref", "string", "The branch, tag or SHA to checkout.")
);
expect(result).not.toBeUndefined();
const expected =
"The branch, tag or SHA to checkout.\n\n" +
"**Context:** github, inputs, vars, needs, strategy, matrix, secrets, steps, job, runner, env, hashFiles(1,255)";
expect(result?.contents).toEqual(expected);
});
it("falls back to the token description", async () => {
const input = `
on: push
jobs:
build:
runs-on: [self-hosted]
steps:
- uses|: actions/checkout@v2
`;
const result = await hover(...getPositionFromCursor(input), testHoverConfig("uses", "non-empty-string", undefined));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual(
"Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image."
);
});
});
+183
View File
@@ -0,0 +1,183 @@
import {DescriptionDictionary, Parser} from "@github/actions-expressions";
import {FunctionInfo} from "@github/actions-expressions/funcs/info";
import {Lexer} from "@github/actions-expressions/lexer";
import {convertWorkflowTemplate, parseWorkflow} from "@github/actions-workflow-parser";
import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert";
import {getCronDescription} from "@github/actions-workflow-parser/model/converter/cron";
import {splitAllowedContext} from "@github/actions-workflow-parser/templates/allowed-context";
import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
import {isBasicExpression, isString} from "@github/actions-workflow-parser/templates/tokens/type-guards";
import {File} from "@github/actions-workflow-parser/workflows/file";
import {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider";
import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {Hover} 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 {
isReusableWorkflowJobInput,
getReusableWorkflowInputDescription
} from "./description-providers/reusable-job-inputs";
import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-pos";
import {HoverVisitor} from "./expression-hover/visitor";
import {validatorFunctions} from "./expression-validation/functions";
import {info} from "./log";
import {nullTrace} from "./nulltrace";
import {isPotentiallyExpression} from "./utils/expression-detection";
import {findToken, TokenResult} from "./utils/find-token";
import {mapRange} from "./utils/range";
export type HoverConfig = {
descriptionProvider?: DescriptionProvider;
contextProviderConfig?: ContextProviderConfig;
fileProvider?: FileProvider;
};
export type DescriptionProvider = {
getDescription(context: WorkflowContext, token: TemplateToken, path: TemplateToken[]): Promise<string | undefined>;
};
export async function hover(document: TextDocument, position: Position, config?: HoverConfig): Promise<Hover | null> {
const file: File = {
name: document.uri,
content: document.getText()
};
const result = parseWorkflow(file, nullTrace);
if (!result.value) {
return null;
}
const tokenResult = findToken(position, result.value);
const {token, keyToken, parent} = tokenResult;
const tokenDefinitionInfo = (keyToken || parent || token)?.definitionInfo;
const template = await convertWorkflowTemplate(result.context, result.value, config?.fileProvider, {
errorPolicy: ErrorPolicy.TryConversion,
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0
});
const workflowContext = getWorkflowContext(document.uri, template, tokenResult.path);
if (token && tokenDefinitionInfo) {
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
info(`Calculating expression hover for token with definition ${tokenDefinitionInfo.definition.key}`);
const allowedContext = tokenDefinitionInfo.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
const context = await getContext(namedContexts, config?.contextProviderConfig, workflowContext, Mode.Completion);
const exprPos = mapToExpressionPos(token, position);
if (exprPos) {
return expressionHover(exprPos, context, namedContexts, functions);
}
}
}
if (!token?.definition) {
return null;
}
info(`Calculating hover for token with definition ${token.definition.key}`);
if (tokenResult.parent && isCronMappingValue(tokenResult)) {
const tokenValue = (token as StringToken).value;
const description = getCronDescription(tokenValue);
if (description) {
return {
contents: description,
range: mapRange(token.range)
} satisfies Hover;
}
}
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
description = appendContext(description, token.definitionInfo?.allowedContext);
return {
contents: description,
range: mapRange(token.range)
} satisfies Hover;
}
let description = await getDescription(config, workflowContext, token, tokenResult.path);
description = appendContext(description, token.definitionInfo?.allowedContext);
return {
contents: description,
range: mapRange(token.range)
} satisfies Hover;
}
function appendContext(description: string, allowedContext?: string[]) {
if (allowedContext && allowedContext?.length > 0) {
// Only add padding if there is a description
description += `${description.length > 0 ? `\n\n` : ""}**Context:** ${allowedContext.join(", ")}`;
}
return description;
}
async function getDescription(
config: HoverConfig | undefined,
workflowContext: WorkflowContext,
token: TemplateToken,
path: TemplateToken[]
) {
const defaultDescription = token.description || "";
if (!config?.descriptionProvider) {
return defaultDescription;
}
const description = await config.descriptionProvider.getDescription(workflowContext, token, path);
return description || defaultDescription;
}
function isCronMappingValue(tokenResult: TokenResult): boolean {
return (
tokenResult.parent?.definition?.key === "cron-mapping" &&
isString(tokenResult.token!) &&
tokenResult.token.value !== "cron"
);
}
function expressionHover(
exprPos: ExpressionPos,
context: DescriptionDictionary,
namedContexts: string[],
functions: FunctionInfo[]
): Hover | null {
const {expression, position, documentRange} = exprPos;
try {
const l = new Lexer(expression);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
const expr = p.parse();
const hv = new HoverVisitor(position, context, [], validatorFunctions);
const hoverResult = hv.hover(expr);
if (!hoverResult) {
return null;
}
const exprRange = hoverResult.range;
return {
contents: hoverResult?.description || hoverResult?.label,
// Map the expression range back to a document range
range: {
start: {
line: documentRange.start.line + exprRange.start.line,
character: documentRange.start.character + exprRange.start.column
},
end: {
line: documentRange.start.line + exprRange.end.line,
character: documentRange.start.character + exprRange.end.column
}
}
};
} catch (e) {
// Hovering over an invalid expression should not cause an error here
info(`Encountered error trying to calculate expression hover: ${e}`);
return null;
}
}
+7
View File
@@ -0,0 +1,7 @@
export {complete} from "./complete";
export {ContextProviderConfig} from "./context-providers/config";
export {documentLinks} from "./document-links";
export {hover} from "./hover";
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log";
export {validate, ValidationConfig} from "./validate";
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
+84
View File
@@ -0,0 +1,84 @@
export enum LogLevel {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3
}
const loggers: Logger[] = [];
let logLevel = LogLevel.Warn;
export interface Logger {
/**
* Show an error message.
*
* @param message The message to show.
*/
error(message: string): void;
/**
* Show a warning message.
*
* @param message The message to show.
*/
warn(message: string): void;
/**
* Show an information message.
*
* @param message The message to show.
*/
info(message: string): void;
/**
* Log a message.
*
* @param message The message to log.
*/
log(message: string): void;
}
export function registerLogger(l: Logger) {
loggers.push(l);
}
export function setLogLevel(ll: LogLevel) {
logLevel = ll;
}
export function log(message: string): void {
if (logLevel > LogLevel.Debug) {
return;
}
for (const l of loggers) {
l.log(message);
}
}
export function info(message: string): void {
if (logLevel > LogLevel.Info) {
return;
}
for (const l of loggers) {
l.info(message);
}
}
export function warn(message: string): void {
if (logLevel > LogLevel.Warn) {
return;
}
for (const l of loggers) {
l.warn(message);
}
}
export function error(message: string): void {
if (logLevel > LogLevel.Error) {
return;
}
for (const l of loggers) {
l.error(message);
}
}
+7
View File
@@ -0,0 +1,7 @@
import {TraceWriter} from "@github/actions-workflow-parser/templates/trace-writer";
export const nullTrace: TraceWriter = {
info: x => {},
verbose: x => {},
error: x => {}
};
@@ -0,0 +1,32 @@
import {getPositionFromCursor} from "./cursor-position";
describe("getPositionFromCursor", () => {
it("returns the position of the cursor and the document without that cursor", () => {
const input = "on: push\njobs:|";
const [newDoc, position] = getPositionFromCursor(input);
expect(position).toEqual({line: 1, character: 5});
expect(newDoc.getText()).toEqual("on: push\njobs:");
});
it("throws an error if no cursor is found", () => {
const input = "on: push\njobs:";
expect(() => getPositionFromCursor(input)).toThrowError("No cursor found in document");
});
it("handles a cursor at the beginning of the document", () => {
const input = "|on: push\njobs:";
const [newDoc, position] = getPositionFromCursor(input);
expect(position).toEqual({line: 0, character: 0});
expect(newDoc.getText()).toEqual("on: push\njobs:");
});
it("handles a cursor in the middle of the document", () => {
const input = "on: push\n jobs|:\n build:";
const [newDoc, position] = getPositionFromCursor(input);
expect(position).toEqual({line: 1, character: 6});
expect(newDoc.getText()).toEqual("on: push\n jobs:\n build:");
});
});
@@ -0,0 +1,30 @@
import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {createDocument} from "./document";
/**
* Calculates the position of the cursor and the document without that cursor
* Cursor is represented by a `|` character
* @param input Input string
* @param skip Instances of `|` to skip
*/
export function getPositionFromCursor(input: string, skip = 0): [TextDocument, Position] {
const doc = createDocument("test.yaml", input);
let cursorIndex = doc.getText().indexOf("|");
for (let i = 0; i < skip && cursorIndex !== -1; i++) {
cursorIndex = doc.getText().indexOf("|", cursorIndex + 1);
}
if (cursorIndex === -1) {
throw new Error("No cursor found in document");
}
// Replace only the last occurence of | in string
let newText = doc.getText();
newText = newText.substring(0, cursorIndex) + newText.substring(cursorIndex + 1);
const position = doc.positionAt(cursorIndex);
const newDoc = TextDocument.create(doc.uri, doc.languageId, doc.version, newText);
return [newDoc, position];
}
@@ -0,0 +1,5 @@
import {TextDocument} from "vscode-languageserver-textdocument";
export function createDocument(fileName: string, content: string): TextDocument {
return TextDocument.create("test://test/" + fileName, "yaml", 0, content);
}
+19
View File
@@ -0,0 +1,19 @@
import {Logger} from "../log";
export class TestLogger implements Logger {
error(message: string): void {
throw new Error(`Error: ${message}`);
}
warn(message: string): void {
console.warn(message);
}
info(message: string): void {
console.info(message);
}
log(message: string): void {
console.warn(message);
}
}
@@ -0,0 +1,113 @@
import {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider";
import {fileIdentifier} from "@github/actions-workflow-parser/workflows/file-reference";
export const testFileProvider: FileProvider = {
getFileContent: async ref => {
switch (fileIdentifier(ref)) {
case "monalisa/octocat/workflow.yaml@main":
return {
name: "monalisa/octocat/workflow.yaml",
content: `
on: workflow_call
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
`
};
case "monalisa/octocat/.github/workflows/non-reusable-workflow.yaml@main":
return {
name: "monalisa/octocat/.github/workflows/non-reusable-workflow.yaml",
content: `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
`
};
case "./reusable-workflow.yaml":
return {
name: "reusable-workflow.yaml",
content: `
on: workflow_call
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
`
};
case "./reusable-workflow-with-inputs.yaml":
return {
name: "reusable-workflow-with-inputs.yaml",
content: `
on:
workflow_call:
inputs:
username:
description: 'A username passed from the caller workflow'
required: true
type: string
name:
description: 'An optional name'
required: false
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
`
};
case "./reusable-workflow-with-inputs-no-description.yaml":
return {
name: "reusable-workflow-with-inputs.yaml",
content: `
on:
workflow_call:
inputs:
username:
required: true
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
`
};
case "./reusable-workflow-with-outputs.yaml":
return {
name: "reusable-workflow-with-outputs.yaml",
content: `
on:
workflow_call:
outputs:
build_id:
description: 'The resulting build ID'
value: 123
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
`
};
default:
throw new Error("File not found");
}
}
};
@@ -0,0 +1,29 @@
import {convertWorkflowTemplate, parseWorkflow, WorkflowTemplate} from "@github/actions-workflow-parser";
import {getWorkflowContext, WorkflowContext} from "../context/workflow-context";
import {nullTrace} from "../nulltrace";
import {findToken} from "../utils/find-token";
import {getPositionFromCursor} from "./cursor-position";
import {testFileProvider} from "./test-file-provider";
export async function testGetWorkflowContext(input: string): Promise<WorkflowContext> {
const [textDocument, pos] = getPositionFromCursor(input);
const result = parseWorkflow(
{
content: textDocument.getText(),
name: "wf.yaml"
},
nullTrace
);
let template: WorkflowTemplate | undefined;
if (result.value) {
template = await convertWorkflowTemplate(result.context, result.value, testFileProvider, {
fetchReusableWorkflowDepth: 1
});
}
const {path} = findToken(pos, result.value);
return getWorkflowContext(textDocument.uri, template, path);
}
@@ -0,0 +1,12 @@
import {isString} from "@github/actions-workflow-parser";
import {DefinitionType} from "@github/actions-workflow-parser/templates/schema/definition-type";
import {StringDefinition} from "@github/actions-workflow-parser/templates/schema/string-definition";
import {OPEN_EXPRESSION} from "@github/actions-workflow-parser/templates/template-constants";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/index";
export function isPotentiallyExpression(token: TemplateToken): boolean {
const isAlwaysExpression =
token.definition?.definitionType === DefinitionType.String && (token.definition as StringDefinition).isExpression;
const containsExpression = isString(token) && token.value.indexOf(OPEN_EXPRESSION) >= 0;
return isAlwaysExpression || containsExpression;
}
@@ -0,0 +1,369 @@
import {isScalar, parseWorkflow} from "@github/actions-workflow-parser";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
import {TokenType} from "@github/actions-workflow-parser/templates/tokens/types";
import {nullTrace} from "../nulltrace";
import {getPositionFromCursor} from "../test-utils/cursor-position";
import {findToken} from "./find-token";
type testTokenInfo = [definitionKey: string | null, tokenType: TokenType, literalValue?: string];
function getTokenInfo(token: TemplateToken | null): testTokenInfo | null {
if (!token) {
return null;
}
return [
token.definition?.key ?? null,
token.templateTokenType,
isScalar(token) ? token.toDisplayString() : undefined
].filter(x => x !== undefined) as testTokenInfo;
}
function testFindToken(input: string): {
parent: testTokenInfo | null;
key: testTokenInfo | null;
token: testTokenInfo | null;
path: testTokenInfo[];
} {
const [textDocument, pos] = getPositionFromCursor(input);
const result = parseWorkflow(
{
content: textDocument.getText(),
name: "wf.yaml"
},
nullTrace
);
const r = findToken(pos, result.value);
return {
parent: getTokenInfo(r.parent),
key: getTokenInfo(r.keyToken),
token: getTokenInfo(r.token),
path: r.path.map(x => getTokenInfo(x)!)
};
}
describe("find-token", () => {
it("on string key", () => {
expect(testFindToken(`o|n: push`)).toEqual({
path: [["workflow-root-strict", TokenType.Mapping]],
parent: ["workflow-root-strict", TokenType.Mapping],
key: null,
token: ["on-strict", TokenType.String, "on"]
});
});
it("on string value", () => {
expect(testFindToken(`on: pu|sh`)).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["on-strict", TokenType.String, "on"]
],
parent: ["workflow-root-strict", TokenType.Mapping],
key: ["on-strict", TokenType.String, "on"],
token: ["push-string", TokenType.String, "push"]
});
});
it("on mapping", () => {
expect(
testFindToken(`on:
pu|sh:`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["on-strict", TokenType.String, "on"],
["on-mapping-strict", TokenType.Mapping]
],
parent: ["on-mapping-strict", TokenType.Mapping],
key: null,
token: ["push", TokenType.String, "push"]
});
});
it("on sequence", () => {
expect(
testFindToken(`on:
- pu|sh`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["on-strict", TokenType.String, "on"],
["on-strict", TokenType.Sequence]
],
parent: ["on-strict", TokenType.Sequence],
key: null,
token: ["push-string", TokenType.String, "push"]
});
});
it("on sequence with cursor outside of sequence values", () => {
expect(
testFindToken(`on:
-| push`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["on-strict", TokenType.String, "on"],
["on-strict", TokenType.Sequence]
],
parent: ["on-strict", TokenType.Sequence],
key: null,
token: null
});
});
it("on sequence with multiple values", () => {
expect(
testFindToken(`on:
- push
- pull_request|`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["on-strict", TokenType.String, "on"],
["on-strict", TokenType.Sequence]
],
parent: ["on-strict", TokenType.Sequence],
key: null,
token: ["pull-request-string", TokenType.String, "pull_request"]
});
});
it("single-line sequence with multiple values", () => {
expect(
testFindToken(`on: push
jobs:
build:
runs-on: [ubuntu-latest, self|]`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["jobs", TokenType.String, "jobs"],
["jobs", TokenType.Mapping],
["job", TokenType.String, "build"],
["job-factory", TokenType.Mapping],
["runs-on", TokenType.String, "runs-on"],
["runs-on", TokenType.Sequence]
],
parent: ["runs-on", TokenType.Sequence],
key: null,
token: ["non-empty-string", TokenType.String, "self"]
});
});
it("jobs key", () => {
expect(
testFindToken(`on: push
jo|bs:
build:`)
).toEqual({
path: [["workflow-root-strict", TokenType.Mapping]],
parent: ["workflow-root-strict", TokenType.Mapping],
key: null,
token: ["jobs", TokenType.String, "jobs"]
});
});
it("value in job", () => {
expect(
testFindToken(`on: push
jobs:
build:
runs-on: ubu|`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["jobs", TokenType.String, "jobs"],
["jobs", TokenType.Mapping],
["job", TokenType.String, "build"],
["job-factory", TokenType.Mapping],
["runs-on", TokenType.String, "runs-on"]
],
parent: ["job-factory", TokenType.Mapping],
key: ["runs-on", TokenType.String, "runs-on"],
token: ["non-empty-string", TokenType.String, "ubu"]
});
});
it("key in job", () => {
expect(
testFindToken(`on: push
jobs:
build:
run|s-on: ubu`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["jobs", TokenType.String, "jobs"],
["jobs", TokenType.Mapping],
["job", TokenType.String, "build"],
["job-factory", TokenType.Mapping]
],
parent: ["job-factory", TokenType.Mapping],
key: null,
token: ["runs-on", TokenType.String, "runs-on"]
});
});
it("pos after colon in empty null mapping ", () => {
expect(
testFindToken(`on: push
jobs:
build:
continue-on-error:|`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["jobs", TokenType.String, "jobs"],
["jobs", TokenType.Mapping],
["job", TokenType.String, "build"],
["job-factory", TokenType.Mapping]
],
parent: ["job-factory", TokenType.Mapping],
key: ["boolean-strategy-context", TokenType.String, "continue-on-error"],
token: [null, TokenType.Null, ""]
});
});
it("pos after colon in empty string mapping", () => {
expect(
testFindToken(`on: push
jobs:
build:
container:|`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["jobs", TokenType.String, "jobs"],
["jobs", TokenType.Mapping],
["job", TokenType.String, "build"],
["job-factory", TokenType.Mapping]
],
parent: ["job-factory", TokenType.Mapping],
key: ["container", TokenType.String, "container"],
token: ["string", TokenType.String, ""]
});
});
it("pos after colon in mapping", () => {
expect(
testFindToken(`on: push
jobs:
build:
continue-on-error:|foo`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["jobs", TokenType.String, "jobs"],
["jobs", TokenType.Mapping],
["job", TokenType.String, "build"]
],
parent: ["jobs", TokenType.Mapping],
key: ["job", TokenType.String, "build"],
token: [null, TokenType.String, "continue-on-error:foo"]
});
});
it("pos after mapping key", () => {
expect(
testFindToken(`on: push
jobs:
build:
continue-on-error:| foo`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["jobs", TokenType.String, "jobs"],
["jobs", TokenType.Mapping],
["job", TokenType.String, "build"],
["job-factory", TokenType.Mapping]
],
parent: ["job-factory", TokenType.Mapping],
key: null,
token: null
});
});
it("pos at end of completed mapping key", () => {
expect(
testFindToken(`on: push
jobs:
build:
continue-on-error|: foo`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["jobs", TokenType.String, "jobs"],
["jobs", TokenType.Mapping],
["job", TokenType.String, "build"],
["job-factory", TokenType.Mapping]
],
parent: ["job-factory", TokenType.Mapping],
key: null,
token: ["boolean-strategy-context", TokenType.String, "continue-on-error"]
});
});
it("pos in mapping key without comment", () => {
expect(
testFindToken(`on: push
jobs:
build:
runs-|`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["jobs", TokenType.String, "jobs"],
["jobs", TokenType.Mapping],
["job", TokenType.String, "build"]
],
parent: ["jobs", TokenType.Mapping],
key: ["job", TokenType.String, "build"],
token: [null, TokenType.String, "runs-"]
});
});
it("pos in mapping key before comment", () => {
expect(
testFindToken(`on: push
jobs:
build:
runs-|
#`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["jobs", TokenType.String, "jobs"],
["jobs", TokenType.Mapping],
["job", TokenType.String, "build"]
],
parent: ["jobs", TokenType.Mapping],
key: ["job", TokenType.String, "build"],
token: [null, TokenType.String, "runs-"]
});
});
it("empty node", () => {
expect(
testFindToken(`on: push
jobs:
build:
concurrency:
runs-on: ubu|`)
).toEqual({
path: [
["workflow-root-strict", TokenType.Mapping],
["jobs", TokenType.String, "jobs"],
["jobs", TokenType.Mapping],
["job", TokenType.String, "build"],
["job-factory", TokenType.Mapping],
["runs-on", TokenType.String, "runs-on"]
],
parent: ["job-factory", TokenType.Mapping],
key: ["runs-on", TokenType.String, "runs-on"],
token: ["non-empty-string", TokenType.String, "ubu"]
});
});
});
+197
View File
@@ -0,0 +1,197 @@
import {isString} from "@github/actions-workflow-parser";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/index";
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token";
import {TokenType} from "@github/actions-workflow-parser/templates/tokens/types";
import {Position} from "vscode-languageserver-textdocument";
export function findInnerToken(pos: Position, root?: TemplateToken) {
const {token} = findToken(pos, root);
return token;
}
export type TokenResult = {
parent: TemplateToken | null;
keyToken: TemplateToken | null;
token: TemplateToken | null;
path: TemplateToken[];
};
/**
* Find a token at the given position in the document.
*
* If the position is within
* - the key of a mapping, parent will be the mapping, keyToken will be null, and token will be the key.
* - the value of a mapping, parent will be the mapping, keyToken will be the key for the value, and token will be the value
* - a sequence item, parent will be the sequence, keyToken will be null, and token will be the item
*
* @param pos Position within the document for which to find a token
* @param root Root node
* @returns Token result
*/
export function findToken(pos: Position, root?: TemplateToken): TokenResult {
if (!root) {
return {
token: null,
keyToken: null,
parent: null,
path: []
};
}
let lastMatching: TokenResult | null = null;
const s: TokenResult[] = [
{
token: root,
keyToken: null,
parent: null,
path: []
}
];
while (s.length > 0) {
const result = s.shift()!;
const {parent, token, keyToken, path} = result;
if (!token) {
break;
}
if (!posInToken(pos, token)) {
continue;
}
// Pos is in token, remember this token
lastMatching = result;
// Position is in token, enqueue children if there are any
switch (token.templateTokenType) {
case TokenType.Mapping:
const mappingToken = token as MappingToken;
for (const {key, value} of mappingToken) {
// If the position is within the key, immediately return it as the token.
if (posInToken(pos, key)) {
return {
parent: mappingToken,
keyToken: null,
token: key,
path: [...path, mappingToken]
};
}
// If pos, key, and value are on the same line, and value is an empty node (null, empty string) return early
// we cannot reliably check the position in that empty node
if (onSameLine(pos, key, value) && emptyNode(value)) {
return {
parent: mappingToken,
keyToken: key,
token: value,
path: [...path, mappingToken]
};
}
s.push({
parent: mappingToken,
keyToken: key,
token: value,
path: [...path, mappingToken, key]
});
}
continue;
case TokenType.Sequence:
const sequenceToken = token as SequenceToken;
for (const token of sequenceToken) {
s.push({
parent: sequenceToken,
keyToken: null,
token: token,
path: [...path, sequenceToken]
});
}
continue;
}
return {
token,
keyToken,
parent,
path
};
}
// Did not find a matching token, return the last matching token as parent
return {
token: null,
parent: lastMatching?.token ?? null,
keyToken: null,
path: lastMatching?.token ? [...lastMatching.path, lastMatching.token] : []
};
}
function posInToken(pos: Position, token: TemplateToken): boolean {
if (!token.range) {
return false;
}
const r = token.range;
// TokenRange is one-based, Position is zero-based
const tokenLine = pos.line + 1;
const tokenChar = pos.character + 1;
// Check lines
if (r.start.line > tokenLine || tokenLine > r.end.line) {
return false;
}
// Position is within the token lines. Check character/column if pos line matches
// start or end
if (
(r.start.line === tokenLine && tokenChar < r.start.column) ||
(r.end.line === tokenLine && tokenChar > r.end.column)
) {
return false;
}
return true;
}
function onSameLine(pos: Position, key: TemplateToken, value: TemplateToken): boolean {
if (!value.range) {
return false;
}
if (!key.range) {
return false;
}
if (value.range.start.line !== value.range.end.line) {
// Token occupies multiple lines, can't be an empty node
return false;
}
// TokenRange is one-based, Position is zero-based
const posLine = pos.line + 1;
if (posLine != value.range.start.line) {
return false;
}
return true;
}
function emptyNode(token: TemplateToken | null): boolean {
if (!token) {
return false;
}
if (token.templateTokenType === TokenType.Null) {
return true;
}
if (isString(token)) {
return token.value === "";
}
return false;
}
@@ -0,0 +1,269 @@
// Copied from https://github.com/microsoft/vscode/blob/c3b617f9b058e01cbc5cf00ec3813a7047326503/src/vs/editor/common/model/indentationGuesser.ts
// And adapted to work with languageserver-textdocument types
import {TextDocument} from "vscode-languageserver-textdocument";
enum CharCode {
Space = 32,
Tab = 9,
Comma = 44
}
function getLineContent(doc: TextDocument, lineNumber: number): string {
return doc.getText({
start: {
line: lineNumber - 1,
character: 0
},
end: {
line: lineNumber - 1,
character: Number.MAX_SAFE_INTEGER
}
});
}
function getLineCharCode(doc: TextDocument, lineNumber: number, index: number): number {
return doc
.getText({
start: {
line: lineNumber - 1,
character: index
},
end: {
line: lineNumber - 1,
character: index + 1
}
})
.charCodeAt(0);
}
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
class SpacesDiffResult {
public spacesDiff: number = 0;
public looksLikeAlignment: boolean = false;
}
/**
* Compute the diff in spaces between two line's indentation.
*/
function spacesDiff(a: string, aLength: number, b: string, bLength: number, result: SpacesDiffResult): void {
result.spacesDiff = 0;
result.looksLikeAlignment = false;
// This can go both ways (e.g.):
// - a: "\t"
// - b: "\t "
// => This should count 1 tab and 4 spaces
let i: number;
for (i = 0; i < aLength && i < bLength; i++) {
const aCharCode = a.charCodeAt(i);
const bCharCode = b.charCodeAt(i);
if (aCharCode !== bCharCode) {
break;
}
}
let aSpacesCnt = 0,
aTabsCount = 0;
for (let j = i; j < aLength; j++) {
const aCharCode = a.charCodeAt(j);
if (aCharCode === CharCode.Space) {
aSpacesCnt++;
} else {
aTabsCount++;
}
}
let bSpacesCnt = 0,
bTabsCount = 0;
for (let j = i; j < bLength; j++) {
const bCharCode = b.charCodeAt(j);
if (bCharCode === CharCode.Space) {
bSpacesCnt++;
} else {
bTabsCount++;
}
}
if (aSpacesCnt > 0 && aTabsCount > 0) {
return;
}
if (bSpacesCnt > 0 && bTabsCount > 0) {
return;
}
const tabsDiff = Math.abs(aTabsCount - bTabsCount);
const spacesDiff = Math.abs(aSpacesCnt - bSpacesCnt);
if (tabsDiff === 0) {
// check if the indentation difference might be caused by alignment reasons
// sometime folks like to align their code, but this should not be used as a hint
result.spacesDiff = spacesDiff;
if (spacesDiff > 0 && 0 <= bSpacesCnt - 1 && bSpacesCnt - 1 < a.length && bSpacesCnt < b.length) {
if (b.charCodeAt(bSpacesCnt) !== CharCode.Space && a.charCodeAt(bSpacesCnt - 1) === CharCode.Space) {
if (a.charCodeAt(a.length - 1) === CharCode.Comma) {
// This looks like an alignment desire: e.g.
// const a = b + c,
// d = b - c;
result.looksLikeAlignment = true;
}
}
}
return;
}
if (spacesDiff % tabsDiff === 0) {
result.spacesDiff = spacesDiff / tabsDiff;
return;
}
}
/**
* Result for a guessIndentation
*/
export interface IGuessedIndentation {
/**
* If indentation is based on spaces (`insertSpaces` = true), then what is the number of spaces that make an indent?
*/
tabSize: number;
/**
* Is indentation based on spaces?
*/
insertSpaces: boolean;
}
export function guessIndentation(
source: TextDocument,
defaultTabSize: number,
defaultInsertSpaces: boolean
): IGuessedIndentation {
// Look at most at the first 10k lines
const linesCount = Math.min(source.lineCount, 10000);
let linesIndentedWithTabsCount = 0; // number of lines that contain at least one tab in indentation
let linesIndentedWithSpacesCount = 0; // number of lines that contain only spaces in indentation
let previousLineText = ""; // content of latest line that contained non-whitespace chars
let previousLineIndentation = 0; // index at which latest line contained the first non-whitespace char
const ALLOWED_TAB_SIZE_GUESSES = [2, 4, 6, 8, 3, 5, 7]; // prefer even guesses for `tabSize`, limit to [2, 8].
const MAX_ALLOWED_TAB_SIZE_GUESS = 8; // max(ALLOWED_TAB_SIZE_GUESSES) = 8
const spacesDiffCount = [0, 0, 0, 0, 0, 0, 0, 0, 0]; // `tabSize` scores
const tmp = new SpacesDiffResult();
for (let lineNumber = 1; lineNumber <= linesCount; lineNumber++) {
const currentLineText = getLineContent(source, lineNumber);
const currentLineLength = currentLineText.length;
// if the text buffer is chunk based, so long lines are cons-string, v8 will flattern the string when we check charCode.
// checking charCode on chunks directly is cheaper.
const useCurrentLineText = currentLineLength <= 65536;
let currentLineHasContent = false; // does `currentLineText` contain non-whitespace chars
let currentLineIndentation = 0; // index at which `currentLineText` contains the first non-whitespace char
let currentLineSpacesCount = 0; // count of spaces found in `currentLineText` indentation
let currentLineTabsCount = 0; // count of tabs found in `currentLineText` indentation
for (let j = 0, lenJ = currentLineLength; j < lenJ; j++) {
const charCode = useCurrentLineText ? currentLineText.charCodeAt(j) : getLineCharCode(source, lineNumber, j);
if (charCode === CharCode.Tab) {
currentLineTabsCount++;
} else if (charCode === CharCode.Space) {
currentLineSpacesCount++;
} else {
// Hit non whitespace character on this line
currentLineHasContent = true;
currentLineIndentation = j;
break;
}
}
// Ignore empty or only whitespace lines
if (!currentLineHasContent) {
continue;
}
if (currentLineTabsCount > 0) {
linesIndentedWithTabsCount++;
} else if (currentLineSpacesCount > 1) {
linesIndentedWithSpacesCount++;
}
spacesDiff(previousLineText, previousLineIndentation, currentLineText, currentLineIndentation, tmp);
if (tmp.looksLikeAlignment) {
// if defaultInsertSpaces === true && the spaces count == tabSize, we may want to count it as valid indentation
//
// - item1
// - item2
//
// otherwise skip this line entirely
//
// const a = 1,
// b = 2;
if (!(defaultInsertSpaces && defaultTabSize === tmp.spacesDiff)) {
continue;
}
}
const currentSpacesDiff = tmp.spacesDiff;
if (currentSpacesDiff <= MAX_ALLOWED_TAB_SIZE_GUESS) {
spacesDiffCount[currentSpacesDiff]++;
}
previousLineText = currentLineText;
previousLineIndentation = currentLineIndentation;
}
let insertSpaces = defaultInsertSpaces;
if (linesIndentedWithTabsCount !== linesIndentedWithSpacesCount) {
insertSpaces = linesIndentedWithTabsCount < linesIndentedWithSpacesCount;
}
let tabSize = defaultTabSize;
// Guess tabSize only if inserting spaces...
if (insertSpaces) {
let tabSizeScore = insertSpaces ? 0 : 0.1 * linesCount;
// console.log("score threshold: " + tabSizeScore);
ALLOWED_TAB_SIZE_GUESSES.forEach(possibleTabSize => {
const possibleTabSizeScore = spacesDiffCount[possibleTabSize];
if (possibleTabSizeScore > tabSizeScore) {
tabSizeScore = possibleTabSizeScore;
tabSize = possibleTabSize;
}
});
// Let a tabSize of 2 win even if it is not the maximum
// (only in case 4 was guessed)
if (
tabSize === 4 &&
spacesDiffCount[4] > 0 &&
spacesDiffCount[2] > 0 &&
spacesDiffCount[2] >= spacesDiffCount[4] / 2
) {
tabSize = 2;
}
}
// console.log('--------------------------');
// console.log('linesIndentedWithTabsCount: ' + linesIndentedWithTabsCount + ', linesIndentedWithSpacesCount: ' + linesIndentedWithSpacesCount);
// console.log('spacesDiffCount: ' + spacesDiffCount);
// console.log('tabSize: ' + tabSize + ', tabSizeScore: ' + tabSizeScore);
return {
insertSpaces: insertSpaces,
tabSize: tabSize
};
}
+29
View File
@@ -0,0 +1,29 @@
import {Position as TokenPosition, TokenRange} from "@github/actions-workflow-parser/templates/tokens/token-range";
import {Position, Range} from "vscode-languageserver-types";
export function mapRange(range: TokenRange | undefined): Range {
if (!range) {
return {
start: {
line: 1,
character: 1
},
end: {
line: 1,
character: 1
}
};
}
return {
start: mapPosition(range.start),
end: mapPosition(range.end)
};
}
export function mapPosition(position: TokenPosition): Position {
return {
line: position.line - 1,
character: position.column - 1
};
}
+15
View File
@@ -0,0 +1,15 @@
import {TokenRange} from "@github/actions-workflow-parser/templates/tokens/token-range";
import {Position} from "vscode-languageserver-textdocument";
import {mapRange} from "./range";
export function getRelCharOffset(tokenRange: TokenRange, currentInput: string, pos: Position): number {
const range = mapRange(tokenRange);
if (range.start.line !== range.end.line) {
const lines = currentInput.split("\n");
const lineDiff = pos.line - range.start.line - 1;
const linesBeforeCusor = lines.slice(0, lineDiff);
return linesBeforeCusor.join("\n").length + pos.character + 1;
} else {
return pos.character - range.start.character;
}
}
@@ -0,0 +1,24 @@
import {data} from "@github/actions-expressions";
import {isBoolean, isNumber, isString} from "@github/actions-workflow-parser";
import {ScalarToken} from "@github/actions-workflow-parser/templates/tokens/scalar-token";
import {TokenType} from "@github/actions-workflow-parser/templates/tokens/types";
export function scalarToData(scalar: ScalarToken): data.ExpressionData {
if (isNumber(scalar)) {
return new data.NumberData(scalar.value);
}
if (isString(scalar)) {
return new data.StringData(scalar.value);
}
if (isBoolean(scalar)) {
return new data.BooleanData(scalar.value);
}
if (scalar.templateTokenType === TokenType.Null) {
return new data.Null();
}
return new data.StringData(scalar.toDisplayString());
}
@@ -0,0 +1,75 @@
import {getPositionFromCursor} from "../test-utils/cursor-position";
import {transform} from "./transform";
describe("transform", () => {
it("adds : at end of line", () => {
const [doc, pos] = getPositionFromCursor("on: push\njobs:\n build:\n runs-on|");
const [newDoc, newPos] = transform(doc, pos);
expect(newDoc.getText()).toEqual(`on: push
jobs:
build:
runs-on:`);
expect(newPos.character).toEqual(11);
});
it("adds : at end of line with trailing comment", () => {
const [doc, pos] = getPositionFromCursor("on: push\njobs:\n build:\n runs-on|\n#");
const [newDoc, newPos] = transform(doc, pos);
expect(newDoc.getText()).toEqual(`on: push
jobs:
build:
runs-on:
#`);
expect(newPos.character).toEqual(11);
});
it("adds placeholder node in empty sequence", () => {
const [doc, pos] = getPositionFromCursor(`on: push
jobs:
build:
runs-on:
- |`);
const [newDoc, newPos] = transform(doc, pos);
expect(newDoc.getText()).toEqual(`on: push
jobs:
build:
runs-on:
- key`);
expect(newPos.character).toEqual(9);
});
it("adds placeholder node in empty line", () => {
const [doc, pos] = getPositionFromCursor(`on: push
jobs:
build:
runs-on:
|`);
const [newDoc, newPos] = transform(doc, pos);
expect(newDoc.getText()).toEqual(`on: push
jobs:
build:
runs-on:
key:`);
expect(newPos.character).toEqual(7);
});
it("does not transform expression lines", () => {
const [doc, pos] = getPositionFromCursor(`on: push
jobs:
build:
runs-on:
\${{ github| }}`);
const [newDoc, newPos] = transform(doc, pos);
expect(newDoc.getText()).toEqual(`on: push
jobs:
build:
runs-on:
\${{ github }}`);
expect(newPos.character).toEqual(16);
});
});
+74
View File
@@ -0,0 +1,74 @@
import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {Range} from "vscode-languageserver-types";
const PLACEHOLDER_KEY = "key";
// Transform a document to work around YAML parsing issues
// Based on `_transform` in https://github.com/cschleiden/github-actions-parser/blob/main/src/lib/parser/complete.ts#L311
export function transform(doc: TextDocument, pos: Position): [TextDocument, Position] {
let offset = doc.offsetAt(pos);
const lineRange: Range = {
start: {line: pos.line, character: 0},
end: {line: pos.line, character: Number.MAX_SAFE_INTEGER}
};
let line = doc.getText(lineRange);
// If the line includes a new-line char, strip that out
const newLinePos = line.indexOf("\n");
if (newLinePos >= 0) {
line = line.substring(0, newLinePos);
}
lineRange.end.character = line.length;
const linePos = pos.character;
// Special case for Actions, if this line contains an expression marker, do _not_ transform. This is
// an ugly fix for auto-completion in multi-line YAML strings. At this point in the process, we cannot
// determine if a line is in such a multi-line string.
if (line.indexOf("${{") !== -1) {
return [doc, pos];
}
const containsColon = line.indexOf(":") !== -1;
if (!containsColon) {
const trimmedLine = line.trim();
if (trimmedLine === "" || trimmedLine === "-") {
// Pos in sequence or empty line
let spacer = "";
if (trimmedLine === "-" && !line.endsWith(" ")) {
spacer = " ";
offset++;
}
line =
line.substring(0, linePos) +
spacer +
PLACEHOLDER_KEY +
(trimmedLine === "-" ? "" : ":") +
line.substring(linePos);
// Adjust pos by one to prevent a sequence node being marked as active
offset++;
} else if (!trimmedLine.startsWith("-")) {
// Add `:` to end of line
line = line + ":";
}
}
const newDoc = TextDocument.create(doc.uri, doc.languageId, doc.version, doc.getText());
TextDocument.update(
newDoc,
[
{
range: lineRange,
text: line
}
],
newDoc.version + 1
);
return [newDoc, newDoc.positionAt(offset)];
}
+92
View File
@@ -0,0 +1,92 @@
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?.fetchActionMetadata) {
return;
}
const action = parseActionReference(step.uses.value);
if (!action) {
return;
}
const actionMetadata = await config.fetchActionMetadata(action);
if (actionMetadata === undefined) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(step.uses.range),
message: `Unable to resolve action \`${step.uses.value}\`, repository or version not found`
});
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);
}
}
const actionInputs = actionMetadata.inputs;
if (actionInputs === undefined) {
return;
}
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) && input.default === undefined
);
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,336 @@
import {DiagnosticSeverity} from "vscode-languageserver-types";
import {ActionMetadata, 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 = {
fetchActionMetadata: async (ref: ActionReference) => {
let metadata: ActionMetadata | undefined = undefined;
switch (ref.owner + "/" + ref.name + "@" + ref.ref) {
case "actions/checkout@v3":
metadata = {
inputs: {
repository: {
description: "Repository name with owner",
default: "${{ github.repository }}"
}
}
};
break;
case "actions/setup-node@v1":
metadata = {
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/deploy-pages@main":
metadata = {
inputs: {
token: {
required: true,
description: "token to use",
default: "${{ github.token }}"
}
}
};
break;
case "actions/cache@v1":
metadata = {
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;
case "actions/action-no-input@v1":
metadata = {};
}
return metadata;
}
};
describe("validate action steps", () => {
it("valid action reference", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([]);
});
it("action does not exist", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/does-not-exist@v3
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Unable to resolve action `actions/does-not-exist@v3`, repository or version not found",
range: {
end: {
character: 41,
line: 6
},
start: {
character: 16,
line: 6
}
},
severity: DiagnosticSeverity.Error
}
]);
});
it("action does not define inputs", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/action-no-input@v1
`;
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@v3
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("required input with default value", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/deploy-pages@main
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([]);
});
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@v3
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
}
]);
});
});
File diff suppressed because it is too large Load Diff
+191
View File
@@ -0,0 +1,191 @@
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {createDocument} from "./test-utils/document";
import {validate} from "./validate";
import {defaultValueProviders} from "./value-providers/default";
describe("validation", () => {
it("valid workflow", async () => {
const result = await validate(createDocument("wf.yaml", "on: push\njobs:\n build:\n runs-on: ubuntu-latest"));
expect(result.length).toBe(0);
});
it("missing jobs key", async () => {
const result = await validate(createDocument("wf.yaml", "on: push"));
expect(result.length).toBe(1);
expect(result[0]).toEqual({
message: "Required property is missing: jobs",
range: {
start: {
line: 0,
character: 0
},
end: {
line: 0,
character: 8
}
}
} as Diagnostic);
});
it("extraneous key", async () => {
const result = await validate(
createDocument(
"wf.yaml",
`on: push
unknown-key: foo
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo`
)
);
expect(result.length).toBe(1);
expect(result[0]).toEqual({
message: "Unexpected value 'unknown-key'",
range: {
end: {
character: 11,
line: 1
},
start: {
character: 0,
line: 1
}
}
} as Diagnostic);
});
it("single value not returned by suggested value provider", async () => {
const result = await validate(
createDocument(
"wf.yaml",
`on: push
jobs:
build:
runs-on: does-not-exist
steps:
- run: echo`
),
{valueProviderConfig: defaultValueProviders}
);
expect(result.length).toBe(0);
});
it("value in sequence not returned by value provider", async () => {
const result = await validate(
createDocument(
"wf.yaml",
`on: push
jobs:
build:
runs-on:
- ubuntu-latest
- does-not-exist
steps:
- run: echo`
),
{valueProviderConfig: defaultValueProviders}
);
expect(result.length).toBe(0);
});
it("single value not returned by allowed value provider", async () => {
const result = await validate(
createDocument(
"wf.yaml",
`on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo
build:
runs-on: ubuntu-latest
needs: test2
steps:
- run: echo`
),
{valueProviderConfig: defaultValueProviders}
);
expect(result[0]).toEqual({
message: "Value 'test2' is not valid",
severity: DiagnosticSeverity.Error,
range: {
end: {
character: 16,
line: 8
},
start: {
character: 11,
line: 8
}
}
} as Diagnostic);
});
it("unknown event type", async () => {
const result = await validate(
createDocument(
"wf.yaml",
`on: [push, check_run, pr]
jobs:
build:
runs-on:
- ubuntu-latest`
),
{valueProviderConfig: defaultValueProviders}
);
expect(result.length).toBe(1);
expect(result[0]).toEqual({
message: "Unexpected value 'pr'",
range: {
end: {
character: 24,
line: 0
},
start: {
character: 22,
line: 0
}
}
} as Diagnostic);
});
it("invalid cron string", async () => {
const result = await validate(
createDocument(
"wf.yaml",
`on:
schedule:
- cron: '0 0 * *'
jobs:
build:
runs-on: ubuntu-latest`
),
{valueProviderConfig: defaultValueProviders}
);
expect(result.length).toBe(1);
expect(result[0]).toEqual({
message: "Invalid cron string",
range: {
end: {
character: 21,
line: 2
},
start: {
character: 12,
line: 2
}
}
} as Diagnostic);
});
});
+227
View File
@@ -0,0 +1,227 @@
import {Evaluator, ExpressionEvaluationError, Lexer, Parser} from "@github/actions-expressions";
import {Expr} from "@github/actions-expressions/ast";
import {
convertWorkflowTemplate,
isBasicExpression,
isString,
parseWorkflow,
ParseWorkflowResult,
WorkflowTemplate
} from "@github/actions-workflow-parser";
import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert";
import {splitAllowedContext} from "@github/actions-workflow-parser/templates/allowed-context";
import {BasicExpressionToken} from "@github/actions-workflow-parser/templates/tokens/basic-expression-token";
import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
import {File} from "@github/actions-workflow-parser/workflows/file";
import {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider";
import {TextDocument} from "vscode-languageserver-textdocument";
import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types";
import {ActionMetadata, ActionReference} from "./action";
import {ContextProviderConfig} from "./context-providers/config";
import {getContext, Mode} from "./context-providers/default";
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
import {AccessError, wrapDictionary} from "./expression-validation/error-dictionary";
import {validatorFunctions} from "./expression-validation/functions";
import {error} from "./log";
import {nullTrace} from "./nulltrace";
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;
fetchActionMetadata?(action: ActionReference): Promise<ActionMetadata | undefined>;
fileProvider?: FileProvider;
};
/**
* Validates a workflow file
*
* @param textDocument Document to validate
* @returns Array of diagnostics
*/
export async function validate(textDocument: TextDocument, config?: ValidationConfig): Promise<Diagnostic[]> {
const file: File = {
name: textDocument.uri,
content: textDocument.getText()
};
const diagnostics: Diagnostic[] = [];
try {
const result: ParseWorkflowResult = parseWorkflow(file, nullTrace);
if (result.value) {
// Errors will be updated in the context. Attempt to do the conversion anyway in order to give the user more information
const template = await convertWorkflowTemplate(result.context, result.value, config?.fileProvider, {
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
errorPolicy: ErrorPolicy.TryConversion
});
// Validate expressions and value providers
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config);
}
// For now map parser errors directly to diagnostics
for (const error of result.context.errors.getErrors()) {
let range = mapRange(error.range);
diagnostics.push({
message: error.rawMessage,
range
});
}
} catch (e) {
error(`Unhandled error while validating: ${e}`);
}
return diagnostics;
}
async function additionalValidations(
diagnostics: Diagnostic[],
documentUri: URI,
template: WorkflowTemplate,
root: TemplateToken,
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
// If the token has a parent (map, sequence, etc), use this definition for validation
const validationToken = key || parent || token;
const validationDefinition = validationToken.definition;
// If this is an expression, validate it
if (isBasicExpression(token)) {
await validateExpression(
diagnostics,
token,
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 (token.range && validationDefinition) {
const defKey = validationDefinition.key;
if (defKey === "step-with") {
// Action inputs should be validated already in validateAction
continue;
}
if (defKey === "workflow-job-with") {
// Reusable workflow job inputs are validated by the parser
continue;
}
// Try a custom value provider first
let valueProvider = config?.valueProviderConfig?.[defKey];
if (!valueProvider) {
// fall back to default
valueProvider = defaultValueProviders[defKey];
}
if (valueProvider) {
const customValues = await valueProvider.get(getProviderContext(documentUri, template, root, token));
const customValuesMap = new Set(customValues.map(x => x.label));
if (isString(token)) {
if (!customValuesMap.has(token.value)) {
invalidValue(diagnostics, token, valueProvider.kind);
}
}
}
}
}
}
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
switch (kind) {
case ValueProviderKind.AllowedValues:
diagnostics.push({
message: `Value '${token.value}' is not valid`,
severity: DiagnosticSeverity.Error,
range: mapRange(token.range)
});
break;
// no messages for SuggestedValues
}
}
function getProviderContext(
documentUri: URI,
template: WorkflowTemplate,
root: TemplateToken,
token: TemplateToken
): WorkflowContext {
const {parent, path} = findToken(
{
line: token.range!.start.line - 1,
character: token.range!.start.column - 1
},
root
);
return getWorkflowContext(documentUri, template, path);
}
async function validateExpression(
diagnostics: Diagnostic[],
token: BasicExpressionToken,
allowedContext: string[],
contextProviderConfig: ContextProviderConfig | undefined,
workflowContext: WorkflowContext
) {
// Validate the expression
for (const expression of token.originalExpressions || [token]) {
const {namedContexts, functions} = splitAllowedContext(allowedContext);
let expr: Expr | undefined;
try {
const l = new Lexer(expression.expression);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
expr = p.parse();
} catch {
// Ignore any error here, we should've caught this earlier in the parsing process
continue;
}
try {
const context = await getContext(namedContexts, contextProviderConfig, workflowContext, Mode.Validation);
const e = new Evaluator(expr, wrapDictionary(context), validatorFunctions);
e.evaluate();
// Any invalid context access would've thrown an error via the `ErrorDictionary`, for now we don't have to check the actual
// result of the evaluation.
} catch (e) {
if (e instanceof AccessError) {
diagnostics.push({
message: `Context access might be invalid: ${e.keyName}`,
severity: DiagnosticSeverity.Warning,
range: mapRange(expression.range)
});
} else if (e instanceof ExpressionEvaluationError) {
diagnostics.push({
message: `Expression might be invalid: ${e.message}`,
severity: DiagnosticSeverity.Error,
range: mapRange(expression.range)
});
}
}
}
}
@@ -0,0 +1,139 @@
import {createDocument} from "./test-utils/document";
import {testFileProvider} from "./test-utils/test-file-provider";
import {validate} from "./validate";
describe("workflow references validation", () => {
it("invalid workflow reference", async () => {
const input = `
on: push
jobs:
build:
uses: monalisa/octocat/.github/workflows/non-reusable-workflow.yaml@main
`;
const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider
});
expect(result).toEqual([
{
message: "workflow_call key is not defined in the referenced workflow.",
range: {
start: {
character: 10,
line: 5
},
end: {
character: 76,
line: 5
}
}
}
]);
});
it("reference to a non-reusable workflow", async () => {
const input = `
on: push
jobs:
build:
uses: monalisa/octocat/workflow.yaml@not-a-branch
`;
const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider
});
expect(result).toEqual([
{
message: "Unable to find reusable workflow",
range: {
start: {
character: 10,
line: 5
},
end: {
character: 53,
line: 5
}
}
}
]);
});
it("valid reference to a reusable workflow", async () => {
const input = `
on: push
jobs:
build:
uses: monalisa/octocat/workflow.yaml@main
`;
const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider
});
expect(result).toEqual([]);
});
it("valid reference to a local workflow", async () => {
const input = `
on: push
jobs:
build:
uses: ./reusable-workflow.yaml
`;
const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider
});
expect(result).toEqual([]);
});
it("workflow reference without required inputs", async () => {
const input = `
on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
`;
const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider
});
expect(result).toEqual([
{
message: "Input username is required, but not provided while calling.",
range: {
start: {
character: 10,
line: 5
},
end: {
character: 46,
line: 5
}
}
}
]);
});
it("workflow reference with required inputs", async () => {
const input = `
on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
with:
username: monalisa
`;
const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider
});
expect(result).toEqual([]);
});
});
@@ -0,0 +1,29 @@
import {WorkflowContext} from "../context/workflow-context";
export interface Value {
/** Label of this value */
label: string;
/** Optional description to show when auto-completing */
description?: string;
/** Whether this value is deprecated */
deprecated?: boolean;
/** Alternative insert text, if not given `label` will be used */
insertText?: string;
}
export enum ValueProviderKind {
AllowedValues,
SuggestedValues
}
export type ValueProvider = {
kind: ValueProviderKind;
get: (context: WorkflowContext) => Promise<Value[]>;
};
export interface ValueProviderConfig {
[definitionKey: string]: ValueProvider;
}
@@ -0,0 +1,35 @@
import {WorkflowContext} from "../context/workflow-context";
import {ValueProviderConfig, ValueProviderKind} from "./config";
import {needs} from "./needs";
import {reusableJobInputs} from "./reusable-job-inputs";
import {stringsToValues} from "./strings-to-values";
export const DEFAULT_RUNNER_LABELS = [
"ubuntu-latest",
"ubuntu-22.04",
"ubuntu-20.04",
"ubuntu-18.04",
"windows-latest",
"windows-2022",
"windows-2019",
"macos-latest",
"macos-12",
"macos-11",
"macos-10.15",
"self-hosted"
];
export const defaultValueProviders: ValueProviderConfig = {
needs: {
kind: ValueProviderKind.AllowedValues,
get: needs
},
"workflow-job-with": {
kind: ValueProviderKind.AllowedValues,
get: async context => reusableJobInputs(context)
},
"runs-on": {
kind: ValueProviderKind.SuggestedValues,
get: async (_: WorkflowContext) => stringsToValues(DEFAULT_RUNNER_LABELS)
}
};
@@ -0,0 +1,107 @@
import {BooleanDefinition} from "@github/actions-workflow-parser/templates/schema/boolean-definition";
import {Definition} from "@github/actions-workflow-parser/templates/schema/definition";
import {DefinitionType} from "@github/actions-workflow-parser/templates/schema/definition-type";
import {MappingDefinition} from "@github/actions-workflow-parser/templates/schema/mapping-definition";
import {OneOfDefinition} from "@github/actions-workflow-parser/templates/schema/one-of-definition";
import {SequenceDefinition} from "@github/actions-workflow-parser/templates/schema/sequence-definition";
import {StringDefinition} from "@github/actions-workflow-parser/templates/schema/string-definition";
import {getWorkflowSchema} from "@github/actions-workflow-parser/workflows/workflow-schema";
import {Value} from "./config";
import {stringsToValues} from "./strings-to-values";
export function definitionValues(def: Definition, indentation: string): Value[] {
const schema = getWorkflowSchema();
if (def instanceof MappingDefinition) {
return mappingValues(def, schema.definitions, indentation);
}
if (def instanceof OneOfDefinition) {
return oneOfValues(def, schema.definitions, indentation);
}
if (def instanceof BooleanDefinition) {
return stringsToValues(["true", "false"]);
}
if (def instanceof StringDefinition && def.constant) {
return [
{
label: def.constant,
description: def.description
}
];
}
if (def instanceof SequenceDefinition) {
const itemDef = schema.getDefinition(def.itemType);
if (itemDef) {
return definitionValues(itemDef, indentation);
}
}
return [];
}
function mappingValues(
mappingDefinition: MappingDefinition,
definitions: {[key: string]: Definition},
indentation: string
): Value[] {
const properties: Value[] = [];
for (const [key, value] of Object.entries(mappingDefinition.properties)) {
let insertText: string | undefined;
let description: string | undefined;
if (value.type) {
const typeDef = definitions[value.type];
description = typeDef?.description;
if (typeDef) {
switch (typeDef.definitionType) {
case DefinitionType.Sequence:
insertText = `${key}:\n${indentation}- `;
break;
case DefinitionType.Mapping:
insertText = `${key}:\n${indentation}`;
break;
case DefinitionType.OneOf:
// No special insertText in this case
break;
default:
insertText = `${key}: `;
}
}
}
properties.push({
label: key,
description,
insertText
});
}
return properties;
}
function oneOfValues(
oneOfDefinition: OneOfDefinition,
definitions: {[key: string]: Definition},
indentation: string
): Value[] {
const values: Value[] = [];
for (const key of oneOfDefinition.oneOf) {
values.push(...definitionValues(definitions[key], indentation));
}
return distinctValues(values);
}
function distinctValues(values: Value[]): Value[] {
const map = new Map<string, Value>();
for (const value of values) {
map.set(value.label, value);
}
return Array.from(map.values());
}
@@ -0,0 +1,13 @@
import {WorkflowContext} from "../context/workflow-context";
import {Value} from "./config";
export async function needs(context: WorkflowContext): Promise<Value[]> {
if (!context.template) {
return [];
}
const uniquejobIDs = new Set(context.template.jobs.map(j => j.id)).values();
return Array.from(uniquejobIDs)
.filter(x => x.value !== context.job?.id.value)
.map(x => ({label: x.value}));
}
@@ -0,0 +1,40 @@
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
import {isMapping, isString} from "@github/actions-workflow-parser/templates/tokens/type-guards";
import {WorkflowContext} from "../context/workflow-context";
import {Value} from "./config";
import {stringsToValues} from "./strings-to-values";
export function reusableJobInputs(context: WorkflowContext): Value[] {
if (!context.reusableWorkflowJob?.["input-definitions"]) {
return [];
}
const values: Value[] = [];
for (const {key, value} of context.reusableWorkflowJob["input-definitions"]) {
if (!isString(key)) {
continue;
}
values.push({
label: key.value,
description: inputDescription(value)
});
}
return values;
}
function inputDescription(inputDef: TemplateToken): string | undefined {
if (!isMapping(inputDef)) {
return undefined;
}
const descriptionToken = inputDef.find("description");
if (!descriptionToken || !isString(descriptionToken)) {
return undefined;
}
return descriptionToken.value;
}
@@ -0,0 +1,5 @@
import {Value} from "./config";
export function stringsToValues(labels: string[]): Value[] {
return labels.map(x => ({label: x}));
}