From 13aaa381c8c3855ee08e07cead49b6cd5c8ecc01 Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Mon, 6 Feb 2023 15:10:34 -0500 Subject: [PATCH] Parse reusable workflows up to a depth of 1 --- .../src/test-utils/workflow-context.ts | 8 +- actions-languageservice/src/complete.ts | 2 +- .../src/context/workflow-context.test.ts | 20 +-- actions-languageservice/src/document-links.ts | 2 +- .../src/expression-hover/visitor.test.ts | 2 +- actions-languageservice/src/hover.ts | 4 +- actions-languageservice/src/validate.ts | 2 +- .../src/model/convert.test.ts | 24 ++-- actions-workflow-parser/src/model/convert.ts | 78 ++++++++++- .../src/model/converter/base-job.ts | 66 --------- .../src/model/converter/job.ts | 125 ++++++++++++++---- .../src/model/converter/jobs.ts | 8 +- .../src/model/converter/referencedWorkflow.ts | 98 ++++++++++++++ .../src/model/converter/reusable-job.ts | 34 ----- .../src/model/workflow-template.ts | 5 +- .../src/workflows/file-provider.ts | 2 + .../src/workflows/workflow-parser.ts | 10 +- actions-workflow-parser/src/xlang.test.ts | 45 +++++-- .../errors-reusable-workflow-job-secrets.yml | 1 - .../reader/reusable-workflow-job-name.yml | 1 - .../reader/reusable-workflow-multiple.yml | 1 - .../reader/reusable-workflow-no-inputs.yml | 1 - 22 files changed, 356 insertions(+), 183 deletions(-) delete mode 100644 actions-workflow-parser/src/model/converter/base-job.ts create mode 100644 actions-workflow-parser/src/model/converter/referencedWorkflow.ts delete mode 100644 actions-workflow-parser/src/model/converter/reusable-job.ts diff --git a/actions-languageserver/src/test-utils/workflow-context.ts b/actions-languageserver/src/test-utils/workflow-context.ts index bd0ee97..edde697 100644 --- a/actions-languageserver/src/test-utils/workflow-context.ts +++ b/actions-languageserver/src/test-utils/workflow-context.ts @@ -8,12 +8,16 @@ const nullTrace: TraceWriter = { error: x => {} }; -export function createWorkflowContext(workflow: string, job?: string, stepIndex?: number): WorkflowContext { +export async function createWorkflowContext( + workflow: string, + job?: string, + stepIndex?: number +): Promise { const parsed = parseWorkflow({name: "test.yaml", content: workflow}, nullTrace); if (!parsed.value) { throw new Error("Failed to parse workflow"); } - const template = convertWorkflowTemplate(parsed.context, parsed.value); + const template = await convertWorkflowTemplate(parsed.context, parsed.value); const context: WorkflowContext = {uri: "test.yaml", template}; if (job) { diff --git a/actions-languageservice/src/complete.ts b/actions-languageservice/src/complete.ts index 3047d63..1b888b7 100644 --- a/actions-languageservice/src/complete.ts +++ b/actions-languageservice/src/complete.ts @@ -71,7 +71,7 @@ export async function complete( } const {token, keyToken, parent, path} = findToken(newPos, result.value); - const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion); + const template = await convertWorkflowTemplate(result.context, result.value, 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 diff --git a/actions-languageservice/src/context/workflow-context.test.ts b/actions-languageservice/src/context/workflow-context.test.ts index c336f3c..1f2a3ad 100644 --- a/actions-languageservice/src/context/workflow-context.test.ts +++ b/actions-languageservice/src/context/workflow-context.test.ts @@ -5,7 +5,7 @@ import {getPositionFromCursor} from "../test-utils/cursor-position"; import {findToken} from "../utils/find-token"; import {getWorkflowContext, WorkflowContext} from "./workflow-context"; -function testGetWorkflowContext(input: string): WorkflowContext { +async function testGetWorkflowContext(input: string): Promise { const [textDocument, pos] = getPositionFromCursor(input); const result = parseWorkflow( { @@ -18,7 +18,7 @@ function testGetWorkflowContext(input: string): WorkflowContext { let template: WorkflowTemplate | undefined; if (result.value) { - template = convertWorkflowTemplate(result.context, result.value); + template = await convertWorkflowTemplate(result.context, result.value); } const {path} = findToken(pos, result.value); @@ -27,8 +27,8 @@ function testGetWorkflowContext(input: string): WorkflowContext { } describe("getWorkflowContext", () => { - it("context for workflow", () => { - const context = testGetWorkflowContext(`on: push + it("context for workflow", async () => { + const context = await testGetWorkflowContext(`on: push name: te|st jobs: build: @@ -41,8 +41,8 @@ jobs: expect(context.step).toBeUndefined(); }); - it("context for workflow job", () => { - const context = testGetWorkflowContext(`on: push + it("context for workflow job", async () => { + const context = await testGetWorkflowContext(`on: push jobs: build: runs-on: ubuntu-lat|est @@ -54,8 +54,8 @@ jobs: expect(context.step).toBeUndefined(); }); - it("context for workflow run step", () => { - const context = testGetWorkflowContext(`on: push + it("context for workflow run step", async () => { + const context = await testGetWorkflowContext(`on: push jobs: build: runs-on: ubuntu-latest @@ -71,8 +71,8 @@ jobs: expect(step.run.toDisplayString()).toBe("echo Hello"); }); - it("context for workflow uses step", () => { - const context = testGetWorkflowContext(`on: push + it("context for workflow uses step", async () => { + const context = await testGetWorkflowContext(`on: push jobs: build: runs-on: ubuntu-latest diff --git a/actions-languageservice/src/document-links.ts b/actions-languageservice/src/document-links.ts index 625498d..a22d358 100644 --- a/actions-languageservice/src/document-links.ts +++ b/actions-languageservice/src/document-links.ts @@ -19,7 +19,7 @@ export async function documentLinks(document: TextDocument): Promise { - it("converts workflow with one job", () => { + it("converts workflow with one job", async () => { const result = parseWorkflow( { name: "wf.yaml", @@ -19,7 +19,7 @@ jobs: 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: { @@ -43,7 +43,7 @@ jobs: }); }); - it("converts workflow if expressions", () => { + it("converts workflow if expressions", async () => { const result = parseWorkflow( { name: "wf.yaml", @@ -59,7 +59,7 @@ jobs: 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: { @@ -92,7 +92,7 @@ jobs: }); }); - it("converts workflow with empty needs", () => { + it("converts workflow with empty needs", async () => { const result = parseWorkflow( { name: "wf.yaml", @@ -105,7 +105,7 @@ jobs: 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: [ @@ -134,7 +134,7 @@ jobs: }); }); - it("converts workflow with needs errors", () => { + it("converts workflow with needs errors", async () => { const result = parseWorkflow( { name: "wf.yaml", @@ -152,7 +152,7 @@ jobs: 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: [ @@ -211,7 +211,7 @@ jobs: }); }); - it("converts workflow with invalid on", () => { + it("converts workflow with invalid on", async () => { const result = parseWorkflow( { name: "wf.yaml", @@ -229,7 +229,7 @@ jobs: 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); @@ -271,7 +271,7 @@ jobs: }); }); - it("converts workflow with invalid jobs", () => { + it("converts workflow with invalid jobs", async () => { const result = parseWorkflow( { name: "wf.yaml", @@ -282,7 +282,7 @@ jobs: 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); diff --git a/actions-workflow-parser/src/model/convert.ts b/actions-workflow-parser/src/model/convert.ts index 3bd9350..197926b 100644 --- a/actions-workflow-parser/src/model/convert.ts +++ b/actions-workflow-parser/src/model/convert.ts @@ -1,9 +1,13 @@ import {TemplateContext} from "../templates/template-context"; import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token"; +import {FileProvider} from "../workflows/file-provider"; +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 +15,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 = { + 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 { const result = {} as WorkflowTemplate; if (context.errors.getErrors().length > 0 && errorPolicy === ErrorPolicy.ReturnErrorsOnly) { @@ -25,6 +53,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 +83,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(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 +125,16 @@ export function convertWorkflowTemplate( return result; } + +function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Required { + return { + maxReusableWorkflowDepth: + options.maxReusableWorkflowDepth !== undefined + ? options.maxReusableWorkflowDepth + : defaultOptions.maxReusableWorkflowDepth, + fetchReusableWorkflowDepth: + options.fetchReusableWorkflowDepth !== undefined + ? options.fetchReusableWorkflowDepth + : defaultOptions.fetchReusableWorkflowDepth + }; +} diff --git a/actions-workflow-parser/src/model/converter/base-job.ts b/actions-workflow-parser/src/model/converter/base-job.ts deleted file mode 100644 index 0b1d02e..0000000 --- a/actions-workflow-parser/src/model/converter/base-job.ts +++ /dev/null @@ -1,66 +0,0 @@ -import {TemplateContext} from "../../templates/template-context"; -import {StringToken, MappingToken, BasicExpressionToken} from "../../templates/tokens"; -import {isSequence, isString} from "../../templates/tokens/type-guards"; -import {BaseJob} from "../workflow-template"; -import {convertConcurrency} from "./concurrency"; -import {handleTemplateTokenErrors} from "./handle-errors"; -import {IdBuilder} from "./id-builder"; - -export function convertBaseJob( - context: TemplateContext, - jobKey: StringToken, - token: MappingToken -): Omit { - const error = new IdBuilder().tryAddKnownId(jobKey.value); - if (error) { - context.error(jobKey, error); - } - - const result: Omit = { - id: jobKey, - name: undefined, - needs: undefined, - if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined), - concurrency: undefined, - strategy: 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)); - result.concurrency = 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 "strategy": - result.strategy = item.value; - break; - } - } - - if (!result.name) { - result.name = result.id; - } - - return result; -} diff --git a/actions-workflow-parser/src/model/converter/job.ts b/actions-workflow-parser/src/model/converter/job.ts index 4fda96b..a7f1776 100644 --- a/actions-workflow-parser/src/model/converter/job.ts +++ b/actions-workflow-parser/src/model/converter/job.ts @@ -1,70 +1,137 @@ import {TemplateContext} from "../../templates/template-context"; -import {StringToken, MappingToken} from "../../templates/tokens"; -import {Job} from "../workflow-template"; -import {convertBaseJob} from "./base-job"; +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): Job { - const base = convertBaseJob(context, jobKey, token); - const result: Job = { - type: "job", - id: base.id, - name: base.name, - needs: base.needs, - if: base.if, - env: undefined, - concurrency: base.concurrency, - environment: undefined, - strategy: base.strategy, - "runs-on": undefined, - container: undefined, - services: undefined, - outputs: undefined, - 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; 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); - result.container = item.value; + container = item.value; break; case "env": - result.env = item.value.assertMapping("job env"); + env = item.value.assertMapping("job env"); break; case "environment": handleTemplateTokenErrors(item.value, context, undefined, () => convertToActionsEnvironmentRef(context, item.value) ); - result.environment = 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": - result.outputs = item.value.assertMapping("job outputs"); + outputs = item.value.assertMapping("job outputs"); break; case "runs-on": handleTemplateTokenErrors(item.value, context, undefined, () => convertRunsOn(context, item.value)); - result["runs-on"] = item.value; + runsOn = item.value; break; case "services": convertToJobServices(context, item.value); - result.services = item.value; + services = item.value; break; case "steps": - result.steps = convertSteps(context, item.value); + steps = convertSteps(context, item.value); + break; + + case "strategy": + strategy = item.value; + break; + + case "uses": + workflowJobRef = item.value.assertString("job item uses"); break; } } - return result; + 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, + 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; } diff --git a/actions-workflow-parser/src/model/converter/jobs.ts b/actions-workflow-parser/src/model/converter/jobs.ts index 5aab4c5..b07e368 100644 --- a/actions-workflow-parser/src/model/converter/jobs.ts +++ b/actions-workflow-parser/src/model/converter/jobs.ts @@ -2,7 +2,7 @@ import {TemplateContext} from "../../templates/template-context"; import {StringToken} from "../../templates/tokens"; import {TemplateToken} from "../../templates/tokens/template-token"; import {isMapping} from "../../templates/tokens/type-guards"; -import {Job} from "../workflow-template"; +import {WorkflowJob} from "../workflow-template"; import {handleTemplateTokenErrors} from "./handle-errors"; import {convertJob} from "./job"; @@ -11,9 +11,9 @@ type nodeInfo = { 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[] = []; @@ -49,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[] ) { diff --git a/actions-workflow-parser/src/model/converter/referencedWorkflow.ts b/actions-workflow-parser/src/model/converter/referencedWorkflow.ts new file mode 100644 index 0000000..e557d82 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/referencedWorkflow.ts @@ -0,0 +1,98 @@ +import {TemplateContext} from "../../templates/template-context"; +import {TemplateToken} from "../../templates/tokens"; +import {TokenType} from "../../templates/tokens/types"; +import {ReusableWorkflowJob} from "../workflow-template"; +import {convertOn} from "./events"; +import {handleTemplateTokenErrors} from "./handle-errors"; +import {convertJobs} from "./jobs"; + +export function convertReferencedWorkflow( + context: TemplateContext, + referencedWorkflow: TemplateToken, + job: ReusableWorkflowJob +) { + const mapping = referencedWorkflow.assertMapping("root"); + + let onToken: TemplateToken | undefined; + for (const pair of mapping) { + const key = pair.key.assertString("root key"); + switch (key.value) { + case "on": { + handleTemplateTokenErrors(mapping, context, undefined, () => + convertReferencedWorkflowOn(context, pair.value, job) + ); + break; + } + case "jobs": { + job.jobs = handleTemplateTokenErrors(mapping, context, [], () => convertJobs(context, pair.value)); + break; + } + } + } + + if (!onToken) { + return; + } + + const events = convertOn(context, onToken); + if (events.workflow_call === undefined) { + context.error(onToken, "workflow_call key is not defined in the referenced workflow."); + } +} + +function convertReferencedWorkflowOn(context: TemplateContext, on: TemplateToken, job: ReusableWorkflowJob) { + switch (on.templateTokenType) { + case TokenType.String: { + const event = on.assertString("Reference workflow on value").value; + if (event === "workflow_call") { + // TODO: Validate workflow job trigger + 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") { + // TODO: Validate workflow job trigger + 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) { + // TODO: Validate workflow job trigger + 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; + } + } + + // TODO: Validate workflow job trigger + return; + } + break; + } + } + + context.error(on, "workflow_call key is not defined in the referenced workflow."); +} diff --git a/actions-workflow-parser/src/model/converter/reusable-job.ts b/actions-workflow-parser/src/model/converter/reusable-job.ts deleted file mode 100644 index 3eef8f3..0000000 --- a/actions-workflow-parser/src/model/converter/reusable-job.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {TemplateContext} from "../../templates/template-context"; -import {MappingToken, StringToken} from "../../templates/tokens"; -import {ReusableWorkflowJob} from "../workflow-template"; -import {convertBaseJob} from "./base-job"; - -export function convertResuableWorkflowJob( - context: TemplateContext, - jobKey: StringToken, - token: MappingToken -): ReusableWorkflowJob { - const base = convertBaseJob(context, jobKey, token); - - const result: ReusableWorkflowJob = { - type: "reusableWorkflowJob", - id: base.id, - name: base.name, - needs: base.needs, - if: base.if, - concurrency: base.concurrency, - strategy: base.strategy, - uses: new StringToken(undefined, undefined, "undefined", undefined, undefined) - }; - - for (const item of token) { - const propertyName = item.key.assertString("job property name"); - switch (propertyName.value) { - case "uses": - result.uses = item.value.assertString("job item uses"); - break; - } - } - - return result; -} diff --git a/actions-workflow-parser/src/model/workflow-template.ts b/actions-workflow-parser/src/model/workflow-template.ts index 508b424..094e209 100644 --- a/actions-workflow-parser/src/model/workflow-template.ts +++ b/actions-workflow-parser/src/model/workflow-template.ts @@ -57,7 +57,10 @@ export type Job = BaseJob & { // `workflow-job` in the schema export type ReusableWorkflowJob = BaseJob & { type: "reusableWorkflowJob"; - uses: StringToken; + ref: StringToken; + "input-definitions"?: MappingToken; + "input-values"?: MappingToken; + jobs?: WorkflowJob[]; }; export type Container = { diff --git a/actions-workflow-parser/src/workflows/file-provider.ts b/actions-workflow-parser/src/workflows/file-provider.ts index 0bdc01d..468fd34 100644 --- a/actions-workflow-parser/src/workflows/file-provider.ts +++ b/actions-workflow-parser/src/workflows/file-provider.ts @@ -1,3 +1,5 @@ +import {File} from "./file"; + export interface FileProvider { getFileContent(path: string): Promise; } diff --git a/actions-workflow-parser/src/workflows/workflow-parser.ts b/actions-workflow-parser/src/workflows/workflow-parser.ts index f291717..ef32027 100644 --- a/actions-workflow-parser/src/workflows/workflow-parser.ts +++ b/actions-workflow-parser/src/workflows/workflow-parser.ts @@ -3,7 +3,6 @@ import * as templateReader from "../templates/template-reader"; import {TemplateToken} from "../templates/tokens/template-token"; import {TraceWriter} from "../templates/trace-writer"; import {File} from "./file"; -import {FileProvider} from "./file-provider"; import {WORKFLOW_ROOT} from "./workflow-constants"; import {getWorkflowSchema} from "./workflow-schema"; import {YamlObjectReader} from "./yaml-object-reader"; @@ -12,8 +11,13 @@ export interface ParseWorkflowResult { value: TemplateToken | undefined; } -export function parseWorkflow(entryFile: File, trace: TraceWriter, fileProvider?: FileProvider): 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); const fileId = context.getFileId(entryFile.name); const reader = new YamlObjectReader(context, fileId, entryFile.content); diff --git a/actions-workflow-parser/src/xlang.test.ts b/actions-workflow-parser/src/xlang.test.ts index a52fcf6..4b2fee9 100644 --- a/actions-workflow-parser/src/xlang.test.ts +++ b/actions-workflow-parser/src/xlang.test.ts @@ -3,6 +3,8 @@ 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 {parseWorkflow} from "./workflows/workflow-parser"; interface TestOptions { @@ -41,17 +43,34 @@ 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 = {}; 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 testFileProvider: FileProvider = { + getFileContent: async (fileName: string) => { + const file = reusableWorkflows[fileName]; + if (file) { + return file; + } + + throw new Error("File not found: " + fileName); + } + }; + const parseResult = parseWorkflow( { name: testFileName, @@ -62,7 +81,15 @@ describe("x-lang tests", () => { 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 diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets.yml index b812f32..dac0d5a 100644 --- a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets.yml +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets.yml @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-name.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-name.yml index 60c8c3c..3e67c56 100644 --- a/actions-workflow-parser/testdata/reader/reusable-workflow-job-name.yml +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-name.yml @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-multiple.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-multiple.yml index 959dfc1..d129cdb 100644 --- a/actions-workflow-parser/testdata/reader/reusable-workflow-multiple.yml +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-multiple.yml @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-no-inputs.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-no-inputs.yml index df0edad..e07bb6b 100644 --- a/actions-workflow-parser/testdata/reader/reusable-workflow-no-inputs.yml +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-no-inputs.yml @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: