erge branch 'main' into thyeggman/reusable-workflow-hover

This commit is contained in:
Jacob Wallraff
2023-02-15 17:42:51 -08:00
49 changed files with 1072 additions and 234 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@github/actions-languageservice",
"version": "0.1.137",
"version": "0.1.143",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -38,8 +38,8 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@github/actions-expressions": "^0.1.137",
"@github/actions-workflow-parser": "^0.1.137",
"@github/actions-expressions": "^0.1.143",
"@github/actions-workflow-parser": "^0.1.143",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"yaml": "^2.1.1"
+6
View File
@@ -1,3 +1,9 @@
// 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;
+4 -9
View File
@@ -77,15 +77,10 @@ export async function complete(
}
const {token, keyToken, parent, path} = findToken(newPos, result.value);
const template = await convertWorkflowTemplate(
result.context,
result.value,
ErrorPolicy.TryConversion,
config?.fileProvider,
{
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0
}
);
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
@@ -1,4 +1,5 @@
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";
@@ -96,8 +97,13 @@ jobs:
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("my-build-id"),
description: undefined
}
]);
});
it("reusable job with outputs", async () => {
@@ -123,6 +129,13 @@ jobs:
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"
}
]);
});
});
});
@@ -1,6 +1,8 @@
import {data, DescriptionDictionary} from "@github/actions-expressions";
import {isScalar, isString} from "@github/actions-workflow-parser";
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 {
@@ -44,9 +46,28 @@ function jobOutputs(job?: WorkflowJob): DescriptionDictionary {
continue;
}
// Include the value for hover purposes
const value = isScalar(output.value) ? new data.StringData(output.value.toDisplayString()) : new data.Null();
d.add(output.key.value, value);
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
];
}
@@ -19,7 +19,9 @@ export async function documentLinks(document: TextDocument): Promise<DocumentLin
return [];
}
const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Add links to referenced actions
const actionLinks: DocumentLink[] = [];
@@ -110,7 +110,9 @@ async function hoverExpression(input: string) {
return undefined;
}
const template = await convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion);
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);
@@ -1,4 +1,4 @@
import {data} from "@github/actions-expressions";
import {data, isDescriptionDictionary} from "@github/actions-expressions";
import {isDictionary} from "@github/actions-expressions/data/dictionary";
import {ExpressionData, Pair} from "@github/actions-expressions/data/expressiondata";
@@ -12,6 +12,7 @@ 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);
@@ -19,12 +20,17 @@ export class ErrorDictionary extends data.Dictionary {
return value;
}
throw new AccessError(`Invalid context access: ${key}`, key);
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)) {
+6 -10
View File
@@ -61,7 +61,9 @@ export async function hover(document: TextDocument, position: Position, config?:
const allowedContext = tokenDefinitionInfo.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
const template = await convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion);
const template = await convertWorkflowTemplate(result.context, result.value, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
const workflowContext = getWorkflowContext(document.uri, template, tokenResult.path);
const context = await getContext(namedContexts, config?.contextProviderConfig, workflowContext, Mode.Completion);
@@ -116,15 +118,9 @@ async function getDescription(
return defaultDescription;
}
const template = await convertWorkflowTemplate(
result.context,
result.value,
ErrorPolicy.TryConversion,
config?.fileProvider,
{
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0
}
);
const template = await convertWorkflowTemplate(result.context, result.value, config?.fileProvider, {
errorPolicy: ErrorPolicy.TryConversion
});
const workflowContext = getWorkflowContext(document.uri, template, path);
const description = await config.descriptionProvider.getDescription(workflowContext, token, path, template);
return description || defaultDescription;
@@ -18,7 +18,7 @@ export async function testGetWorkflowContext(input: string): Promise<WorkflowCon
let template: WorkflowTemplate | undefined;
if (result.value) {
template = await convertWorkflowTemplate(result.context, result.value, undefined, testFileProvider, {
template = await convertWorkflowTemplate(result.context, result.value, testFileProvider, {
fetchReusableWorkflowDepth: 1
});
}
@@ -14,7 +14,7 @@ export async function validateAction(
step: Step | undefined,
config: ValidationConfig | undefined
): Promise<void> {
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.getActionInputs) {
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.fetchActionMetadata) {
return;
}
@@ -23,8 +23,8 @@ export async function validateAction(
return;
}
const actionInputs = await config.getActionInputs(action);
if (actionInputs === undefined) {
const actionMetadata = await config.fetchActionMetadata(action);
if (actionMetadata === undefined) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(step.uses.range),
@@ -50,6 +50,11 @@ export async function validateAction(
}
}
const actionInputs = actionMetadata.inputs;
if (actionInputs === undefined) {
return;
}
for (const [input, inputToken] of stepInputs) {
if (!actionInputs[input]) {
diagnostics.push({
@@ -1,5 +1,5 @@
import {DiagnosticSeverity} from "vscode-languageserver-types";
import {ActionInput, ActionReference} from "./action";
import {ActionMetadata, ActionReference} from "./action";
import {registerLogger} from "./log";
import {createDocument} from "./test-utils/document";
import {TestLogger} from "./test-utils/logger";
@@ -9,53 +9,63 @@ import {ValueProviderKind} from "./value-providers/config";
registerLogger(new TestLogger());
const validationConfig: ValidationConfig = {
getActionInputs: async (ref: ActionReference) => {
let inputs: Record<string, ActionInput> | undefined = undefined;
fetchActionMetadata: async (ref: ActionReference) => {
let metadata: ActionMetadata | undefined = undefined;
switch (ref.owner + "/" + ref.name + "@" + ref.ref) {
case "actions/checkout@v3":
inputs = {
repository: {
description: "Repository name with owner",
default: "${{ github.repository }}"
metadata = {
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"
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":
inputs = {
token: {
required: true,
description: "token to use",
default: "${{ github.token }}"
metadata = {
inputs: {
token: {
required: true,
description: "token to use",
default: "${{ github.token }}"
}
}
};
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
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 inputs;
return metadata;
}
};
@@ -103,6 +113,20 @@ jobs:
]);
});
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
@@ -1,8 +1,10 @@
import {data, DescriptionDictionary} from "@github/actions-expressions/.";
import {DiagnosticSeverity} from "vscode-languageserver-types";
import {ContextProviderConfig} from "./context-providers/config";
import {registerLogger} from "./log";
import {createDocument} from "./test-utils/document";
import {TestLogger} from "./test-utils/logger";
import {validate} from "./validate";
import {validate, ValidationConfig} from "./validate";
registerLogger(new TestLogger());
@@ -39,6 +41,58 @@ jobs:
]);
});
it("partial skip access invalid context on incomplete", async () => {
const contextProviderConfig: ContextProviderConfig = {
getContext: async (context: string) => {
switch (context) {
case "secrets":
const dict = new DescriptionDictionary();
dict.complete = false;
return dict;
}
return undefined;
}
};
const validationConfig: ValidationConfig = {
contextProviderConfig: contextProviderConfig
};
const result = await validate(
createDocument(
"wf.yaml",
`on: push
run-name: name-\${{ github.does-not-exist }}
env:
secret: \${{ secrets.secret-not-exist }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo`
),
validationConfig
);
expect(result).toEqual([
{
message: "Context access might be invalid: does-not-exist",
range: {
end: {
character: 43,
line: 1
},
start: {
character: 15,
line: 1
}
},
severity: DiagnosticSeverity.Warning
}
]);
});
it("access invalid nested context field", async () => {
const result = await validate(
createDocument(
+6 -11
View File
@@ -17,7 +17,7 @@ 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 {ActionInputs, ActionReference} from "./action";
import {ActionMetadata, ActionReference} from "./action";
import {ContextProviderConfig} from "./context-providers/config";
import {getContext, Mode} from "./context-providers/default";
@@ -35,7 +35,7 @@ import {defaultValueProviders} from "./value-providers/default";
export type ValidationConfig = {
valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig;
getActionInputs?(action: ActionReference): Promise<ActionInputs | undefined>;
fetchActionMetadata?(action: ActionReference): Promise<ActionMetadata | undefined>;
fileProvider?: FileProvider;
};
@@ -57,15 +57,10 @@ export async function validate(textDocument: TextDocument, config?: ValidationCo
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,
ErrorPolicy.TryConversion,
config?.fileProvider,
{
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0
}
);
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);