erge branch 'main' into thyeggman/reusable-workflow-hover
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user