Merge branch 'main' into elbrenn/expression-complete
This commit is contained in:
@@ -6,19 +6,16 @@ import {parseWorkflow} from "./workflows/workflow-parser";
|
||||
describe("Workflow Expression Parsing", () => {
|
||||
it("preserves original expressions when building format", () => {
|
||||
const result = parseWorkflow(
|
||||
"test.yaml",
|
||||
[
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
run-name: Test \${{ github.event_name }} \${{ github.ref }}
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'hello'`
|
||||
}
|
||||
],
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
@@ -44,20 +41,17 @@ jobs:
|
||||
|
||||
it("preserves original expressions when building format for multi-line strings", () => {
|
||||
const result = parseWorkflow(
|
||||
"test.yaml",
|
||||
[
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
echo \${{ github.event_name }}
|
||||
echo 'hello' \${{ github.ref }}`
|
||||
}
|
||||
],
|
||||
echo 'hello' \${{github.ref }}`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
@@ -81,19 +75,27 @@ jobs:
|
||||
}
|
||||
|
||||
expect(stepRun.originalExpressions).toHaveLength(2);
|
||||
expect(stepRun.originalExpressions!.map(x => [x.toDisplayString(), x.range])).toEqual([
|
||||
expect(stepRun.originalExpressions!.map(x => [x.toDisplayString(), x.range, x.expressionRange])).toEqual([
|
||||
[
|
||||
"${{ github.event_name }}",
|
||||
{
|
||||
start: {line: 7, column: 16},
|
||||
end: {line: 7, column: 40}
|
||||
},
|
||||
{
|
||||
start: {line: 7, column: 20},
|
||||
end: {line: 7, column: 37}
|
||||
}
|
||||
],
|
||||
[
|
||||
"${{ github.ref }}",
|
||||
{
|
||||
start: {line: 8, column: 24},
|
||||
end: {line: 8, column: 41}
|
||||
end: {line: 8, column: 40}
|
||||
},
|
||||
{
|
||||
start: {line: 8, column: 27},
|
||||
end: {line: 8, column: 37}
|
||||
}
|
||||
]
|
||||
]);
|
||||
@@ -101,11 +103,9 @@ jobs:
|
||||
|
||||
it("return errors and string token with preserved expressions for (multiple) expression errors", () => {
|
||||
const result = parseWorkflow(
|
||||
"test.yaml",
|
||||
[
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -113,8 +113,7 @@ jobs:
|
||||
- run: |
|
||||
echo \${{ abc }}
|
||||
echo 'hello' \${{ gith }}`
|
||||
}
|
||||
],
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
@@ -139,11 +138,9 @@ jobs:
|
||||
|
||||
it("reports all errors for multi-line expressions at the correct locations", () => {
|
||||
const result = parseWorkflow(
|
||||
"test.yaml",
|
||||
[
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -151,8 +148,7 @@ jobs:
|
||||
- run: |
|
||||
echo \${{ fromJSON2('test') }}
|
||||
echo 'hello' \${{ toJSON2(inputs.test) }}`
|
||||
}
|
||||
],
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
@@ -178,19 +174,16 @@ jobs:
|
||||
|
||||
it("parses isExpression strings into expression tokens", () => {
|
||||
const result = parseWorkflow(
|
||||
"test.yaml",
|
||||
[
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- run: echo 'hello'`
|
||||
}
|
||||
],
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
|
||||
@@ -5,13 +5,10 @@ import {parseWorkflow} from "./workflows/workflow-parser";
|
||||
describe("parseWorkflow", () => {
|
||||
it("parses valid workflow", () => {
|
||||
const result = parseWorkflow(
|
||||
"test.yaml",
|
||||
[
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: "on: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'hello'"
|
||||
}
|
||||
],
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: "on: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'hello'"
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
@@ -20,13 +17,10 @@ describe("parseWorkflow", () => {
|
||||
|
||||
it("contains range for error", () => {
|
||||
const result = parseWorkflow(
|
||||
"test.yaml",
|
||||
[
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: "on: push\njobs:\n build:\n steps:\n - run: echo 'hello'"
|
||||
}
|
||||
],
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: "on: push\njobs:\n build:\n steps:\n - run: echo 'hello'"
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
@@ -40,19 +34,16 @@ describe("parseWorkflow", () => {
|
||||
|
||||
it("error range for expression is constrained to scalar node", () => {
|
||||
const result = parseWorkflow(
|
||||
"test.yaml",
|
||||
[
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: \${{ github.event = 12 }}
|
||||
run: echo 'hello'`
|
||||
}
|
||||
],
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
@@ -71,14 +62,11 @@ jobs:
|
||||
|
||||
it("tokens contain descriptions", () => {
|
||||
const result = parseWorkflow(
|
||||
"test.yaml",
|
||||
[
|
||||
{
|
||||
name: "test.yaml",
|
||||
content:
|
||||
"on: push\nname: hello\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'hello'"
|
||||
}
|
||||
],
|
||||
{
|
||||
name: "test.yaml",
|
||||
content:
|
||||
"on: push\nname: hello\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'hello'"
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
|
||||
@@ -7,22 +7,19 @@ function serializeTemplate(template: unknown): unknown {
|
||||
}
|
||||
|
||||
describe("convertWorkflowTemplate", () => {
|
||||
it("converts workflow with one job", () => {
|
||||
it("converts workflow with one job", async () => {
|
||||
const result = parseWorkflow(
|
||||
"wf.yaml",
|
||||
[
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
}
|
||||
],
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
|
||||
expect(serializeTemplate(template)).toEqual({
|
||||
events: {
|
||||
@@ -46,13 +43,11 @@ jobs:
|
||||
});
|
||||
});
|
||||
|
||||
it("converts workflow if expressions", () => {
|
||||
it("converts workflow if expressions", async () => {
|
||||
const result = parseWorkflow(
|
||||
"wf.yaml",
|
||||
[
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: \${{ true }}
|
||||
@@ -60,12 +55,11 @@ jobs:
|
||||
deploy:
|
||||
if: true
|
||||
runs-on: ubuntu-latest`
|
||||
}
|
||||
],
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
|
||||
expect(serializeTemplate(template)).toEqual({
|
||||
events: {
|
||||
@@ -98,23 +92,20 @@ jobs:
|
||||
});
|
||||
});
|
||||
|
||||
it("converts workflow with empty needs", () => {
|
||||
it("converts workflow with empty needs", async () => {
|
||||
const result = parseWorkflow(
|
||||
"wf.yaml",
|
||||
[
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
needs: # comment to preserve whitespace in test
|
||||
runs-on: ubuntu-latest`
|
||||
}
|
||||
],
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
|
||||
expect(serializeTemplate(template)).toEqual({
|
||||
errors: [
|
||||
@@ -143,13 +134,11 @@ jobs:
|
||||
});
|
||||
});
|
||||
|
||||
it("converts workflow with needs errors", () => {
|
||||
it("converts workflow with needs errors", async () => {
|
||||
const result = parseWorkflow(
|
||||
"wf.yaml",
|
||||
[
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
job1:
|
||||
needs: [unknown-job, job3]
|
||||
@@ -159,12 +148,11 @@ jobs:
|
||||
job3:
|
||||
needs: job1
|
||||
runs-on: ubuntu-latest`
|
||||
}
|
||||
],
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
|
||||
expect(serializeTemplate(template)).toEqual({
|
||||
errors: [
|
||||
@@ -223,13 +211,11 @@ jobs:
|
||||
});
|
||||
});
|
||||
|
||||
it("converts workflow with invalid on", () => {
|
||||
it("converts workflow with invalid on", async () => {
|
||||
const result = parseWorkflow(
|
||||
"wf.yaml",
|
||||
[
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on:
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
test:
|
||||
@@ -239,12 +225,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hello`
|
||||
}
|
||||
],
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
|
||||
expect(template.jobs).not.toBeUndefined();
|
||||
expect(template.jobs).toHaveLength(1);
|
||||
@@ -286,21 +271,18 @@ jobs:
|
||||
});
|
||||
});
|
||||
|
||||
it("converts workflow with invalid jobs", () => {
|
||||
it("converts workflow with invalid jobs", async () => {
|
||||
const result = parseWorkflow(
|
||||
"wf.yaml",
|
||||
[
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:`
|
||||
}
|
||||
],
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
|
||||
|
||||
expect(template.jobs).not.toBeUndefined();
|
||||
expect(template.jobs).toHaveLength(0);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import {TemplateContext} from "../templates/template-context";
|
||||
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token";
|
||||
import {FileProvider} from "../workflows/file-provider";
|
||||
import {parseFileReference} from "../workflows/file-reference";
|
||||
import {parseWorkflow} from "../workflows/workflow-parser";
|
||||
import {convertConcurrency} from "./converter/concurrency";
|
||||
import {convertOn} from "./converter/events";
|
||||
import {handleTemplateTokenErrors} from "./converter/handle-errors";
|
||||
import {convertJobs} from "./converter/jobs";
|
||||
import {convertReferencedWorkflow} from "./converter/referencedWorkflow";
|
||||
import {isReusableWorkflowJob} from "./type-guards";
|
||||
import {WorkflowTemplate} from "./workflow-template";
|
||||
|
||||
export enum ErrorPolicy {
|
||||
@@ -11,11 +16,35 @@ export enum ErrorPolicy {
|
||||
TryConversion
|
||||
}
|
||||
|
||||
export function convertWorkflowTemplate(
|
||||
export type WorkflowTemplateConverterOptions = {
|
||||
/**
|
||||
* The maximum depth of reusable workflows allowed in the workflow.
|
||||
* If this depth is exceeded, an error will be reported.
|
||||
* If {@link fetchReusableWorkflowDepth} is less than this value, the maximum depth
|
||||
* won't be enforced.
|
||||
* Default: 4
|
||||
*/
|
||||
maxReusableWorkflowDepth?: number;
|
||||
/**
|
||||
* The depth to fetch reusable workflows, up to {@link maxReusableWorkflowDepth}.
|
||||
* Currently only a fetch depth of 0 or 1 is supported.
|
||||
* Default: 0
|
||||
*/
|
||||
fetchReusableWorkflowDepth?: number;
|
||||
};
|
||||
|
||||
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
|
||||
maxReusableWorkflowDepth: 4,
|
||||
fetchReusableWorkflowDepth: 0
|
||||
};
|
||||
|
||||
export async function convertWorkflowTemplate(
|
||||
context: TemplateContext,
|
||||
root: TemplateToken,
|
||||
errorPolicy: ErrorPolicy = ErrorPolicy.ReturnErrorsOnly
|
||||
): WorkflowTemplate {
|
||||
errorPolicy: ErrorPolicy = ErrorPolicy.ReturnErrorsOnly,
|
||||
fileProvider?: FileProvider,
|
||||
options: WorkflowTemplateConverterOptions = defaultOptions
|
||||
): Promise<WorkflowTemplate> {
|
||||
const result = {} as WorkflowTemplate;
|
||||
|
||||
if (context.errors.getErrors().length > 0 && errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
|
||||
@@ -25,6 +54,12 @@ export function convertWorkflowTemplate(
|
||||
return result;
|
||||
}
|
||||
|
||||
const opts = getOptionsWithDefaults(options);
|
||||
|
||||
if (fileProvider === undefined && opts.fetchReusableWorkflowDepth > 0) {
|
||||
context.error(root, new Error("A file provider is required to fetch reusable workflows"));
|
||||
}
|
||||
|
||||
try {
|
||||
const rootMapping = root.assertMapping("root");
|
||||
|
||||
@@ -49,6 +84,31 @@ export function convertWorkflowTemplate(
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Load referenced workflows
|
||||
for (const job of result.jobs || []) {
|
||||
if (isReusableWorkflowJob(job)) {
|
||||
if (opts.maxReusableWorkflowDepth === 0) {
|
||||
context.error(job.ref, new Error("Reusable workflows are not allowed"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opts.fetchReusableWorkflowDepth === 0 || fileProvider === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await fileProvider.getFileContent(parseFileReference(job.ref.value));
|
||||
const workflow = parseWorkflow(file, context);
|
||||
if (!workflow.value) {
|
||||
continue;
|
||||
}
|
||||
convertReferencedWorkflow(context, workflow.value, job);
|
||||
} catch {
|
||||
context.error(job.ref, new Error("Unable to find reusable workflow"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof TemplateTokenError) {
|
||||
context.error(err.token, err);
|
||||
@@ -66,3 +126,16 @@ export function convertWorkflowTemplate(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Required<WorkflowTemplateConverterOptions> {
|
||||
return {
|
||||
maxReusableWorkflowDepth:
|
||||
options.maxReusableWorkflowDepth !== undefined
|
||||
? options.maxReusableWorkflowDepth
|
||||
: defaultOptions.maxReusableWorkflowDepth,
|
||||
fetchReusableWorkflowDepth:
|
||||
options.fetchReusableWorkflowDepth !== undefined
|
||||
? options.fetchReusableWorkflowDepth
|
||||
: defaultOptions.fetchReusableWorkflowDepth
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {StringToken, MappingToken, BasicExpressionToken, TemplateToken, ScalarToken} from "../../templates/tokens";
|
||||
import {isSequence, isString} from "../../templates/tokens/type-guards";
|
||||
import {WorkflowJob, Step} from "../workflow-template";
|
||||
import {convertConcurrency} from "./concurrency";
|
||||
import {convertToJobContainer, convertToJobServices} from "./container";
|
||||
import {handleTemplateTokenErrors} from "./handle-errors";
|
||||
import {IdBuilder} from "./id-builder";
|
||||
import {convertToActionsEnvironmentRef} from "./job/environment";
|
||||
import {convertRunsOn} from "./job/runs-on";
|
||||
import {convertSteps} from "./steps";
|
||||
|
||||
export function convertJob(context: TemplateContext, jobKey: StringToken, token: MappingToken): WorkflowJob {
|
||||
const error = new IdBuilder().tryAddKnownId(jobKey.value);
|
||||
if (error) {
|
||||
context.error(jobKey, error);
|
||||
}
|
||||
|
||||
let concurrency, container, env, environment, name, outputs, runsOn, services, strategy: TemplateToken | undefined;
|
||||
let needs: StringToken[] | undefined = undefined;
|
||||
let steps: Step[] = [];
|
||||
let workflowJobRef: StringToken | undefined;
|
||||
let workflowJobInputs: MappingToken | undefined;
|
||||
|
||||
for (const item of token) {
|
||||
const propertyName = item.key.assertString("job property name");
|
||||
switch (propertyName.value) {
|
||||
case "concurrency":
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () => convertConcurrency(context, item.value));
|
||||
concurrency = item.value;
|
||||
break;
|
||||
|
||||
case "container":
|
||||
convertToJobContainer(context, item.value);
|
||||
container = item.value;
|
||||
break;
|
||||
|
||||
case "env":
|
||||
env = item.value.assertMapping("job env");
|
||||
break;
|
||||
|
||||
case "environment":
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () =>
|
||||
convertToActionsEnvironmentRef(context, item.value)
|
||||
);
|
||||
environment = item.value;
|
||||
break;
|
||||
|
||||
case "name":
|
||||
name = item.value.assertScalar("job name");
|
||||
break;
|
||||
|
||||
case "needs": {
|
||||
needs = [];
|
||||
if (isString(item.value)) {
|
||||
const jobNeeds = item.value.assertString("job needs id");
|
||||
needs.push(jobNeeds);
|
||||
}
|
||||
|
||||
if (isSequence(item.value)) {
|
||||
for (const seqItem of item.value) {
|
||||
const jobNeeds = seqItem.assertString("job needs id");
|
||||
needs.push(jobNeeds);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "outputs":
|
||||
outputs = item.value.assertMapping("job outputs");
|
||||
break;
|
||||
|
||||
case "runs-on":
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () => convertRunsOn(context, item.value));
|
||||
runsOn = item.value;
|
||||
break;
|
||||
|
||||
case "services":
|
||||
convertToJobServices(context, item.value);
|
||||
services = item.value;
|
||||
break;
|
||||
|
||||
case "steps":
|
||||
steps = convertSteps(context, item.value);
|
||||
break;
|
||||
|
||||
case "strategy":
|
||||
strategy = item.value;
|
||||
break;
|
||||
|
||||
case "uses":
|
||||
workflowJobRef = item.value.assertString("job uses value");
|
||||
break;
|
||||
|
||||
case "with":
|
||||
workflowJobInputs = item.value.assertMapping("uses-with value");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowJobRef !== undefined) {
|
||||
return {
|
||||
type: "reusableWorkflowJob",
|
||||
id: jobKey,
|
||||
name: jobName(name, jobKey),
|
||||
needs: needs ?? [],
|
||||
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
|
||||
ref: workflowJobRef,
|
||||
"input-definitions": undefined,
|
||||
"input-values": workflowJobInputs,
|
||||
outputs: undefined,
|
||||
concurrency,
|
||||
strategy
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "job",
|
||||
id: jobKey,
|
||||
name: jobName(name, jobKey),
|
||||
needs,
|
||||
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
|
||||
env,
|
||||
concurrency,
|
||||
environment,
|
||||
strategy,
|
||||
"runs-on": runsOn,
|
||||
container,
|
||||
services,
|
||||
outputs,
|
||||
steps
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function jobName(name: ScalarToken | undefined, jobKey: StringToken): ScalarToken {
|
||||
if (name === undefined) {
|
||||
return jobKey;
|
||||
}
|
||||
|
||||
if (isString(name) && name.value === "") {
|
||||
return jobKey;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {TemplateContext} from "../../../templates/template-context";
|
||||
import {MappingToken, TemplateToken} from "../../../templates/tokens";
|
||||
import {ReusableWorkflowJob} from "../../workflow-template";
|
||||
|
||||
type TokenMap = Map<string, [key: string, value: TemplateToken]>;
|
||||
|
||||
export function convertWorkflowJobInputs(context: TemplateContext, job: ReusableWorkflowJob) {
|
||||
const inputDefinitions = createTokenMap(
|
||||
job["input-definitions"]?.assertMapping("workflow job input definitions"),
|
||||
"inputs"
|
||||
);
|
||||
|
||||
const inputValues = createTokenMap(job["input-values"]?.assertMapping("workflow job input values"), "with");
|
||||
|
||||
if (inputDefinitions !== undefined) {
|
||||
for (const [_, [name, value]] of inputDefinitions) {
|
||||
const inputSpec = createTokenMap(value.assertMapping(`input ${name}`), `input ${name} key`)!;
|
||||
|
||||
const inputTypeToken = inputSpec.get("type")?.[1];
|
||||
if (!inputTypeToken) {
|
||||
// This should be validated by the template reader per the schema
|
||||
continue;
|
||||
}
|
||||
|
||||
const inputSet = inputValues !== undefined && inputValues.has(name.toLowerCase());
|
||||
const required = inputSpec.get("required")?.[1].assertBoolean(`input ${name} required`).value;
|
||||
if (required && !inputSet) {
|
||||
context.error(job.ref, `Input ${name} is required, but not provided while calling.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inputValues !== undefined) {
|
||||
for (const [_, [name, value]] of inputValues) {
|
||||
if (!inputDefinitions?.has(name.toLowerCase())) {
|
||||
context.error(value, `Invalid input, ${name} is not defined in the referenced workflow.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createTokenMap(mapping: MappingToken | undefined, description: string): TokenMap | undefined {
|
||||
if (!mapping) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = new Map<string, [key: string, value: TemplateToken]>();
|
||||
for (const item of mapping) {
|
||||
const name = item.key.assertString(`${description} key`);
|
||||
result.set(name.value.toLowerCase(), [name.value, item.value]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {TemplateContext} from "../../../templates/template-context";
|
||||
import {TemplateToken} from "../../../templates/tokens";
|
||||
import {isMapping, isString, isSequence} from "../../../templates/tokens/type-guards";
|
||||
|
||||
type RunsOn = {
|
||||
labels: Set<string>;
|
||||
group: string;
|
||||
};
|
||||
|
||||
export function convertRunsOn(context: TemplateContext, token: TemplateToken): RunsOn {
|
||||
const labels = convertRunsOnLabels(token);
|
||||
|
||||
if (!isMapping(token)) {
|
||||
return {
|
||||
labels,
|
||||
group: ""
|
||||
};
|
||||
}
|
||||
|
||||
let group = "";
|
||||
|
||||
for (const item of token) {
|
||||
const key = item.key.assertString("job runs-on property name");
|
||||
switch (key.value) {
|
||||
case "group": {
|
||||
if (item.value.isExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupName = item.value.assertString("job runs-on group name").value;
|
||||
const names = groupName.split("/");
|
||||
switch (names.length) {
|
||||
case 1: {
|
||||
group = groupName;
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
if (!["org", "organization", "ent", "enterprise"].includes(names[0])) {
|
||||
context.error(
|
||||
item.value,
|
||||
`Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!names[1]) {
|
||||
context.error(item.value, `Invalid runs-on group name '${groupName}'.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
group = groupName;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
context.error(
|
||||
item.value,
|
||||
`Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "labels": {
|
||||
const mapLabels = convertRunsOnLabels(item.value);
|
||||
for (const label of mapLabels) {
|
||||
labels.add(label);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
group
|
||||
};
|
||||
}
|
||||
|
||||
function convertRunsOnLabels(token: TemplateToken): Set<string> {
|
||||
const labels = new Set<string>();
|
||||
if (token.isExpression) {
|
||||
return labels;
|
||||
}
|
||||
|
||||
if (isString(token)) {
|
||||
labels.add(token.value);
|
||||
return labels;
|
||||
}
|
||||
|
||||
if (isSequence(token)) {
|
||||
for (const item of token) {
|
||||
if (item.isExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = item.assertString("job runs-on label sequence item");
|
||||
labels.add(label.value);
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {BasicExpressionToken, MappingToken, StringToken} from "../../templates/tokens";
|
||||
import {StringToken} from "../../templates/tokens";
|
||||
import {TemplateToken} from "../../templates/tokens/template-token";
|
||||
import {isMapping, isSequence, isString} from "../../templates/tokens/type-guards";
|
||||
import {Job} from "../workflow-template";
|
||||
import {convertConcurrency} from "./concurrency";
|
||||
import {convertToJobContainer, convertToJobServices} from "./container";
|
||||
import {isMapping} from "../../templates/tokens/type-guards";
|
||||
import {WorkflowJob} from "../workflow-template";
|
||||
import {handleTemplateTokenErrors} from "./handle-errors";
|
||||
import {IdBuilder} from "./id-builder";
|
||||
import {convertToActionsEnvironmentRef} from "./job/environment";
|
||||
import {convertSteps} from "./steps";
|
||||
import {convertJob} from "./job";
|
||||
|
||||
type nodeInfo = {
|
||||
name: string;
|
||||
needs: StringToken[];
|
||||
};
|
||||
|
||||
export function convertJobs(context: TemplateContext, token: TemplateToken): Job[] {
|
||||
export function convertJobs(context: TemplateContext, token: TemplateToken): WorkflowJob[] {
|
||||
if (isMapping(token)) {
|
||||
const result: Job[] = [];
|
||||
const result: WorkflowJob[] = [];
|
||||
const jobsWithSatisfiedNeeds: nodeInfo[] = [];
|
||||
const alljobsWithUnsatisfiedNeeds: nodeInfo[] = [];
|
||||
|
||||
@@ -53,7 +49,7 @@ export function convertJobs(context: TemplateContext, token: TemplateToken): Job
|
||||
function validateNeeds(
|
||||
token: TemplateToken,
|
||||
context: TemplateContext,
|
||||
result: Job[],
|
||||
result: WorkflowJob[],
|
||||
jobsWithSatisfiedNeeds: nodeInfo[],
|
||||
alljobsWithUnsatisfiedNeeds: nodeInfo[]
|
||||
) {
|
||||
@@ -101,199 +97,3 @@ function validateNeeds(
|
||||
}
|
||||
}
|
||||
|
||||
function convertJob(context: TemplateContext, jobKey: StringToken, token: MappingToken): Job {
|
||||
const error = new IdBuilder().tryAddKnownId(jobKey.value);
|
||||
if (error) {
|
||||
context.error(jobKey, error);
|
||||
}
|
||||
const result: Job = {
|
||||
type: "job",
|
||||
id: jobKey,
|
||||
name: undefined,
|
||||
needs: undefined,
|
||||
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, "success()"),
|
||||
env: undefined,
|
||||
concurrency: undefined,
|
||||
environment: undefined,
|
||||
strategy: undefined,
|
||||
"runs-on": undefined,
|
||||
container: undefined,
|
||||
services: undefined,
|
||||
outputs: undefined,
|
||||
steps: []
|
||||
};
|
||||
|
||||
for (const item of token) {
|
||||
const propertyName = item.key.assertString("job property name");
|
||||
switch (propertyName.value) {
|
||||
case "concurrency":
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () => convertConcurrency(context, item.value));
|
||||
result.concurrency = item.value;
|
||||
break;
|
||||
|
||||
case "container":
|
||||
// Do early validation, but don't convert
|
||||
convertToJobContainer(context, item.value);
|
||||
result.container = item.value;
|
||||
break;
|
||||
|
||||
case "env":
|
||||
result.env = item.value.assertMapping("job env");
|
||||
break;
|
||||
|
||||
case "environment":
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () =>
|
||||
convertToActionsEnvironmentRef(context, item.value)
|
||||
);
|
||||
result.environment = item.value;
|
||||
break;
|
||||
|
||||
case "name":
|
||||
result.name = item.value.assertScalar("job name");
|
||||
break;
|
||||
|
||||
case "needs":
|
||||
result.needs = [];
|
||||
if (isString(item.value)) {
|
||||
const jobNeeds = item.value.assertString("job needs id");
|
||||
result.needs.push(jobNeeds);
|
||||
}
|
||||
|
||||
if (isSequence(item.value)) {
|
||||
for (const seqItem of item.value) {
|
||||
const jobNeeds = seqItem.assertString("job needs id");
|
||||
result.needs.push(jobNeeds);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "outputs":
|
||||
result.outputs = item.value.assertMapping("job outputs");
|
||||
break;
|
||||
|
||||
case "runs-on":
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () => convertRunsOn(context, item.value));
|
||||
result["runs-on"] = item.value;
|
||||
break;
|
||||
|
||||
case "services":
|
||||
// Do early validation, but don't convert
|
||||
convertToJobServices(context, item.value);
|
||||
result.services = item.value;
|
||||
break;
|
||||
|
||||
case "steps":
|
||||
result.steps = convertSteps(context, item.value);
|
||||
break;
|
||||
|
||||
case "strategy":
|
||||
result.strategy = item.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.name) {
|
||||
result.name = result.id;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
type RunsOn = {
|
||||
labels: Set<string>;
|
||||
group: string;
|
||||
};
|
||||
|
||||
function convertRunsOn(context: TemplateContext, token: TemplateToken): RunsOn {
|
||||
const labels = convertRunsOnLabels(token);
|
||||
|
||||
if (!isMapping(token)) {
|
||||
return {
|
||||
labels,
|
||||
group: ""
|
||||
};
|
||||
}
|
||||
|
||||
let group = "";
|
||||
|
||||
for (const item of token) {
|
||||
const key = item.key.assertString("job runs-on property name");
|
||||
switch (key.value) {
|
||||
case "group": {
|
||||
if (item.value.isExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupName = item.value.assertString("job runs-on group name").value;
|
||||
const names = groupName.split("/");
|
||||
switch (names.length) {
|
||||
case 1: {
|
||||
group = groupName;
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
if (!["org", "organization", "ent", "enterprise"].includes(names[0])) {
|
||||
context.error(
|
||||
item.value,
|
||||
`Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!names[1]) {
|
||||
context.error(item.value, `Invalid runs-on group name '${groupName}'.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
group = groupName;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
context.error(
|
||||
item.value,
|
||||
`Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "labels": {
|
||||
const mapLabels = convertRunsOnLabels(item.value);
|
||||
for (const label of mapLabels) {
|
||||
labels.add(label);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
group
|
||||
};
|
||||
}
|
||||
|
||||
function convertRunsOnLabels(token: TemplateToken): Set<string> {
|
||||
const labels = new Set<string>();
|
||||
if (token.isExpression) {
|
||||
return labels;
|
||||
}
|
||||
|
||||
if (isString(token)) {
|
||||
labels.add(token.value);
|
||||
return labels;
|
||||
}
|
||||
|
||||
if (isSequence(token)) {
|
||||
for (const item of token) {
|
||||
if (item.isExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = item.assertString("job runs-on label sequence item");
|
||||
labels.add(label.value);
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {TemplateToken} from "../../templates/tokens";
|
||||
import {TokenType} from "../../templates/tokens/types";
|
||||
import {ReusableWorkflowJob} from "../workflow-template";
|
||||
import {handleTemplateTokenErrors} from "./handle-errors";
|
||||
import {convertWorkflowJobInputs} from "./job/inputs";
|
||||
import {convertJobs} from "./jobs";
|
||||
|
||||
export function convertReferencedWorkflow(
|
||||
context: TemplateContext,
|
||||
referencedWorkflow: TemplateToken,
|
||||
job: ReusableWorkflowJob
|
||||
) {
|
||||
const mapping = referencedWorkflow.assertMapping("root");
|
||||
|
||||
// The language service doesn't currently handles on other documents,
|
||||
// So use the ref in the original workflow as the error location
|
||||
const tokenForErrors = job.ref;
|
||||
|
||||
for (const pair of mapping) {
|
||||
const key = pair.key.assertString("root key");
|
||||
switch (key.value) {
|
||||
case "on": {
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () =>
|
||||
convertReferencedWorkflowOn(context, pair.value, job)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "jobs": {
|
||||
job.jobs = handleTemplateTokenErrors(tokenForErrors, context, [], () => convertJobs(context, pair.value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function convertReferencedWorkflowOn(context: TemplateContext, on: TemplateToken, job: ReusableWorkflowJob) {
|
||||
const tokenForErrors = job.ref;
|
||||
switch (on.templateTokenType) {
|
||||
case TokenType.String: {
|
||||
const event = on.assertString("Reference workflow on value").value;
|
||||
if (event === "workflow_call") {
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case TokenType.Sequence: {
|
||||
const events = on.assertSequence("Reference workflow on value");
|
||||
for (const eventToken of events) {
|
||||
const event = eventToken.assertString(`Reference workflow on value ${eventToken}`).value;
|
||||
if (event === "workflow_call") {
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job));
|
||||
return;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case TokenType.Mapping: {
|
||||
const eventMapping = on.assertMapping("Reference workflow on value");
|
||||
|
||||
for (const pair of eventMapping) {
|
||||
const event = pair.key.assertString(`Reference workflow on value ${pair.key}`).value;
|
||||
if (event !== "workflow_call") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pair.value.templateTokenType === TokenType.Null) {
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job));
|
||||
return;
|
||||
}
|
||||
|
||||
const definitions = pair.value.assertMapping(`Reference workflow on value ${pair.key}`);
|
||||
for (const definition of definitions) {
|
||||
const definitionKey = definition.key.assertString(`on-workflow_call-${definition.key}`).value;
|
||||
switch (definitionKey) {
|
||||
case "inputs":
|
||||
job["input-definitions"] = definition.value.assertMapping(`on-workflow_call-${definition.key}`);
|
||||
break;
|
||||
|
||||
case "outputs":
|
||||
job.outputs = definition.value.assertMapping(`on-workflow_call-${definition.key}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
context.error(tokenForErrors, "workflow_call key is not defined in the referenced workflow.");
|
||||
}
|
||||
@@ -52,7 +52,7 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
|
||||
let uses: StringToken | undefined;
|
||||
let continueOnError: boolean | undefined;
|
||||
let env: MappingToken | undefined;
|
||||
const ifCondition = new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, "success()");
|
||||
const ifCondition = new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined);
|
||||
for (const item of mapping) {
|
||||
const key = item.key.assertString("steps item key");
|
||||
switch (key.value) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ActionStep, RunStep, Step} from "./workflow-template";
|
||||
import {ActionStep, Job, ReusableWorkflowJob, RunStep, Step, WorkflowJob} from "./workflow-template";
|
||||
|
||||
export function isRunStep(step: Step): step is RunStep {
|
||||
return (step as RunStep).run !== undefined;
|
||||
@@ -7,3 +7,11 @@ export function isRunStep(step: Step): step is RunStep {
|
||||
export function isActionStep(step: Step): step is ActionStep {
|
||||
return (step as ActionStep).uses !== undefined;
|
||||
}
|
||||
|
||||
export function isJob(job: WorkflowJob): job is Job {
|
||||
return job.type === "job";
|
||||
}
|
||||
|
||||
export function isReusableWorkflowJob(job: WorkflowJob): job is ReusableWorkflowJob {
|
||||
return job.type === "reusableWorkflowJob";
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
export type WorkflowTemplate = {
|
||||
events: EventsConfig;
|
||||
jobs: Job[];
|
||||
jobs: WorkflowJob[];
|
||||
concurrency: TemplateToken;
|
||||
env: TemplateToken;
|
||||
|
||||
@@ -28,23 +28,41 @@ export type ActionsEnvironmentReference = {
|
||||
url?: TemplateToken;
|
||||
};
|
||||
|
||||
export type Job = {
|
||||
type: string;
|
||||
export type WorkflowJob = Job | ReusableWorkflowJob;
|
||||
|
||||
export type JobType = "job" | "reusableWorkflowJob";
|
||||
|
||||
export type BaseJob = {
|
||||
type: JobType;
|
||||
id: StringToken;
|
||||
name?: ScalarToken;
|
||||
needs?: StringToken[];
|
||||
if: BasicExpressionToken;
|
||||
env?: MappingToken;
|
||||
concurrency?: TemplateToken;
|
||||
environment?: TemplateToken;
|
||||
strategy?: TemplateToken;
|
||||
outputs?: MappingToken;
|
||||
};
|
||||
|
||||
// `job-factory` in the schema
|
||||
export type Job = BaseJob & {
|
||||
type: "job";
|
||||
env?: MappingToken;
|
||||
environment?: TemplateToken;
|
||||
"runs-on"?: TemplateToken;
|
||||
container?: TemplateToken;
|
||||
services?: TemplateToken;
|
||||
outputs?: MappingToken;
|
||||
steps: Step[];
|
||||
};
|
||||
|
||||
// `workflow-job` in the schema
|
||||
export type ReusableWorkflowJob = BaseJob & {
|
||||
type: "reusableWorkflowJob";
|
||||
ref: StringToken;
|
||||
"input-definitions"?: MappingToken;
|
||||
"input-values"?: MappingToken;
|
||||
jobs?: WorkflowJob[];
|
||||
};
|
||||
|
||||
export type Container = {
|
||||
image: StringToken;
|
||||
credentials?: Credential;
|
||||
|
||||
@@ -616,10 +616,10 @@ class TemplateReader {
|
||||
tr: TokenRange,
|
||||
rawExpression: string,
|
||||
allowedContext: string[],
|
||||
token: TemplateToken,
|
||||
token: StringToken,
|
||||
definitionInfo: DefinitionInfo | undefined
|
||||
): ExpressionToken | undefined {
|
||||
const parseExpressionResult = this.parseExpression(tr, rawExpression, allowedContext, definitionInfo);
|
||||
const parseExpressionResult = this.parseExpression(tr, token, rawExpression, allowedContext, definitionInfo);
|
||||
|
||||
// Check for error
|
||||
if (parseExpressionResult.error) {
|
||||
@@ -631,7 +631,8 @@ class TemplateReader {
|
||||
}
|
||||
|
||||
private parseExpression(
|
||||
range: TokenRange | undefined,
|
||||
range: TokenRange,
|
||||
token: StringToken,
|
||||
value: string,
|
||||
allowedContext: string[],
|
||||
definitionInfo: DefinitionInfo | undefined
|
||||
@@ -666,9 +667,31 @@ class TemplateReader {
|
||||
};
|
||||
}
|
||||
|
||||
const startTrim = value.length - value.trimStart().length;
|
||||
const endTrim = value.length - value.trimEnd().length;
|
||||
|
||||
const expressionRange: TokenRange = {
|
||||
start: {
|
||||
...range.start,
|
||||
column: range.start.column + OPEN_EXPRESSION.length + startTrim
|
||||
},
|
||||
end: {
|
||||
...range.end,
|
||||
column: range.end.column - CLOSE_EXPRESSION.length - endTrim
|
||||
}
|
||||
};
|
||||
|
||||
// Return the expression
|
||||
return <ParseExpressionResult>{
|
||||
expression: new BasicExpressionToken(this._fileId, range, trimmed, definitionInfo, undefined, value),
|
||||
expression: new BasicExpressionToken(
|
||||
this._fileId,
|
||||
range,
|
||||
trimmed,
|
||||
definitionInfo,
|
||||
undefined,
|
||||
token.source,
|
||||
expressionRange
|
||||
),
|
||||
error: undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,10 +11,20 @@ import {TokenType} from "./types";
|
||||
export class BasicExpressionToken extends ExpressionToken {
|
||||
private readonly expr: string;
|
||||
|
||||
public readonly source: string | undefined;
|
||||
|
||||
public readonly originalExpressions: BasicExpressionToken[] | undefined;
|
||||
|
||||
public readonly source: string;
|
||||
|
||||
/**
|
||||
* The range of the expression within the source string.
|
||||
*
|
||||
* `range` is the range of the entire expression, including the `${{` and `}}`. `expression` is only the expression
|
||||
* without any ${{ }} markers. `expressionRange` is the range of just the expression within the document.
|
||||
*/
|
||||
public readonly expressionRange: TokenRange | undefined;
|
||||
|
||||
/**
|
||||
* @param originalExpressions If the basic expression was transformed from individual expressions, these will be the original ones
|
||||
*/
|
||||
@@ -24,12 +34,14 @@ export class BasicExpressionToken extends ExpressionToken {
|
||||
expression: string,
|
||||
definitionInfo: DefinitionInfo | undefined,
|
||||
originalExpressions: BasicExpressionToken[] | undefined,
|
||||
source: string
|
||||
source: string | undefined,
|
||||
expressionRange?: TokenRange | undefined
|
||||
) {
|
||||
super(TokenType.BasicExpression, file, range, undefined, definitionInfo);
|
||||
this.expr = expression;
|
||||
this.originalExpressions = originalExpressions;
|
||||
this.source = source;
|
||||
this.originalExpressions = originalExpressions;
|
||||
this.expressionRange = expressionRange;
|
||||
}
|
||||
|
||||
public get expression(): string {
|
||||
@@ -44,7 +56,8 @@ export class BasicExpressionToken extends ExpressionToken {
|
||||
this.expr,
|
||||
this.definitionInfo,
|
||||
this.originalExpressions,
|
||||
this.source
|
||||
this.source,
|
||||
this.expressionRange
|
||||
)
|
||||
: new BasicExpressionToken(
|
||||
this.file,
|
||||
@@ -52,7 +65,8 @@ export class BasicExpressionToken extends ExpressionToken {
|
||||
this.expr,
|
||||
this.definitionInfo,
|
||||
this.originalExpressions,
|
||||
this.source
|
||||
this.source,
|
||||
this.expressionRange
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,10 @@ import {TemplateToken} from "./template-token";
|
||||
describe("traverse", () => {
|
||||
it("returns parent token and key", () => {
|
||||
const workflow = parseWorkflow(
|
||||
"wf.yaml",
|
||||
[
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push`
|
||||
}
|
||||
],
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export type Position = {
|
||||
/** The one-based line value */
|
||||
line: number;
|
||||
|
||||
/** The one-based column value */
|
||||
column: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import {File} from "./file";
|
||||
import {FileReference} from "./file-reference";
|
||||
|
||||
export interface FileProvider {
|
||||
getFileContent(ref: FileReference): Promise<File>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {parseFileReference} from "./file-reference";
|
||||
|
||||
describe("parseFileReference", () => {
|
||||
it("parses local file reference", () => {
|
||||
const ref = parseFileReference("./workflow/path");
|
||||
expect(ref).toEqual({
|
||||
path: "workflow/path"
|
||||
});
|
||||
});
|
||||
|
||||
it("parses local file references with an empty path", () => {
|
||||
const ref = parseFileReference("./");
|
||||
expect(ref).toEqual({
|
||||
path: ""
|
||||
});
|
||||
});
|
||||
|
||||
it("parses remote file reference", () => {
|
||||
const ref = parseFileReference("owner/repo/path@version");
|
||||
expect(ref).toEqual({
|
||||
owner: "owner",
|
||||
repository: "repo",
|
||||
path: "path",
|
||||
version: "version"
|
||||
});
|
||||
});
|
||||
|
||||
it("parses remote file reference with an empty path", () => {
|
||||
const ref = parseFileReference("owner/repo@version");
|
||||
expect(ref).toEqual({
|
||||
owner: "owner",
|
||||
repository: "repo",
|
||||
path: "",
|
||||
version: "version"
|
||||
});
|
||||
});
|
||||
|
||||
it("parses remote file reference with slashes in the version", () => {
|
||||
const ref = parseFileReference("owner/repo@feature-branch/dev");
|
||||
expect(ref).toEqual({
|
||||
owner: "owner",
|
||||
repository: "repo",
|
||||
path: "",
|
||||
version: "feature-branch/dev"
|
||||
});
|
||||
});
|
||||
|
||||
it("throws for malformed remote file references", () => {
|
||||
expect(() => parseFileReference("owner/repo/path")).toThrowError("Invalid file reference: owner/repo/path");
|
||||
|
||||
expect(() => parseFileReference("owner/repo/path@")).toThrowError("Invalid file reference: owner/repo/path@");
|
||||
|
||||
expect(() => parseFileReference("owner@")).toThrowError("Invalid file reference: owner@");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
export type FileReference = LocalFileReference | RemoteFileReference;
|
||||
|
||||
export type LocalFileReference = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type RemoteFileReference = {
|
||||
repository: string;
|
||||
owner: string;
|
||||
path: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export function parseFileReference(ref: string): FileReference {
|
||||
if (ref.startsWith("./")) {
|
||||
return {
|
||||
path: ref.substring(2)
|
||||
};
|
||||
}
|
||||
|
||||
const [remotePath, version] = ref.split("@");
|
||||
const [owner, repository, ...pathSegments] = remotePath.split("/").filter(s => s.length > 0);
|
||||
|
||||
if (!owner || !repository || !version) {
|
||||
throw new Error(`Invalid file reference: ${ref}`);
|
||||
}
|
||||
|
||||
return {
|
||||
repository,
|
||||
owner,
|
||||
path: pathSegments.join("/"),
|
||||
version
|
||||
};
|
||||
}
|
||||
|
||||
export function fileIdentifier(ref: FileReference): string {
|
||||
if (!("repository" in ref)) {
|
||||
return "./" + ref.path;
|
||||
}
|
||||
|
||||
return `${ref.owner}/${ref.repository}/${ref.path}@${ref.version}`;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
- name: 'Hello \${{ fromJSON('test') == inputs.name }}'
|
||||
run: echo Hello, world!`;
|
||||
|
||||
const result = parseWorkflow("main.yaml", [{name: "main.yaml", content: content}], nullTrace);
|
||||
const result = parseWorkflow({name: "main.yaml", content: content}, nullTrace);
|
||||
|
||||
expect(result.context.errors.count).toBe(1);
|
||||
expect(result.value).toBeUndefined();
|
||||
|
||||
@@ -11,15 +11,16 @@ export interface ParseWorkflowResult {
|
||||
value: TemplateToken | undefined;
|
||||
}
|
||||
|
||||
export function parseWorkflow(entryFileName: string, files: File[], trace: TraceWriter): ParseWorkflowResult {
|
||||
const context = new TemplateContext(new TemplateValidationErrors(), getWorkflowSchema(), trace);
|
||||
export function parseWorkflow(entryFile: File, trace: TraceWriter): ParseWorkflowResult;
|
||||
export function parseWorkflow(entryFile: File, context: TemplateContext): ParseWorkflowResult;
|
||||
export function parseWorkflow(entryFile: File, contextOrTrace: TraceWriter | TemplateContext): ParseWorkflowResult {
|
||||
const context =
|
||||
contextOrTrace instanceof TemplateContext
|
||||
? contextOrTrace
|
||||
: new TemplateContext(new TemplateValidationErrors(), getWorkflowSchema(), contextOrTrace);
|
||||
|
||||
// Add file ids
|
||||
files.forEach(x => context.getFileId(x.name));
|
||||
|
||||
const fileId = context.getFileId(entryFileName);
|
||||
const fileContent = files[fileId - 1].content;
|
||||
const reader = new YamlObjectReader(context, fileId, fileContent);
|
||||
const fileId = context.getFileId(entryFile.name);
|
||||
const reader = new YamlObjectReader(context, fileId, entryFile.content);
|
||||
if (context.errors.count > 0) {
|
||||
// The file is not valid YAML, template errors could be misleading
|
||||
return {
|
||||
|
||||
@@ -81,13 +81,10 @@ it("YAML errors include range information", () => {
|
||||
|
||||
function parseAsWorkflow(content: string): TemplateToken | undefined {
|
||||
const result = parseWorkflow(
|
||||
"test.yaml",
|
||||
[
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: content
|
||||
}
|
||||
],
|
||||
{
|
||||
name: "test.yaml",
|
||||
content: content
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import * as path from "path";
|
||||
import * as YAML from "yaml";
|
||||
import {convertWorkflowTemplate} from "./model/convert";
|
||||
import {TraceWriter} from "./templates/trace-writer";
|
||||
import {File} from "./workflows/file";
|
||||
import {FileProvider} from "./workflows/file-provider";
|
||||
import {fileIdentifier, FileReference} from "./workflows/file-reference";
|
||||
import {parseWorkflow} from "./workflows/workflow-parser";
|
||||
|
||||
interface TestOptions {
|
||||
@@ -41,31 +44,53 @@ describe("x-lang tests", () => {
|
||||
const testOptions: TestOptions = YAML.parse(testDocs[0]);
|
||||
const unsupportedTest = contains(testOptions.skip, "TypeScript");
|
||||
|
||||
const test = () => {
|
||||
let testFileName = ".github/workflows" + fileName.substring(fileName.lastIndexOf("/"));
|
||||
let testInput = testDocs[1];
|
||||
let expectedTemplate = testDocs[2].trim();
|
||||
// TODO: when reusable workflows are implemented, implement correctly
|
||||
const test = async () => {
|
||||
const testFileName = ".github/workflows" + fileName.substring(fileName.lastIndexOf("/"));
|
||||
const testInput = testDocs[1];
|
||||
const expectedTemplate = testDocs[testDocs.length - 1].trim();
|
||||
|
||||
// For reusable workflow tests, additional workflows are passed in as pairs of
|
||||
// file names and file contents
|
||||
const reusableWorkflows: Record<string, File> = {};
|
||||
if (fileName.indexOf("reusable") !== -1) {
|
||||
testFileName = testDocs[1];
|
||||
testInput = testDocs[2];
|
||||
expectedTemplate = testDocs[3].trim();
|
||||
for (let i = 2; i < testDocs.length - 1; i = i + 2) {
|
||||
reusableWorkflows[testDocs[i]] = {
|
||||
name: testDocs[i],
|
||||
content: testDocs[i + 1]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const parseResult = parseWorkflow(
|
||||
testFileName,
|
||||
[
|
||||
{
|
||||
name: testFileName,
|
||||
content: testInput
|
||||
const testFileProvider: FileProvider = {
|
||||
getFileContent: async (ref: FileReference) => {
|
||||
const file = reusableWorkflows[fileIdentifier(ref)];
|
||||
if (file) {
|
||||
return file;
|
||||
}
|
||||
],
|
||||
|
||||
throw new Error("File not found: " + fileName);
|
||||
}
|
||||
};
|
||||
|
||||
const parseResult = parseWorkflow(
|
||||
{
|
||||
name: testFileName,
|
||||
content: testInput
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
expect(parseResult.value).not.toBeUndefined();
|
||||
|
||||
const workflowTemplate = convertWorkflowTemplate(parseResult.context, parseResult.value!);
|
||||
const workflowTemplate = await convertWorkflowTemplate(
|
||||
parseResult.context,
|
||||
parseResult.value!,
|
||||
undefined,
|
||||
testFileProvider,
|
||||
{
|
||||
fetchReusableWorkflowDepth: 1
|
||||
}
|
||||
);
|
||||
|
||||
// Unless this tests is only used by TypeScript, remove the events for now.
|
||||
// TODO: Remove this once we parse events everywhere
|
||||
|
||||
Reference in New Issue
Block a user