Merge branch 'main' into thyeggman/implement-job-context-provider
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@github/actions-languageserver",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.58",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -38,10 +38,12 @@
|
||||
"prettier-fix": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@github/actions-languageservice": "^0.1.48",
|
||||
"@github/actions-languageservice": "^0.1.58",
|
||||
"@github/actions-workflow-parser": "*",
|
||||
"@octokit/rest": "^19.0.5",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
"vscode-languageserver-textdocument": "^1.0.7"
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"yaml": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { hover } from "@github/actions-languageservice/hover";
|
||||
import { registerLogger, setLogLevel } from "@github/actions-languageservice/log";
|
||||
import { validate } from "@github/actions-languageservice/validate";
|
||||
import {hover} from "@github/actions-languageservice/hover";
|
||||
import {registerLogger, setLogLevel} from "@github/actions-languageservice/log";
|
||||
import {validate, ValidationConfig} from "@github/actions-languageservice/validate";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {
|
||||
CompletionItem,
|
||||
Connection,
|
||||
@@ -12,12 +13,13 @@ import {
|
||||
TextDocuments,
|
||||
TextDocumentSyncKind
|
||||
} from "vscode-languageserver";
|
||||
import { TextDocument } from "vscode-languageserver-textdocument";
|
||||
import { contextProviders } from "./context-providers";
|
||||
import { InitializationOptions, RepositoryContext } from "./initializationOptions";
|
||||
import { onCompletion } from "./on-completion";
|
||||
import { TTLCache } from "./utils/cache";
|
||||
import { valueProviders } from "./value-providers";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {contextProviders} from "./context-providers";
|
||||
import {InitializationOptions, RepositoryContext} from "./initializationOptions";
|
||||
import {onCompletion} from "./on-completion";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {valueProviders} from "./value-providers";
|
||||
import {getActionInputs} from "./value-providers/action-inputs";
|
||||
|
||||
export function initConnection(connection: Connection) {
|
||||
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
|
||||
@@ -82,11 +84,21 @@ export function initConnection(connection: Connection) {
|
||||
|
||||
async function validateTextDocument(textDocument: TextDocument): Promise<void> {
|
||||
const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri));
|
||||
const result = await validate(
|
||||
textDocument,
|
||||
valueProviders(sessionToken, repoContext, cache),
|
||||
contextProviders(sessionToken, repoContext, cache)
|
||||
);
|
||||
|
||||
const config: ValidationConfig = {
|
||||
valueProviderConfig: valueProviders(sessionToken, repoContext, cache),
|
||||
contextProviderConfig: contextProviders(sessionToken, repoContext, cache),
|
||||
getActionInputs: async action => {
|
||||
if (sessionToken) {
|
||||
const octokit = new Octokit({
|
||||
auth: sessionToken
|
||||
});
|
||||
return await getActionInputs(octokit, cache, action);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const result = await validate(textDocument, config);
|
||||
|
||||
connection.sendDiagnostics({uri: textDocument.uri, diagnostics: result});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {ValueProviderKind} from "@github/actions-languageservice/value-providers
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {getActionInputValues} from "./value-providers/action-inputs";
|
||||
import {getEnvironments} from "./value-providers/job-environment";
|
||||
import {getRunnerLabels} from "./value-providers/runs-on";
|
||||
|
||||
@@ -32,6 +33,10 @@ export function valueProviders(
|
||||
"runs-on": {
|
||||
kind: ValueProviderKind.SuggestedValues,
|
||||
get: (_: WorkflowContext) => getRunnerLabels(octokit, cache, repo.owner, repo.name)
|
||||
},
|
||||
"step-with": {
|
||||
kind: ValueProviderKind.AllowedValues,
|
||||
get: (context: WorkflowContext) => getActionInputValues(octokit, cache, context)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
actionIdentifier,
|
||||
ActionInputs,
|
||||
ActionReference,
|
||||
parseActionReference
|
||||
} from "@github/actions-languageservice/action";
|
||||
import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context";
|
||||
import {Value} from "@github/actions-languageservice/value-providers/config";
|
||||
import {isActionStep} from "@github/actions-workflow-parser/model/type-guards";
|
||||
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
|
||||
import {parse} from "yaml";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
|
||||
export async function getActionInputs(
|
||||
client: Octokit,
|
||||
cache: TTLCache,
|
||||
action: ActionReference
|
||||
): Promise<ActionInputs | undefined> {
|
||||
const inputs = await cache.get(`${actionIdentifier(action)}/action-inputs`, undefined, () =>
|
||||
fetchActionInputs(client, action)
|
||||
);
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
export async function getActionInputValues(
|
||||
client: Octokit,
|
||||
cache: TTLCache,
|
||||
context: WorkflowContext
|
||||
): Promise<Value[]> {
|
||||
if (!context.step || !isActionStep(context.step)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const action = parseActionReference(context.step.uses.value);
|
||||
if (!action) {
|
||||
return [];
|
||||
}
|
||||
const inputs = await getActionInputs(client, cache, action);
|
||||
if (!inputs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(inputs).map(([inputName, input]) => {
|
||||
return {
|
||||
label: inputName,
|
||||
description: input.description,
|
||||
deprecated: input.deprecationMessage !== undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchActionInputs(client: Octokit, action: ActionReference): Promise<ActionInputs | undefined> {
|
||||
const metadata = await getActionMetadata(client, action);
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parseActionMetadata(metadata);
|
||||
}
|
||||
|
||||
async function getActionMetadata(client: Octokit, action: ActionReference): Promise<string | undefined> {
|
||||
let resp: RestEndpointMethodTypes["repos"]["getContent"]["response"];
|
||||
try {
|
||||
resp = await client.repos.getContent({
|
||||
owner: action.owner,
|
||||
repo: action.name,
|
||||
ref: action.ref,
|
||||
path: action.path ? `${action.path}/action.yml` : "action.yml"
|
||||
});
|
||||
} catch (e: any) {
|
||||
// If action.yml doesn't exist, try action.yaml
|
||||
if (e.status === 404) {
|
||||
resp = await client.repos.getContent({
|
||||
owner: action.owner,
|
||||
repo: action.name,
|
||||
ref: action.ref,
|
||||
path: action.path ? `${action.path}/action.yaml` : "action.yaml"
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28
|
||||
// Ignore directories (array of files) and non-file content
|
||||
if (resp.data === undefined || Array.isArray(resp.data) || resp.data.type !== "file") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (resp.data.content === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const text = Buffer.from(resp.data.content, "base64").toString("utf8");
|
||||
// Remove any null bytes
|
||||
return text.replace(/\0/g, "");
|
||||
}
|
||||
|
||||
type ActionMetadata = {
|
||||
inputs?: ActionInputs;
|
||||
};
|
||||
|
||||
// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions
|
||||
async function parseActionMetadata(content: string): Promise<ActionInputs> {
|
||||
const metadata: ActionMetadata = parse(content);
|
||||
return metadata.inputs ?? {};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@github/actions-languageservice",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.58",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -38,6 +38,7 @@
|
||||
"prettier-fix": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@github/actions-expressions": "*",
|
||||
"@github/actions-workflow-parser": "*",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#inputs
|
||||
export type ActionInput = {
|
||||
description: string;
|
||||
required?: boolean;
|
||||
default?: string;
|
||||
deprecationMessage?: string;
|
||||
};
|
||||
|
||||
export type ActionInputs = Record<string, ActionInput>;
|
||||
|
||||
export type ActionReference = {
|
||||
owner: string;
|
||||
name: string;
|
||||
ref: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
// https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsuses
|
||||
export function parseActionReference(uses: string): ActionReference | undefined {
|
||||
if (!uses || uses.startsWith("docker://") || uses.startsWith("./") || uses.startsWith(".\\")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [action, ref] = uses.split("@");
|
||||
const [owner, name, ...pathSegments] = action.split(/[\\/]/).filter(s => s.length > 0);
|
||||
if (!owner || !name) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
return {
|
||||
owner,
|
||||
name,
|
||||
ref
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
name,
|
||||
ref,
|
||||
path: pathSegments.join("/")
|
||||
};
|
||||
}
|
||||
|
||||
export function actionIdentifier(ref: ActionReference): string {
|
||||
if (ref.path) {
|
||||
return `${ref.owner}/${ref.name}/${ref.ref}/${ref.path}`;
|
||||
}
|
||||
return `${ref.owner}/${ref.name}/${ref.ref}`;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {data} from "@github/actions-expressions";
|
||||
import {CompletionItemKind} from "vscode-languageserver-types";
|
||||
import {complete, getExpressionInput} from "./complete";
|
||||
import {ContextProviderConfig} from "./context-providers/config";
|
||||
import {registerLogger} from "./log";
|
||||
@@ -30,13 +31,17 @@ describe("expressions", () => {
|
||||
|
||||
expect(test("${{ gh |")).toBe(" gh ");
|
||||
expect(test("${{ gh |}}")).toBe(" gh ");
|
||||
expect(test("${{ vars| == 'test' }}")).toBe(" vars");
|
||||
expect(test("${{ fromJso|('test').bar == 'test' }}")).toBe(" fromJso");
|
||||
expect(test("${{ github.| == 'test' }}")).toBe(" github.");
|
||||
expect(test("test ${{ github.| == 'test' }}")).toBe(" github.");
|
||||
expect(test("${{ vars }} ${{ gh |}}")).toBe(" gh ");
|
||||
});
|
||||
|
||||
describe("top-level auto-complete", () => {
|
||||
it("single region", async () => {
|
||||
const input = "run-name: ${{ | }}";
|
||||
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
|
||||
const result = await complete(...getPositionFromCursor(input), undefined, undefined);
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
@@ -70,6 +75,42 @@ describe("expressions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("single region with existing condition", async () => {
|
||||
const input = "run-name: ${{ g| == 'test' }}";
|
||||
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
"fromJson",
|
||||
"join",
|
||||
"startsWith",
|
||||
"toJson"
|
||||
]);
|
||||
});
|
||||
|
||||
it("multiple regions with partial function", async () => {
|
||||
const input = "run-name: Run a ${{ inputs.test }} one-line script ${{ from|('test') == inputs.name }}";
|
||||
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
"fromJson",
|
||||
"join",
|
||||
"startsWith",
|
||||
"toJson"
|
||||
]);
|
||||
});
|
||||
|
||||
it("multiple regions", async () => {
|
||||
const input = "run-name: test-${{ github }}-${{ | }}";
|
||||
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
|
||||
@@ -320,6 +361,23 @@ jobs:
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("github context includes expected keys", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: echo \${{ github.| }}
|
||||
`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), undefined, undefined);
|
||||
|
||||
expect(result.map(x => x.label)).toContain("actor");
|
||||
});
|
||||
|
||||
describe("steps context", () => {
|
||||
it("includes defined step IDs", async () => {
|
||||
const input = `
|
||||
@@ -615,4 +673,53 @@ jobs:
|
||||
expect(result.map(x => x.label)).toEqual(["color"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("context completion items include kind and insert text", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: echo \${{ | }}.txt
|
||||
`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
|
||||
|
||||
// Built-in function
|
||||
const toJSON = result.find(x => x.label === "toJson");
|
||||
expect(toJSON).toBeDefined();
|
||||
expect(toJSON!.kind).toBe(CompletionItemKind.Function);
|
||||
expect(toJSON!.insertText).toBe("toJson()");
|
||||
|
||||
// Function from context
|
||||
const hashFiles = result.find(x => x.label === "hashFiles");
|
||||
expect(hashFiles).toBeDefined();
|
||||
expect(hashFiles!.kind).toBe(CompletionItemKind.Function);
|
||||
expect(hashFiles!.insertText).toBe("hashFiles()");
|
||||
|
||||
// Not a function
|
||||
const github = result.find(x => x.label === "github");
|
||||
expect(github).toBeDefined();
|
||||
expect(github!.kind).toBe(CompletionItemKind.Variable);
|
||||
expect(github!.insertText).toBeUndefined();
|
||||
});
|
||||
|
||||
it("function parentheses are not inserted when parentheses already exist", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ toJS|(github.event) }}
|
||||
`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
|
||||
|
||||
expect(result.find(x => x.label === "toJson")!.insertText).toBe("toJson");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,6 +199,24 @@ jobs:
|
||||
expect(result[0].label).toEqual("my-custom-label");
|
||||
});
|
||||
|
||||
it("custom value providers for sequences", async () => {
|
||||
const input = "on: push\njobs:\n build:\n runs-on: [m|]";
|
||||
|
||||
const config: ValueProviderConfig = {
|
||||
"runs-on": {
|
||||
kind: ValueProviderKind.SuggestedValues,
|
||||
get: async (_: WorkflowContext) => {
|
||||
return [{label: "my-custom-label"}];
|
||||
}
|
||||
}
|
||||
};
|
||||
const result = await complete(...getPositionFromCursor(input), config);
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(1);
|
||||
expect(result[0].label).toEqual("my-custom-label");
|
||||
});
|
||||
|
||||
it("does not show parent mapping sibling keys", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {complete as completeExpression} from "@github/actions-expressions";
|
||||
import {CompletionItem as ExpressionCompletionItem} from "@github/actions-expressions/completion";
|
||||
import {convertWorkflowTemplate, isSequence, isString, parseWorkflow} from "@github/actions-workflow-parser";
|
||||
import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert";
|
||||
import {DefinitionType} from "@github/actions-workflow-parser/templates/schema/definition-type";
|
||||
import {StringDefinition} from "@github/actions-workflow-parser/templates/schema/string-definition";
|
||||
import {CLOSE_EXPRESSION, OPEN_EXPRESSION} from "@github/actions-workflow-parser/templates/template-constants";
|
||||
import {OPEN_EXPRESSION} from "@github/actions-workflow-parser/templates/template-constants";
|
||||
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/index";
|
||||
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
|
||||
import {TokenType} from "@github/actions-workflow-parser/templates/tokens/types";
|
||||
import {File} from "@github/actions-workflow-parser/workflows/file";
|
||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {CompletionItem, Range, TextEdit} from "vscode-languageserver-types";
|
||||
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
||||
import {ContextProviderConfig} from "./context-providers/config";
|
||||
import {getContext, Mode} from "./context-providers/default";
|
||||
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
|
||||
import {nullTrace} from "./nulltrace";
|
||||
import {getAllowedContext} from "./utils/allowed-context";
|
||||
import {findToken} from "./utils/find-token";
|
||||
import {mapRange} from "./utils/range";
|
||||
import {transform} from "./utils/transform";
|
||||
@@ -29,14 +29,7 @@ export function getExpressionInput(input: string, pos: number): string {
|
||||
return input;
|
||||
}
|
||||
|
||||
// Find end marker after the cursor position
|
||||
let endPos = input.indexOf(CLOSE_EXPRESSION, pos);
|
||||
if (endPos === -1) {
|
||||
// Assume an unfinished expression like "${{ someinput.|"
|
||||
endPos = input.length;
|
||||
}
|
||||
|
||||
return input.substring(startPos + OPEN_EXPRESSION.length, endPos);
|
||||
return input.substring(startPos + OPEN_EXPRESSION.length, pos);
|
||||
}
|
||||
|
||||
export async function complete(
|
||||
@@ -85,17 +78,19 @@ export async function complete(
|
||||
if (token.range!.start[0] !== token.range!.end[0]) {
|
||||
const lines = currentInput.split("\n");
|
||||
const linesBeforeCusor = lines.slice(0, lineDiff);
|
||||
relCharPos = linesBeforeCusor.join("\n").length + newPos.character;
|
||||
relCharPos = linesBeforeCusor.join("\n").length + 1 + newPos.character;
|
||||
} else {
|
||||
relCharPos = newPos.character - token.range!.start[1];
|
||||
relCharPos = newPos.character - token.range!.start[1] + 1;
|
||||
}
|
||||
|
||||
const expressionInput = (getExpressionInput(currentInput, relCharPos) || "").trim();
|
||||
|
||||
const allowedContext = getAllowedContext(token, parent);
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion);
|
||||
|
||||
return completeExpression(expressionInput, context, []);
|
||||
return completeExpression(expressionInput, context, []).map(item =>
|
||||
mapExpressionCompletionItem(item, currentInput[relCharPos])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,11 +101,12 @@ export async function complete(
|
||||
}
|
||||
|
||||
return values.map(value => {
|
||||
const item = CompletionItem.create(value.label);
|
||||
item.detail = value.description;
|
||||
if (replaceRange) {
|
||||
item.textEdit = TextEdit.replace(replaceRange, value.label);
|
||||
}
|
||||
const item: CompletionItem = {
|
||||
label: value.label,
|
||||
detail: value.description,
|
||||
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
|
||||
textEdit: replaceRange ? TextEdit.replace(replaceRange, value.label) : undefined
|
||||
};
|
||||
|
||||
return item;
|
||||
});
|
||||
@@ -129,21 +125,22 @@ async function getValues(
|
||||
|
||||
const existingValues = getExistingValues(token, parent);
|
||||
|
||||
if (keyToken?.definition?.key) {
|
||||
const customValues = await valueProviderConfig?.[keyToken.definition.key]?.get(workflowContext);
|
||||
// Use the value providers from the parent if the current key is null
|
||||
const valueProviderToken = keyToken || parent;
|
||||
|
||||
const customValueProvider =
|
||||
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
|
||||
if (customValueProvider) {
|
||||
const customValues = await customValueProvider.get(workflowContext);
|
||||
if (customValues) {
|
||||
return filterAndSortCompletionOptions(customValues, existingValues);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the value provider from the parent if we don't have a value provider for the current key
|
||||
const valueProvider =
|
||||
(keyToken?.definition?.key && defaultValueProviders[keyToken.definition.key]) ||
|
||||
(parent.definition?.key && defaultValueProviders[parent.definition.key]);
|
||||
|
||||
if (valueProvider) {
|
||||
const values = await valueProvider.get(workflowContext);
|
||||
const defaultValueProvider =
|
||||
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
|
||||
if (defaultValueProvider) {
|
||||
const values = await defaultValueProvider.get(workflowContext);
|
||||
return filterAndSortCompletionOptions(values, existingValues);
|
||||
}
|
||||
|
||||
@@ -198,3 +195,17 @@ function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<s
|
||||
options.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return options;
|
||||
}
|
||||
|
||||
function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPos: string): CompletionItem {
|
||||
let insertText: string | undefined;
|
||||
// Insert parentheses if the cursor is after a function
|
||||
// and the function does not have any parantheses already
|
||||
if (item.function) {
|
||||
insertText = charAfterPos === "(" ? item.label : item.label + "()";
|
||||
}
|
||||
return {
|
||||
label: item.label,
|
||||
insertText: insertText,
|
||||
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {data} from "@github/actions-expressions";
|
||||
import {Kind} from "@github/actions-expressions/data/expressiondata";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {ContextProviderConfig} from "./config";
|
||||
import {getGithubContext} from "./github";
|
||||
import {getInputsContext} from "./inputs";
|
||||
import {getJobContext} from "./job";
|
||||
import {getMatrixContext} from "./matrix";
|
||||
@@ -45,6 +46,18 @@ export async function getContext(
|
||||
|
||||
function getDefaultContext(name: string, workflowContext: WorkflowContext, mode: Mode): ContextValue | undefined {
|
||||
switch (name) {
|
||||
case "github":
|
||||
return getGithubContext();
|
||||
|
||||
case "inputs":
|
||||
return getInputsContext(workflowContext);
|
||||
|
||||
case "matrix":
|
||||
return getMatrixContext(workflowContext, mode);
|
||||
|
||||
case "needs":
|
||||
return getNeedsContext(workflowContext);
|
||||
|
||||
case "runner":
|
||||
return objectToDictionary({
|
||||
os: "Linux",
|
||||
@@ -54,12 +67,6 @@ function getDefaultContext(name: string, workflowContext: WorkflowContext, mode:
|
||||
temp: "/home/runner/work/_temp"
|
||||
});
|
||||
|
||||
case "needs":
|
||||
return getNeedsContext(workflowContext);
|
||||
|
||||
case "inputs":
|
||||
return getInputsContext(workflowContext);
|
||||
|
||||
case "secrets":
|
||||
return objectToDictionary({GITHUB_TOKEN: "***"});
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {data} from "@github/actions-expressions";
|
||||
|
||||
export function getGithubContext(): data.Dictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
||||
const keys = [
|
||||
"action",
|
||||
"action_path",
|
||||
"action_ref",
|
||||
"action_repository",
|
||||
"action_status",
|
||||
"actor",
|
||||
"api_url",
|
||||
"base_ref",
|
||||
"env",
|
||||
"event",
|
||||
"event_name",
|
||||
"event_path",
|
||||
"graphql_url",
|
||||
"head_ref",
|
||||
"job",
|
||||
"ref",
|
||||
"ref_name",
|
||||
"ref_protected",
|
||||
"ref_type",
|
||||
"path",
|
||||
"repository",
|
||||
"repository_owner",
|
||||
"repositoryUrl",
|
||||
"retention_days",
|
||||
"run_id",
|
||||
"run_number",
|
||||
"run_attempt",
|
||||
"secret_source",
|
||||
"server_url",
|
||||
"sha",
|
||||
"token",
|
||||
"triggering_actor",
|
||||
"workflow",
|
||||
"workspace"
|
||||
];
|
||||
|
||||
return new data.Dictionary(
|
||||
...keys.map(key => {
|
||||
return {key, value: new data.Null()};
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
|
||||
|
||||
export function getAllowedContext(token: TemplateToken, parent: TemplateToken | null | undefined): string[] {
|
||||
// Workaround for https://github.com/github/c2c-actions-experience/issues/6876
|
||||
// Context is inherited from the parent
|
||||
const allowedContext = new Set<string>();
|
||||
for (const t of [token, parent]) {
|
||||
if (t?.definition?.readerContext) {
|
||||
for (const context of t.definition.readerContext) {
|
||||
allowedContext.add(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(allowedContext);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {isMapping} from "@github/actions-workflow-parser";
|
||||
import {isActionStep} from "@github/actions-workflow-parser/model/type-guards";
|
||||
import {Step} from "@github/actions-workflow-parser/model/workflow-template";
|
||||
import {ScalarToken} from "@github/actions-workflow-parser/templates/tokens/scalar-token";
|
||||
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
|
||||
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
import {parseActionReference} from "./action";
|
||||
import {mapRange} from "./utils/range";
|
||||
import {ValidationConfig} from "./validate";
|
||||
|
||||
export async function validateAction(
|
||||
diagnostics: Diagnostic[],
|
||||
stepToken: TemplateToken,
|
||||
step: Step | undefined,
|
||||
config: ValidationConfig | undefined
|
||||
): Promise<void> {
|
||||
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.getActionInputs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = parseActionReference(step.uses.value);
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionInputs = await config.getActionInputs(action);
|
||||
if (actionInputs === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let withKey: ScalarToken | undefined;
|
||||
let withToken: TemplateToken | undefined;
|
||||
for (const {key, value} of stepToken) {
|
||||
if (key.toString() === "with") {
|
||||
withKey = key;
|
||||
withToken = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const stepInputs = new Map<string, ScalarToken>();
|
||||
if (withToken && isMapping(withToken)) {
|
||||
for (const {key} of withToken) {
|
||||
stepInputs.set(key.toString(), key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [input, inputToken] of stepInputs) {
|
||||
if (!actionInputs[input]) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(inputToken.range),
|
||||
message: `Invalid action input '${input}'`
|
||||
});
|
||||
}
|
||||
|
||||
const deprecationMessage = actionInputs[input]?.deprecationMessage;
|
||||
if (deprecationMessage) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
range: mapRange(inputToken.range),
|
||||
message: deprecationMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const missingRequiredInputs = Object.entries(actionInputs).filter(
|
||||
([inputName, input]) => input.required && !stepInputs.has(inputName)
|
||||
);
|
||||
|
||||
if (missingRequiredInputs.length > 0) {
|
||||
const message =
|
||||
missingRequiredInputs.length === 1
|
||||
? `Missing required input \`${missingRequiredInputs[0][0]}\``
|
||||
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
|
||||
message: message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
import {ActionInput, ActionReference} from "./action";
|
||||
import {registerLogger} from "./log";
|
||||
import {createDocument} from "./test-utils/document";
|
||||
import {TestLogger} from "./test-utils/logger";
|
||||
import {validate, ValidationConfig} from "./validate";
|
||||
import {ValueProviderKind} from "./value-providers/config";
|
||||
|
||||
registerLogger(new TestLogger());
|
||||
|
||||
const validationConfig: ValidationConfig = {
|
||||
getActionInputs: async (ref: ActionReference) => {
|
||||
let inputs: Record<string, ActionInput> = {};
|
||||
switch (ref.owner + "/" + ref.name + "@" + ref.ref) {
|
||||
case "actions/checkout@v3":
|
||||
inputs = {
|
||||
repository: {
|
||||
description: "Repository name with owner",
|
||||
default: "${{ github.repository }}"
|
||||
}
|
||||
};
|
||||
break;
|
||||
case "actions/setup-node@v1":
|
||||
inputs = {
|
||||
version: {
|
||||
description: "Deprecated. Use node-version instead. Will not be supported after October 1, 2019",
|
||||
deprecationMessage:
|
||||
"The version property will not be supported after October 1, 2019. Use node-version instead"
|
||||
}
|
||||
};
|
||||
break;
|
||||
case "actions/cache@v1":
|
||||
inputs = {
|
||||
path: {
|
||||
description: "A directory to store and save the cache",
|
||||
required: true
|
||||
},
|
||||
key: {
|
||||
description: "An explicit key for restoring and saving the cache",
|
||||
required: true
|
||||
},
|
||||
"restore-keys": {
|
||||
description: "An ordered list of keys to use for restoring the cache if no cache hit occurred for key",
|
||||
required: false
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
};
|
||||
describe("validate action steps", () => {
|
||||
it("valid action reference", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), validationConfig);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("invalid input", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
notanoption: true
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), validationConfig);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Invalid action input 'notanoption'",
|
||||
range: {
|
||||
end: {
|
||||
character: 19,
|
||||
line: 8
|
||||
},
|
||||
start: {
|
||||
character: 8,
|
||||
line: 8
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Error
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("deprecated input", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
version: 10
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), validationConfig);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "The version property will not be supported after October 1, 2019. Use node-version instead",
|
||||
range: {
|
||||
end: {
|
||||
character: 15,
|
||||
line: 8
|
||||
},
|
||||
start: {
|
||||
character: 8,
|
||||
line: 8
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Warning
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("missing required input", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
key: \${{ runner.os }}-node-\${{ hashFiles('**/package-lock.json') }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), validationConfig);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Missing required input `path`",
|
||||
range: {
|
||||
end: {
|
||||
character: 10,
|
||||
line: 7
|
||||
},
|
||||
start: {
|
||||
character: 6,
|
||||
line: 7
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Error
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("multiple missing required inputs", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
restore-keys: \${{ runner.os }}-node-
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), validationConfig);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Missing required inputs: `path`, `key`",
|
||||
range: {
|
||||
end: {
|
||||
character: 10,
|
||||
line: 7
|
||||
},
|
||||
start: {
|
||||
character: 6,
|
||||
line: 7
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Error
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("missing required inputs without a `with` key", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), validationConfig);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Missing required inputs: `path`, `key`",
|
||||
range: {
|
||||
end: {
|
||||
character: 0,
|
||||
line: 7
|
||||
},
|
||||
start: {
|
||||
character: 6,
|
||||
line: 6
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Error
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips extra validation from custom value provider", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
notanoption: true
|
||||
`;
|
||||
const config = validationConfig;
|
||||
config.valueProviderConfig = {
|
||||
"step-with": {
|
||||
kind: ValueProviderKind.AllowedValues,
|
||||
get: async () => {
|
||||
return [{label: "repository", description: "Repository name with owner."}];
|
||||
}
|
||||
}
|
||||
};
|
||||
const result = await validate(createDocument("wf.yaml", input), config);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Invalid action input 'notanoption'",
|
||||
range: {
|
||||
end: {
|
||||
character: 19,
|
||||
line: 8
|
||||
},
|
||||
start: {
|
||||
character: 8,
|
||||
line: 8
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Error
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -367,7 +367,7 @@ jobs:
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-line strings", () => {
|
||||
describe("multi-line strings warnings", () => {
|
||||
it("indented |", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
@@ -489,6 +489,124 @@ jobs:
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-line strings errors", () => {
|
||||
it("indented |", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
first line
|
||||
test \${{ fromJSON2('') }}
|
||||
test2`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Unrecognized function: 'fromJSON2'",
|
||||
range: {
|
||||
end: {
|
||||
character: 35,
|
||||
line: 7
|
||||
},
|
||||
start: {
|
||||
character: 15,
|
||||
line: 7
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("indented |+", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |+
|
||||
first line
|
||||
test \${{ fromJSON2('') }}
|
||||
test2`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Unrecognized function: 'fromJSON2'",
|
||||
range: {
|
||||
end: {
|
||||
character: 35,
|
||||
line: 7
|
||||
},
|
||||
start: {
|
||||
character: 15,
|
||||
line: 7
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("indented >", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: >
|
||||
first line
|
||||
test \${{ fromJSON2('') }}
|
||||
test2`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Unrecognized function: 'fromJSON2'",
|
||||
range: {
|
||||
end: {
|
||||
character: 35,
|
||||
line: 7
|
||||
},
|
||||
start: {
|
||||
character: 15,
|
||||
line: 7
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("indented >+", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: >+
|
||||
first line
|
||||
test \${{ fromJSON2('') }}
|
||||
test2`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Unrecognized function: 'fromJSON2'",
|
||||
range: {
|
||||
end: {
|
||||
character: 35,
|
||||
line: 7
|
||||
},
|
||||
start: {
|
||||
character: 15,
|
||||
line: 7
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix context", () => {
|
||||
it("reference within a matrix job", async () => {
|
||||
const input = `
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
steps:
|
||||
- run: echo`
|
||||
),
|
||||
defaultValueProviders
|
||||
{valueProviderConfig: defaultValueProviders}
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
steps:
|
||||
- run: echo`
|
||||
),
|
||||
defaultValueProviders
|
||||
{valueProviderConfig: defaultValueProviders}
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
steps:
|
||||
- run: echo`
|
||||
),
|
||||
defaultValueProviders
|
||||
{valueProviderConfig: defaultValueProviders}
|
||||
);
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
runs-on:
|
||||
- ubuntu-latest`
|
||||
),
|
||||
defaultValueProviders
|
||||
{valueProviderConfig: defaultValueProviders}
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/te
|
||||
import {File} from "@github/actions-workflow-parser/workflows/file";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types";
|
||||
import {ActionInputs, ActionReference} from "./action";
|
||||
|
||||
import {ContextProviderConfig} from "./context-providers/config";
|
||||
import {getContext, Mode} from "./context-providers/default";
|
||||
@@ -23,12 +24,18 @@ import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
|
||||
import {AccessError, wrapDictionary} from "./expression-validation/error-dictionary";
|
||||
import {error} from "./log";
|
||||
import {nullTrace} from "./nulltrace";
|
||||
import {getAllowedContext} from "./utils/allowed-context";
|
||||
import {findToken} from "./utils/find-token";
|
||||
import {mapRange} from "./utils/range";
|
||||
import {validateAction} from "./validate-action";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
||||
import {defaultValueProviders} from "./value-providers/default";
|
||||
|
||||
export type ValidationConfig = {
|
||||
valueProviderConfig?: ValueProviderConfig;
|
||||
contextProviderConfig?: ContextProviderConfig;
|
||||
getActionInputs?(action: ActionReference): Promise<ActionInputs | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a workflow file
|
||||
*
|
||||
@@ -37,9 +44,8 @@ import {defaultValueProviders} from "./value-providers/default";
|
||||
*/
|
||||
export async function validate(
|
||||
textDocument: TextDocument,
|
||||
config?: ValidationConfig
|
||||
// TODO: Support multiple files, context for API calls
|
||||
valueProviderConfig?: ValueProviderConfig,
|
||||
contextProviderConfig?: ContextProviderConfig
|
||||
): Promise<Diagnostic[]> {
|
||||
const file: File = {
|
||||
name: textDocument.uri,
|
||||
@@ -55,14 +61,7 @@ export async function validate(
|
||||
const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion);
|
||||
|
||||
// Validate expressions and value providers
|
||||
await additionalValidations(
|
||||
diagnostics,
|
||||
textDocument.uri,
|
||||
template,
|
||||
result.value,
|
||||
valueProviderConfig,
|
||||
contextProviderConfig
|
||||
);
|
||||
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config);
|
||||
}
|
||||
|
||||
// For now map parser errors directly to diagnostics
|
||||
@@ -86,8 +85,7 @@ async function additionalValidations(
|
||||
documentUri: URI,
|
||||
template: WorkflowTemplate,
|
||||
root: TemplateToken,
|
||||
valueProviderConfig: ValueProviderConfig | undefined,
|
||||
contextProviderConfig: ContextProviderConfig | undefined
|
||||
config?: ValidationConfig
|
||||
) {
|
||||
for (const [parent, token, key] of TemplateToken.traverse(root)) {
|
||||
// If the token is a value in a pair, use the key definition for validation
|
||||
@@ -95,26 +93,33 @@ async function additionalValidations(
|
||||
const validationToken = key || parent || token;
|
||||
const validationDefinition = validationToken.definition;
|
||||
|
||||
const allowedContext = getAllowedContext(validationToken, parent);
|
||||
|
||||
// If this is an expression, validate it
|
||||
if (isBasicExpression(token)) {
|
||||
await validateExpression(
|
||||
diagnostics,
|
||||
token,
|
||||
allowedContext,
|
||||
contextProviderConfig,
|
||||
validationToken.definitionInfo?.allowedContext || [],
|
||||
config?.contextProviderConfig,
|
||||
getProviderContext(documentUri, template, root, token)
|
||||
);
|
||||
}
|
||||
|
||||
if (token.definition?.key === "regular-step") {
|
||||
const context = getProviderContext(documentUri, template, root, token);
|
||||
await validateAction(diagnostics, token, context.step, config);
|
||||
}
|
||||
|
||||
// Allowed values coming from the schema have already been validated. Only check if
|
||||
// a value provider is defined for a token and if it is, validate the values match.
|
||||
if (valueProviderConfig && token.range && validationDefinition) {
|
||||
if (config?.valueProviderConfig && token.range && validationDefinition) {
|
||||
const defKey = validationDefinition.key;
|
||||
if (defKey === "step-with") {
|
||||
// Action inputs should be validated already in validateAction
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try a custom value provider first
|
||||
let valueProvider = valueProviderConfig[defKey];
|
||||
let valueProvider = config.valueProviderConfig[defKey];
|
||||
if (!valueProvider) {
|
||||
// fall back to default
|
||||
valueProvider = defaultValueProviders[defKey];
|
||||
@@ -209,8 +214,6 @@ async function validateExpression(
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
range: mapRange(expression.range)
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {WorkflowContext} from "../context/workflow-context";
|
||||
export interface Value {
|
||||
label: string;
|
||||
description?: string;
|
||||
deprecated?: boolean;
|
||||
}
|
||||
|
||||
export enum ValueProviderKind {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "browser-playground",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.58",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@github/actions-languageserver": "^0.1.48",
|
||||
"@github/actions-languageserver": "^0.1.58",
|
||||
"monaco-editor-webpack-plugin": "^7.0.1",
|
||||
"monaco-editor-workers": "^0.34.2",
|
||||
"monaco-languageclient": "^4.0.3",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.1.48"
|
||||
"version": "0.1.58"
|
||||
}
|
||||
|
||||
Generated
+27
-21
@@ -16,13 +16,15 @@
|
||||
},
|
||||
"actions-languageserver": {
|
||||
"name": "@github/actions-languageserver",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.58",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/actions-languageservice": "^0.1.48",
|
||||
"@github/actions-languageservice": "^0.1.58",
|
||||
"@github/actions-workflow-parser": "*",
|
||||
"@octokit/rest": "^19.0.5",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
"vscode-languageserver-textdocument": "^1.0.7"
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"yaml": "^2.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.3",
|
||||
@@ -38,9 +40,10 @@
|
||||
},
|
||||
"actions-languageservice": {
|
||||
"name": "@github/actions-languageservice",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.58",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/actions-expressions": "*",
|
||||
"@github/actions-workflow-parser": "*",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
@@ -59,10 +62,10 @@
|
||||
}
|
||||
},
|
||||
"browser-playground": {
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.58",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/actions-languageserver": "^0.1.48",
|
||||
"@github/actions-languageserver": "^0.1.58",
|
||||
"monaco-editor-webpack-plugin": "^7.0.1",
|
||||
"monaco-editor-workers": "^0.34.2",
|
||||
"monaco-languageclient": "^4.0.3",
|
||||
@@ -681,9 +684,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@github/actions-expressions": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://npm.pkg.github.com/download/@github/actions-expressions/0.0.7/deed2be11b9f74791730303b6e33f9aca701d6a7",
|
||||
"integrity": "sha512-PG8dQUafOckk/ny5O1wcUcfml5IZM8REoi7WBBY1HfpnRR7mL7gqTczQEmQWNTpoHb9f1VQK47y2hBZTOg9Y7g==",
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://npm.pkg.github.com/download/@github/actions-expressions/0.0.8/055f455fcb0ed907f52c349b9385728aeed38f9a",
|
||||
"integrity": "sha512-SPuGfnjgKAbMzJNCk4sZPAK2bxHlRXDJCk6vrwIi9VJL16Eh1YyiyM4fU9kV5EXEE5AV9aPFlZAPXu6t0mmBNw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -698,9 +701,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@github/actions-workflow-parser": {
|
||||
"version": "0.0.31",
|
||||
"resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.31/ebc46956b91ed1a8c45efd41a3256ae818722557",
|
||||
"integrity": "sha512-3k+MBWG7Gn86aHeLdJTiYUTGm53npEelzleCbq/8Ir51xVkRINsyNe4HuGJizXxZ2WoTxAkLeZ6qpO1oCucSIg==",
|
||||
"version": "0.0.35",
|
||||
"resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.35/0e88d541486f7c8772c5640fc13fac630cfaeaa8",
|
||||
"integrity": "sha512-OuNEuqUH4AOfWd2xb8VRl7KwJrtiZu1ic0b27lmadwvK8qgRhPHxvS+QICEc+JtFbbVWpAA2ymh8I1U35639mA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/actions-expressions": "*",
|
||||
@@ -13498,14 +13501,15 @@
|
||||
"dev": true
|
||||
},
|
||||
"@github/actions-expressions": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://npm.pkg.github.com/download/@github/actions-expressions/0.0.7/deed2be11b9f74791730303b6e33f9aca701d6a7",
|
||||
"integrity": "sha512-PG8dQUafOckk/ny5O1wcUcfml5IZM8REoi7WBBY1HfpnRR7mL7gqTczQEmQWNTpoHb9f1VQK47y2hBZTOg9Y7g=="
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://npm.pkg.github.com/download/@github/actions-expressions/0.0.8/055f455fcb0ed907f52c349b9385728aeed38f9a",
|
||||
"integrity": "sha512-SPuGfnjgKAbMzJNCk4sZPAK2bxHlRXDJCk6vrwIi9VJL16Eh1YyiyM4fU9kV5EXEE5AV9aPFlZAPXu6t0mmBNw=="
|
||||
},
|
||||
"@github/actions-languageserver": {
|
||||
"version": "file:actions-languageserver",
|
||||
"requires": {
|
||||
"@github/actions-languageservice": "^0.1.48",
|
||||
"@github/actions-languageservice": "^0.1.58",
|
||||
"@github/actions-workflow-parser": "*",
|
||||
"@octokit/rest": "^19.0.5",
|
||||
"@types/jest": "^29.0.3",
|
||||
"jest": "^29.0.3",
|
||||
@@ -13514,12 +13518,14 @@
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.8.4",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
"vscode-languageserver-textdocument": "^1.0.7"
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"yaml": "^2.1.3"
|
||||
}
|
||||
},
|
||||
"@github/actions-languageservice": {
|
||||
"version": "file:actions-languageservice",
|
||||
"requires": {
|
||||
"@github/actions-expressions": "*",
|
||||
"@github/actions-workflow-parser": "*",
|
||||
"@types/jest": "^29.0.3",
|
||||
"jest": "^29.0.3",
|
||||
@@ -13533,9 +13539,9 @@
|
||||
}
|
||||
},
|
||||
"@github/actions-workflow-parser": {
|
||||
"version": "0.0.31",
|
||||
"resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.31/ebc46956b91ed1a8c45efd41a3256ae818722557",
|
||||
"integrity": "sha512-3k+MBWG7Gn86aHeLdJTiYUTGm53npEelzleCbq/8Ir51xVkRINsyNe4HuGJizXxZ2WoTxAkLeZ6qpO1oCucSIg==",
|
||||
"version": "0.0.35",
|
||||
"resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.35/0e88d541486f7c8772c5640fc13fac630cfaeaa8",
|
||||
"integrity": "sha512-OuNEuqUH4AOfWd2xb8VRl7KwJrtiZu1ic0b27lmadwvK8qgRhPHxvS+QICEc+JtFbbVWpAA2ymh8I1U35639mA==",
|
||||
"requires": {
|
||||
"@github/actions-expressions": "*",
|
||||
"yaml": "^2.0.0-8"
|
||||
@@ -16288,7 +16294,7 @@
|
||||
"browser-playground": {
|
||||
"version": "file:browser-playground",
|
||||
"requires": {
|
||||
"@github/actions-languageserver": "^0.1.48",
|
||||
"@github/actions-languageserver": "^0.1.58",
|
||||
"css-loader": "^6.7.2",
|
||||
"monaco-editor-webpack-plugin": "^7.0.1",
|
||||
"monaco-editor-workers": "^0.34.2",
|
||||
|
||||
Reference in New Issue
Block a user