Merge branch 'main' into thyeggman/cron-parsing

This commit is contained in:
Jacob Wallraff
2023-01-10 10:30:29 -08:00
128 changed files with 3306 additions and 4620 deletions
+92 -117
View File
@@ -1,9 +1,9 @@
import { nullTrace } from "../test-utils/null-trace"
import { parseWorkflow } from "../workflows/workflow-parser"
import { convertWorkflowTemplate, ErrorPolicy } from "./convert"
import {nullTrace} from "../test-utils/null-trace";
import {parseWorkflow} from "../workflows/workflow-parser";
import {convertWorkflowTemplate, ErrorPolicy} from "./convert";
function serializeTemplate(template: unknown): unknown {
return JSON.parse(JSON.stringify(template))
return JSON.parse(JSON.stringify(template));
}
describe("convertWorkflowTemplate", () => {
@@ -16,39 +16,35 @@ describe("convertWorkflowTemplate", () => {
content: `on: push
jobs:
build:
runs-on: ubuntu-latest`,
},
runs-on: ubuntu-latest`
}
],
nullTrace
)
);
const template = convertWorkflowTemplate(
result.context,
result.value!,
ErrorPolicy.TryConversion
)
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
expect(serializeTemplate(template)).toEqual({
events: {
push: {},
push: {}
},
jobs: [
{
id: "build",
if: {
expr: "success()",
type: 3,
type: 3
},
name: "build",
needs: undefined,
outputs: undefined,
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
},
],
})
})
type: "job"
}
]
});
});
it("converts workflow if expressions", () => {
const result = parseWorkflow(
@@ -63,48 +59,44 @@ jobs:
runs-on: ubuntu-latest
deploy:
if: true
runs-on: ubuntu-latest`,
},
runs-on: ubuntu-latest`
}
],
nullTrace
)
);
const template = convertWorkflowTemplate(
result.context,
result.value!,
ErrorPolicy.TryConversion
)
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
expect(serializeTemplate(template)).toEqual({
events: {
push: {},
push: {}
},
jobs: [
{
id: "build",
if: {
expr: "success()",
type: 3,
type: 3
},
name: "build",
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
type: "job"
},
{
id: "deploy",
if: {
expr: "success()",
type: 3,
type: 3
},
name: "deploy",
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
},
],
})
})
type: "job"
}
]
});
});
it("converts workflow with empty needs", () => {
const result = parseWorkflow(
@@ -116,44 +108,40 @@ jobs:
jobs:
build:
needs: # comment to preserve whitespace in test
runs-on: ubuntu-latest`,
},
runs-on: ubuntu-latest`
}
],
nullTrace
)
);
const template = convertWorkflowTemplate(
result.context,
result.value!,
ErrorPolicy.TryConversion
)
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
expect(serializeTemplate(template)).toEqual({
errors: [
{
Message: "wf.yaml (Line: 4, Col: 12): Unexpected value ''",
},
Message: "wf.yaml (Line: 4, Col: 12): Unexpected value ''"
}
],
events: {
push: {},
push: {}
},
jobs: [
{
id: "build",
if: {
expr: "success()",
type: 3,
type: 3
},
name: "build",
needs: [],
outputs: undefined,
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
},
],
})
})
type: "job"
}
]
});
});
it("converts workflow with needs errors", () => {
const result = parseWorkflow(
@@ -170,75 +158,70 @@ jobs:
runs-on: ubuntu-latest
job3:
needs: job1
runs-on: ubuntu-latest`,
},
runs-on: ubuntu-latest`
}
],
nullTrace
)
);
const template = convertWorkflowTemplate(
result.context,
result.value!,
ErrorPolicy.TryConversion
)
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
expect(serializeTemplate(template)).toEqual({
errors: [
{
Message:
"wf.yaml (Line: 4, Col: 13): Job 'job1' depends on unknown job 'unknown-job'.",
Message: "wf.yaml (Line: 4, Col: 13): Job 'job1' depends on unknown job 'unknown-job'."
},
{
Message:
"wf.yaml (Line: 4, Col: 26): Job 'job1' depends on job 'job3' which creates a cycle in the dependency graph.",
"wf.yaml (Line: 4, Col: 26): Job 'job1' depends on job 'job3' which creates a cycle in the dependency graph."
},
{
Message:
"wf.yaml (Line: 9, Col: 12): Job 'job3' depends on job 'job1' which creates a cycle in the dependency graph.",
},
"wf.yaml (Line: 9, Col: 12): Job 'job3' depends on job 'job1' which creates a cycle in the dependency graph."
}
],
events: {
push: {},
push: {}
},
jobs: [
{
id: "job1",
if: {
expr: "success()",
type: 3,
type: 3
},
name: "job1",
needs: ["unknown-job", "job3"],
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
type: "job"
},
{
id: "job2",
if: {
expr: "success()",
type: 3,
type: 3
},
name: "job2",
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
type: "job"
},
{
id: "job3",
if: {
expr: "success()",
type: 3,
type: 3
},
name: "job3",
needs: ["job1"],
"runs-on": "ubuntu-latest",
steps: [],
type: "job",
},
],
})
})
type: "job"
}
]
});
});
it("converts workflow with invalid on", () => {
const result = parseWorkflow(
@@ -255,29 +238,25 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hello`,
},
- run: echo hello`
}
],
nullTrace
)
);
const template = convertWorkflowTemplate(
result.context,
result.value!,
ErrorPolicy.TryConversion
)
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
expect(template.jobs).not.toBeUndefined()
expect(template.jobs).toHaveLength(1)
expect(template.jobs).not.toBeUndefined();
expect(template.jobs).toHaveLength(1);
expect(serializeTemplate(template)).toEqual({
errors: [
{
Message: "wf.yaml (Line: 5, Col: 18): Unexpected value '123'",
Message: "wf.yaml (Line: 5, Col: 18): Unexpected value '123'"
},
{
Message:
"wf.yaml (Line: 5, Col: 18): Unexpected type 'NumberToken' encountered while reading 'input options'. The type 'SequenceToken' was expected.",
},
"wf.yaml (Line: 5, Col: 18): Unexpected type 'NumberToken' encountered while reading 'input options'. The type 'SequenceToken' was expected."
}
],
events: {},
jobs: [
@@ -285,7 +264,7 @@ jobs:
id: "build",
if: {
expr: "success()",
type: 3,
type: 3
},
name: "build",
needs: undefined,
@@ -296,16 +275,16 @@ jobs:
id: "__run",
if: {
expr: "success()",
type: 3,
type: 3
},
run: "echo hello",
},
run: "echo hello"
}
],
type: "job",
},
],
})
})
type: "job"
}
]
});
});
it("converts workflow with invalid jobs", () => {
const result = parseWorkflow(
@@ -315,34 +294,30 @@ jobs:
name: "wf.yaml",
content: `on: push
jobs:
build:`,
},
build:`
}
],
nullTrace
)
);
const template = convertWorkflowTemplate(
result.context,
result.value!,
ErrorPolicy.TryConversion
)
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
expect(template.jobs).not.toBeUndefined()
expect(template.jobs).toHaveLength(0)
expect(template.jobs).not.toBeUndefined();
expect(template.jobs).toHaveLength(0);
expect(serializeTemplate(template)).toEqual({
errors: [
{
Message: "wf.yaml (Line: 3, Col: 9): Unexpected value ''",
Message: "wf.yaml (Line: 3, Col: 9): Unexpected value ''"
},
{
Message:
"wf.yaml (Line: 3, Col: 9): Unexpected type 'NullToken' encountered while reading 'job build'. The type 'MappingToken' was expected.",
},
"wf.yaml (Line: 3, Col: 9): Unexpected type 'NullToken' encountered while reading 'job build'. The type 'MappingToken' was expected."
}
],
events: {
push: {},
push: {}
},
jobs: [],
})
})
})
jobs: []
});
});
});
+31 -43
View File
@@ -1,17 +1,14 @@
import { TemplateContext } from "../templates/template-context"
import {
TemplateToken,
TemplateTokenError,
} from "../templates/tokens/template-token"
import { convertConcurrency } from "./converter/concurrency"
import { convertOn } from "./converter/events"
import { handleTemplateTokenErrors } from "./converter/handle-errors"
import { convertJobs } from "./converter/jobs"
import { WorkflowTemplate } from "./workflow-template"
import {TemplateContext} from "../templates/template-context";
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token";
import {convertConcurrency} from "./converter/concurrency";
import {convertOn} from "./converter/events";
import {handleTemplateTokenErrors} from "./converter/handle-errors";
import {convertJobs} from "./converter/jobs";
import {WorkflowTemplate} from "./workflow-template";
export enum ErrorPolicy {
ReturnErrorsOnly,
TryConversion,
TryConversion
}
export function convertWorkflowTemplate(
@@ -19,62 +16,53 @@ export function convertWorkflowTemplate(
root: TemplateToken,
errorPolicy: ErrorPolicy = ErrorPolicy.ReturnErrorsOnly
): WorkflowTemplate {
const result = {} as WorkflowTemplate
const result = {} as WorkflowTemplate;
if (
context.errors.getErrors().length > 0 &&
errorPolicy === ErrorPolicy.ReturnErrorsOnly
) {
result.errors = context.errors.getErrors().map((x) => ({
Message: x.message,
}))
return result
if (context.errors.getErrors().length > 0 && errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
result.errors = context.errors.getErrors().map(x => ({
Message: x.message
}));
return result;
}
try {
const rootMapping = root.assertMapping("root")
const rootMapping = root.assertMapping("root");
for (const item of rootMapping) {
const key = item.key.assertString("root key")
const key = item.key.assertString("root key");
switch (key.value) {
case "on":
result.events = handleTemplateTokenErrors(root, context, {}, () =>
convertOn(context, item.value)
)
break
result.events = handleTemplateTokenErrors(root, context, {}, () => convertOn(context, item.value));
break;
case "jobs":
result.jobs = handleTemplateTokenErrors(root, context, [], () =>
convertJobs(context, item.value)
)
break
result.jobs = handleTemplateTokenErrors(root, context, [], () => convertJobs(context, item.value));
break;
case "concurrency":
handleTemplateTokenErrors(root, context, {}, () =>
convertConcurrency(context, item.value)
)
result.concurrency = item.value
break
handleTemplateTokenErrors(root, context, {}, () => convertConcurrency(context, item.value));
result.concurrency = item.value;
break;
case "env":
result.env = item.value
break
result.env = item.value;
break;
}
}
} catch (err) {
if (err instanceof TemplateTokenError) {
context.error(err.token, err)
context.error(err.token, err);
} else {
// Report error for the root node
context.error(root, err)
context.error(root, err);
}
} finally {
if (context.errors.getErrors().length > 0) {
result.errors = context.errors.getErrors().map((x) => ({
Message: x.message,
}))
result.errors = context.errors.getErrors().map(x => ({
Message: x.message
}));
}
}
return result
return result;
}
@@ -1,42 +1,35 @@
import { TemplateContext } from "../../templates/template-context"
import { TemplateToken } from "../../templates/tokens/template-token"
import { isString } from "../../templates/tokens/type-guards"
import { ConcurrencySetting } from "../workflow-template"
import {TemplateContext} from "../../templates/template-context";
import {TemplateToken} from "../../templates/tokens/template-token";
import {isString} from "../../templates/tokens/type-guards";
import {ConcurrencySetting} from "../workflow-template";
export function convertConcurrency(
context: TemplateContext,
token: TemplateToken
): ConcurrencySetting {
const result: ConcurrencySetting = {}
export function convertConcurrency(context: TemplateContext, token: TemplateToken): ConcurrencySetting {
const result: ConcurrencySetting = {};
if (token.isExpression) {
return result
return result;
}
if (isString(token)) {
result.group = token
return result
result.group = token;
return result;
}
const concurrencyProperty = token.assertMapping("concurrency group")
const concurrencyProperty = token.assertMapping("concurrency group");
for (const property of concurrencyProperty) {
const propertyName = property.key.assertString("concurrency group key")
const propertyName = property.key.assertString("concurrency group key");
if (property.key.isExpression || property.value.isExpression) {
continue
continue;
}
switch (propertyName.value) {
case "group":
result.group = property.value.assertString("concurrency group")
break
result.group = property.value.assertString("concurrency group");
break;
case "cancel-in-progress":
result.cancelInProgress =
property.value.assertBoolean("cancel-in-progress").value
break
result.cancelInProgress = property.value.assertBoolean("cancel-in-progress").value;
break;
default:
context.error(
propertyName,
`Invalid property name: ${propertyName.value}`
)
context.error(propertyName, `Invalid property name: ${propertyName.value}`);
}
}
return result
return result;
}
@@ -1,124 +1,110 @@
import { TemplateContext } from "../../templates/template-context"
import {
MappingToken,
SequenceToken,
StringToken,
TemplateToken,
} from "../../templates/tokens"
import { isString } from "../../templates/tokens/type-guards"
import { Container, Credential } from "../workflow-template"
import {TemplateContext} from "../../templates/template-context";
import {MappingToken, SequenceToken, StringToken, TemplateToken} from "../../templates/tokens";
import {isString} from "../../templates/tokens/type-guards";
import {Container, Credential} from "../workflow-template";
export function convertToJobContainer(
context: TemplateContext,
container: TemplateToken
): Container | undefined {
let image: StringToken | undefined
let env: MappingToken | undefined
let ports: SequenceToken | undefined
let volumes: SequenceToken | undefined
let options: StringToken | undefined
export function convertToJobContainer(context: TemplateContext, container: TemplateToken): Container | undefined {
let image: StringToken | undefined;
let env: MappingToken | undefined;
let ports: SequenceToken | undefined;
let volumes: SequenceToken | undefined;
let options: StringToken | undefined;
// Skip validation for expressions for now to match
// behavior of the other parsers
for (const [_, token, __] of TemplateToken.traverse(container)) {
if (token.isExpression) {
return
return;
}
}
if (isString(container)) {
// Workflow uses shorthand syntax `container: image-name`
image = container.assertString("container item")
return { image: image }
image = container.assertString("container item");
return {image: image};
}
const mapping = container.assertMapping("container item")
const mapping = container.assertMapping("container item");
if (mapping)
for (const item of mapping) {
const key = item.key.assertString("container item key")
const value = item.value
const key = item.key.assertString("container item key");
const value = item.value;
switch (key.value) {
case "image":
image = value.assertString("container image")
break
image = value.assertString("container image");
break;
case "credentials":
convertToJobCredentials(context, value)
break
convertToJobCredentials(context, value);
break;
case "env":
env = value.assertMapping("container env")
env = value.assertMapping("container env");
for (const envItem of env) {
envItem.key.assertString("container env value")
envItem.key.assertString("container env value");
}
break
break;
case "ports":
ports = value.assertSequence("container ports")
ports = value.assertSequence("container ports");
for (const port of ports) {
port.assertString("container port")
port.assertString("container port");
}
break
break;
case "volumes":
volumes = value.assertSequence("container volumes")
volumes = value.assertSequence("container volumes");
for (const volume of volumes) {
volume.assertString("container volume")
volume.assertString("container volume");
}
break
break;
case "options":
options = value.assertString("container options")
break
options = value.assertString("container options");
break;
default:
context.error(key, `Unexpected container item key: ${key.value}`)
context.error(key, `Unexpected container item key: ${key.value}`);
}
}
if (!image) {
context.error(container, "Container image cannot be empty")
context.error(container, "Container image cannot be empty");
} else {
return { image, env, ports, volumes, options }
return {image, env, ports, volumes, options};
}
}
export function convertToJobServices(
context: TemplateContext,
services: TemplateToken
): Container[] | undefined {
const serviceList: Container[] = []
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
const serviceList: Container[] = [];
const mapping = services.assertMapping("services")
const mapping = services.assertMapping("services");
for (const service of mapping) {
service.key.assertString("service key")
const container = convertToJobContainer(context, service.value)
service.key.assertString("service key");
const container = convertToJobContainer(context, service.value);
if (container) {
serviceList.push(container)
serviceList.push(container);
}
}
return serviceList
return serviceList;
}
function convertToJobCredentials(
context: TemplateContext,
value: TemplateToken
): Credential | undefined {
const mapping = value.assertMapping("credentials")
function convertToJobCredentials(context: TemplateContext, value: TemplateToken): Credential | undefined {
const mapping = value.assertMapping("credentials");
let username: StringToken | undefined
let password: StringToken | undefined
let username: StringToken | undefined;
let password: StringToken | undefined;
for (const item of mapping) {
const key = item.key.assertString("credentials item")
const value = item.value
const key = item.key.assertString("credentials item");
const value = item.value;
switch (key.value) {
case "username":
username = value.assertString("credentials username")
break
username = value.assertString("credentials username");
break;
case "password":
password = value.assertString("credentials password")
break
password = value.assertString("credentials password");
break;
default:
context.error(key, `credentials key ${key.value}`)
context.error(key, `credentials key ${key.value}`);
}
}
return { username, password }
return {username, password};
}
@@ -1,14 +1,9 @@
import { TemplateContext } from "../../templates/template-context"
import { MappingToken } from "../../templates/tokens/mapping-token"
import { SequenceToken } from "../../templates/tokens/sequence-token"
import { TemplateToken } from "../../templates/tokens/template-token"
import {
isLiteral,
isMapping,
isSequence,
isString,
} from "../../templates/tokens/type-guards"
import { TokenType } from "../../templates/tokens/types"
import {TemplateContext} from "../../templates/template-context";
import {MappingToken} from "../../templates/tokens/mapping-token";
import {SequenceToken} from "../../templates/tokens/sequence-token";
import {TemplateToken} from "../../templates/tokens/template-token";
import {isLiteral, isMapping, isSequence, isString} from "../../templates/tokens/type-guards";
import {TokenType} from "../../templates/tokens/types";
import {
BranchFilterConfig,
EventsConfig,
@@ -16,57 +11,54 @@ import {
ScheduleConfig,
TagFilterConfig,
TypesFilterConfig,
WorkflowFilterConfig,
} from "../workflow-template"
import { isValidCron } from "./cron"
import { convertStringList } from "./string-list"
import { convertEventWorkflowDispatchInputs } from "./workflow-dispatch"
WorkflowFilterConfig
} from "../workflow-template";
import {isValidCron} from "./cron"
import {convertStringList} from "./string-list";
import {convertEventWorkflowDispatchInputs} from "./workflow-dispatch";
export function convertOn(
context: TemplateContext,
token: TemplateToken
): EventsConfig {
export function convertOn(context: TemplateContext, token: TemplateToken): EventsConfig {
if (isLiteral(token)) {
const event = token.assertString("on")
const event = token.assertString("on");
return {
[event.value]: {},
} as EventsConfig
[event.value]: {}
} as EventsConfig;
}
if (isSequence(token)) {
const result = {} as EventsConfig
const result = {} as EventsConfig;
for (const item of token) {
const event = item.assertString("on")
result[event.value] = {}
const event = item.assertString("on");
result[event.value] = {};
}
return result
return result;
}
if (isMapping(token)) {
const result = {} as EventsConfig
const result = {} as EventsConfig;
for (const item of token) {
const eventKey = item.key.assertString("event name")
const eventName = eventKey.value
const eventKey = item.key.assertString("event name");
const eventName = eventKey.value;
if (item.value.templateTokenType === TokenType.Null) {
result[eventName] = {}
continue
result[eventName] = {};
continue;
}
// Schedule is the only event that can be a sequence, handle that separately
if (eventName === "schedule") {
const scheduleToken = item.value.assertSequence(`event ${eventName}`)
result.schedule = convertSchedule(context, scheduleToken)
continue
const scheduleToken = item.value.assertSequence(`event ${eventName}`);
result.schedule = convertSchedule(context, scheduleToken);
continue;
}
// All other events are defined as mappings. During schema validation we already ensure that events
// receive only known keys, so here we can focus on the values and whether they are valid.
const eventToken = item.value.assertMapping(`event ${eventName}`)
const eventToken = item.value.assertMapping(`event ${eventName}`);
result[eventName] = {
...convertPatternFilter("branches", eventToken),
@@ -75,103 +67,95 @@ export function convertOn(
...convertFilter("types", eventToken),
...convertFilter("workflows", eventToken),
// TODO - share input parsing for now, but workflow_call also needs outputs and secrets
...convertEventWorkflowDispatchInputs(context, eventToken),
}
...convertEventWorkflowDispatchInputs(context, eventToken)
};
}
return result
return result;
}
context.error(token, "Invalid format for 'on'")
return {}
context.error(token, "Invalid format for 'on'");
return {};
}
function convertPatternFilter<
T extends BranchFilterConfig & TagFilterConfig & PathFilterConfig
>(name: "branches" | "tags" | "paths", token: MappingToken): T {
const result = {} as T
function convertPatternFilter<T extends BranchFilterConfig & TagFilterConfig & PathFilterConfig>(
name: "branches" | "tags" | "paths",
token: MappingToken
): T {
const result = {} as T;
for (const item of token) {
const key = item.key.assertString(`${name} filter key`)
const key = item.key.assertString(`${name} filter key`);
switch (key.value) {
case name:
if (isString(item.value)) {
result[name] = [item.value.value]
result[name] = [item.value.value];
} else {
result[name] = convertStringList(
name,
item.value.assertSequence(`${name} list`)
)
result[name] = convertStringList(name, item.value.assertSequence(`${name} list`));
}
break
break;
case `${name}-ignore`:
if (isString(item.value)) {
result[`${name}-ignore`] = [item.value.value]
result[`${name}-ignore`] = [item.value.value];
} else {
result[`${name}-ignore`] = convertStringList(
`${name}-ignore`,
item.value.assertSequence(`${name}-ignore list`)
)
);
}
break
break;
}
}
return result
return result;
}
function convertFilter<T extends TypesFilterConfig & WorkflowFilterConfig>(
name: "types" | "workflows",
token: MappingToken
): T {
const result = {} as T
const result = {} as T;
for (const item of token) {
const key = item.key.assertString(`${name} filter key`)
const key = item.key.assertString(`${name} filter key`);
switch (key.value) {
case name:
if (isString(item.value)) {
result[name] = [item.value.value]
result[name] = [item.value.value];
} else {
result[name] = convertStringList(
name,
item.value.assertSequence(`${name} list`)
)
result[name] = convertStringList(name, item.value.assertSequence(`${name} list`));
}
break
break;
}
}
return result
return result;
}
function convertSchedule(
context: TemplateContext,
token: SequenceToken
): ScheduleConfig[] | undefined {
const result = [] as ScheduleConfig[]
function convertSchedule(context: TemplateContext, token: SequenceToken): ScheduleConfig[] | undefined {
const result = [] as ScheduleConfig[];
for (const item of token) {
const mappingToken = item.assertMapping(`event schedule`)
const mappingToken = item.assertMapping(`event schedule`);
if (mappingToken.count == 1) {
const schedule = mappingToken.get(0)
const scheduleKey = schedule.key.assertString(`schedule key`)
const schedule = mappingToken.get(0);
const scheduleKey = schedule.key.assertString(`schedule key`);
if (scheduleKey.value == "cron") {
const cron = schedule.value.assertString(`schedule cron`)
const cron = schedule.value.assertString(`schedule cron`);
// Validate the cron string
if (!isValidCron(cron.value)) {
context.error(cron, "Invalid cron string")
}
result.push({ cron: cron.value })
result.push({cron: cron.value});
} else {
context.error(scheduleKey, `Invalid schedule key`)
context.error(scheduleKey, `Invalid schedule key`);
}
} else {
context.error(mappingToken, "Invalid format for 'schedule'")
context.error(mappingToken, "Invalid format for 'schedule'");
}
}
return result
return result;
}
@@ -1,8 +1,5 @@
import { TemplateContext } from "../../templates/template-context"
import {
TemplateToken,
TemplateTokenError,
} from "../../templates/tokens/template-token"
import {TemplateContext} from "../../templates/template-context";
import {TemplateToken, TemplateTokenError} from "../../templates/tokens/template-token";
export function handleTemplateTokenErrors<TResult>(
root: TemplateToken,
@@ -10,18 +7,18 @@ export function handleTemplateTokenErrors<TResult>(
defaultValue: TResult,
f: () => TResult
): TResult {
let r: TResult = defaultValue
let r: TResult = defaultValue;
try {
r = f()
r = f();
} catch (err) {
if (err instanceof TemplateTokenError) {
context.error(err.token, err)
context.error(err.token, err);
} else {
// Report error for the root node
context.error(root, err)
context.error(root, err);
}
}
return r
return r;
}
@@ -1,147 +1,146 @@
import { IdBuilder } from "./id-builder"
import {IdBuilder} from "./id-builder";
function build(...segments: string[]): string {
const builder = new IdBuilder()
const builder = new IdBuilder();
for (const segment of segments) {
builder.appendSegment(segment)
builder.appendSegment(segment);
}
return builder.build()
return builder.build();
}
describe("ID Builder", () => {
it("builds IDs", () => {
expect(build("one")).toEqual("one")
expect(build("one")).toEqual("one");
expect(build("one", "two")).toEqual("one_two")
expect(build("one", "two")).toEqual("one_two");
expect(build("one", "two", "three")).toEqual("one_two_three")
})
expect(build("one", "two", "three")).toEqual("one_two_three");
});
it("empty builder", () => {
const builder = new IdBuilder()
expect(builder.build()).toEqual("job")
})
const builder = new IdBuilder();
expect(builder.build()).toEqual("job");
});
it("ignores empty segments", () => {
expect(build("", "one")).toEqual("one")
expect(build("", "one")).toEqual("one");
expect(build("one", "", "two", "")).toEqual("one_two")
})
expect(build("one", "", "two", "")).toEqual("one_two");
});
it("handles illegal characters", () => {
const builder = new IdBuilder()
builder.appendSegment("hello world!")
expect(builder.build()).toEqual("hello_world_")
})
const builder = new IdBuilder();
builder.appendSegment("hello world!");
expect(builder.build()).toEqual("hello_world_");
});
it("handles illegal leading characters", () => {
expect(build("!hello")).toEqual("_hello")
expect(build("!hello")).toEqual("_hello");
expect(build("!hello", "!world")).toEqual("_hello__world")
expect(build("!hello", "!world")).toEqual("_hello__world");
expect(build("!@world", "!@world")).toEqual("__world___world")
expect(build("!@world", "!@world")).toEqual("__world___world");
expect(build("123")).toEqual("_123")
expect(build("123")).toEqual("_123");
expect(build("123", "456")).toEqual("_123_456")
expect(build("123", "456")).toEqual("_123_456");
expect(build("-abc")).toEqual("_-abc")
expect(build("-abc")).toEqual("_-abc");
expect(build("-abc", "-def")).toEqual("_-abc_-def")
})
expect(build("-abc", "-def")).toEqual("_-abc_-def");
});
it("allows legal characters", () => {
expect(build("abyzABYZ0189_-")).toEqual("abyzABYZ0189_-")
})
expect(build("abyzABYZ0189_-")).toEqual("abyzABYZ0189_-");
});
it("allows legal leading characters", () => {
expect(build("abc")).toEqual("abc")
expect(build("abc")).toEqual("abc");
expect(build("bcd")).toEqual("bcd")
expect(build("bcd")).toEqual("bcd");
expect(build("zyx")).toEqual("zyx")
expect(build("zyx")).toEqual("zyx");
expect(build("yxw")).toEqual("yxw")
expect(build("yxw")).toEqual("yxw");
expect(build("ABCD")).toEqual("ABCD")
expect(build("ABCD")).toEqual("ABCD");
expect(build("BCDE")).toEqual("BCDE")
expect(build("BCDE")).toEqual("BCDE");
expect(build("ZYXW")).toEqual("ZYXW")
expect(build("ZYXW")).toEqual("ZYXW");
expect(build("YXWV")).toEqual("YXWV")
expect(build("YXWV")).toEqual("YXWV");
expect(build("_abc")).toEqual("_abc")
})
expect(build("_abc")).toEqual("_abc");
});
it("errors for max collisions", () => {
const builder = new IdBuilder()
builder.appendSegment("abc")
builder.appendSegment("def")
expect(builder.build()).toEqual("abc_def")
const builder = new IdBuilder();
builder.appendSegment("abc");
builder.appendSegment("def");
expect(builder.build()).toEqual("abc_def");
for (let i = 2; i < 1000; i++) {
builder.appendSegment("abc")
builder.appendSegment("def")
expect(builder.build()).toEqual(`abc_def_${i}`)
builder.appendSegment("abc");
builder.appendSegment("def");
expect(builder.build()).toEqual(`abc_def_${i}`);
}
builder.appendSegment("abc")
builder.appendSegment("def")
expect(() => builder.build()).toThrowError("Unable to create a unique name")
})
builder.appendSegment("abc");
builder.appendSegment("def");
expect(() => builder.build()).toThrowError("Unable to create a unique name");
});
it("takes suffix into account for max length", () => {
const builder = new IdBuilder()
const builder = new IdBuilder();
const name =
"_234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"
builder.appendSegment(name)
expect(builder.build()).toEqual(name)
const name = "_234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890";
builder.appendSegment(name);
expect(builder.build()).toEqual(name);
builder.appendSegment(name)
builder.appendSegment(name);
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_2"
)
);
builder.appendSegment(name)
builder.appendSegment(name);
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_3"
)
);
builder.appendSegment(name)
builder.appendSegment(name);
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_4"
)
);
builder.appendSegment(name)
builder.appendSegment(name);
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_5"
)
);
builder.appendSegment(name)
builder.appendSegment(name);
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_6"
)
);
builder.appendSegment(name)
builder.appendSegment(name);
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_7"
)
);
builder.appendSegment(name)
builder.appendSegment(name);
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_8"
)
);
builder.appendSegment(name)
builder.appendSegment(name);
expect(builder.build()).toEqual(
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_9"
)
);
builder.appendSegment(name)
builder.appendSegment(name);
expect(builder.build()).toEqual(
"_234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567_10"
)
})
})
);
});
});
@@ -1,69 +1,65 @@
const SEPARATOR = "_"
const MAX_ATTEMPTS = 1000
const MAX_LENGTH = 100
const SEPARATOR = "_";
const MAX_ATTEMPTS = 1000;
const MAX_LENGTH = 100;
export class IdBuilder {
private name: string[] = []
private readonly distinctNames: Set<string> = new Set()
private name: string[] = [];
private readonly distinctNames: Set<string> = new Set();
public appendSegment(value: string) {
if (value.length === 0) {
return
return;
}
if (this.name.length == 0) {
const first = value[0]
const first = value[0];
if (this.isAlpha(first) || first == "_") {
// Legal first char
} else if (this.isNumeric(first) || first == "-") {
// Illegal first char, but legal char.
// Prepend "_".
this.name.push("_")
this.name.push("_");
} else {
// Illegal char
}
} else {
// Separator
this.name.push(SEPARATOR)
this.name.push(SEPARATOR);
}
for (const c of value) {
{
if (this.isAlphaNumeric(c) || c == "_" || c == "-") {
// Legal
this.name.push(c)
this.name.push(c);
} else {
// Illegal
this.name.push(SEPARATOR)
this.name.push(SEPARATOR);
}
}
}
}
public build(): string {
const original = this.name.length > 0 ? this.name.join("") : "job"
let suffix = ""
const original = this.name.length > 0 ? this.name.join("") : "job";
let suffix = "";
for (let attempt = 1; attempt < MAX_ATTEMPTS; attempt++) {
if (attempt === 1) {
suffix = ""
suffix = "";
} else {
suffix = "_" + attempt
suffix = "_" + attempt;
}
const candidate =
original.substring(
0,
Math.min(original.length, MAX_LENGTH - suffix.length)
) + suffix
const candidate = original.substring(0, Math.min(original.length, MAX_LENGTH - suffix.length)) + suffix;
if (!this.distinctNames.has(candidate)) {
this.distinctNames.add(candidate)
this.name = []
return candidate
this.distinctNames.add(candidate);
this.name = [];
return candidate;
}
}
throw new Error("Unable to create a unique name")
throw new Error("Unable to create a unique name");
}
/**
@@ -73,19 +69,19 @@ export class IdBuilder {
*/
public tryAddKnownId(value: string): string | undefined {
if (!value || !this.isValid(value) || value.length >= MAX_LENGTH) {
return `The identifier '${value}' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than ${MAX_LENGTH} characters.`
return `The identifier '${value}' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than ${MAX_LENGTH} characters.`;
}
if (value.startsWith(SEPARATOR + SEPARATOR)) {
return `The identifier '${value}' is invalid. IDs starting with '__' are reserved.`
return `The identifier '${value}' is invalid. IDs starting with '__' are reserved.`;
}
if (this.distinctNames.has(value)) {
return `The identifier '${value}' may not be used more than once within the same scope.`
return `The identifier '${value}' may not be used more than once within the same scope.`;
}
this.distinctNames.add(value)
return
this.distinctNames.add(value);
return;
}
/**
@@ -95,32 +91,32 @@ export class IdBuilder {
* @returns Whether the name is valid
*/
private isValid(name: string): boolean {
let first = true
let first = true;
for (const c of name) {
if (first) {
first = false
first = false;
if (!this.isAlpha(c) && c != "_") {
return false
return false;
}
continue
continue;
}
if (!this.isAlphaNumeric(c) && c != "_" && c != "-") {
return false
return false;
}
}
return true
return true;
}
private isAlphaNumeric(c: string): boolean {
return this.isAlpha(c) || this.isNumeric(c)
return this.isAlpha(c) || this.isNumeric(c);
}
private isNumeric(c: string): boolean {
return c >= "0" && c <= "9"
return c >= "0" && c <= "9";
}
private isAlpha(c: string): boolean {
return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z")
return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
}
}
@@ -1,41 +1,41 @@
import { TemplateContext } from "../../../templates/template-context"
import { TemplateToken } from "../../../templates/tokens/template-token"
import { isScalar } from "../../../templates/tokens/type-guards"
import { ActionsEnvironmentReference } from "../../workflow-template"
import {TemplateContext} from "../../../templates/template-context";
import {TemplateToken} from "../../../templates/tokens/template-token";
import {isScalar} from "../../../templates/tokens/type-guards";
import {ActionsEnvironmentReference} from "../../workflow-template";
export function convertToActionsEnvironmentRef(
context: TemplateContext,
token: TemplateToken
): ActionsEnvironmentReference {
const result: ActionsEnvironmentReference = {}
const result: ActionsEnvironmentReference = {};
if (token.isExpression) {
return result
return result;
}
if (isScalar(token)) {
result.name = token
return result
result.name = token;
return result;
}
const environmentMapping = token.assertMapping("job environment")
const environmentMapping = token.assertMapping("job environment");
for (const property of environmentMapping) {
const propertyName = property.key.assertString("job environment key")
const propertyName = property.key.assertString("job environment key");
if (property.key.isExpression || property.value.isExpression) {
continue
continue;
}
switch (propertyName.value) {
case "name":
result.name = property.value.assertScalar("job environment name key")
break
result.name = property.value.assertScalar("job environment name key");
break;
case "url":
result.url = property.value
break
result.url = property.value;
break;
}
}
return result
return result;
}
@@ -1,72 +1,53 @@
import { TemplateContext } from "../../templates/template-context"
import {
BasicExpressionToken,
MappingToken,
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 { handleTemplateTokenErrors } from "./handle-errors"
import { IdBuilder } from "./id-builder"
import { convertToActionsEnvironmentRef } from "./job/environment"
import { convertSteps } from "./steps"
import {TemplateContext} from "../../templates/template-context";
import {BasicExpressionToken, MappingToken, 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 {handleTemplateTokenErrors} from "./handle-errors";
import {IdBuilder} from "./id-builder";
import {convertToActionsEnvironmentRef} from "./job/environment";
import {convertSteps} from "./steps";
type nodeInfo = {
name: string
needs: StringToken[]
}
name: string;
needs: StringToken[];
};
export function convertJobs(
context: TemplateContext,
token: TemplateToken
): Job[] {
export function convertJobs(context: TemplateContext, token: TemplateToken): Job[] {
if (isMapping(token)) {
const result: Job[] = []
const jobsWithSatisfiedNeeds: nodeInfo[] = []
const alljobsWithUnsatisfiedNeeds: nodeInfo[] = []
const result: Job[] = [];
const jobsWithSatisfiedNeeds: nodeInfo[] = [];
const alljobsWithUnsatisfiedNeeds: nodeInfo[] = [];
for (const item of token) {
const jobKey = item.key.assertString("job name")
const jobDef = item.value.assertMapping(`job ${jobKey.value}`)
const jobKey = item.key.assertString("job name");
const jobDef = item.value.assertMapping(`job ${jobKey.value}`);
const job = handleTemplateTokenErrors(token, context, undefined, () =>
convertJob(context, jobKey, jobDef)
)
const job = handleTemplateTokenErrors(token, context, undefined, () => convertJob(context, jobKey, jobDef));
if (job) {
result.push(job)
result.push(job);
const node = {
name: job.id.value,
needs: Object.assign([], job.needs),
}
needs: Object.assign([], job.needs)
};
if (node.needs.length > 0) {
alljobsWithUnsatisfiedNeeds.push(node)
alljobsWithUnsatisfiedNeeds.push(node);
} else {
jobsWithSatisfiedNeeds.push(node)
jobsWithSatisfiedNeeds.push(node);
}
}
}
//validate job needs
validateNeeds(
token,
context,
result,
jobsWithSatisfiedNeeds,
alljobsWithUnsatisfiedNeeds
)
validateNeeds(token, context, result, jobsWithSatisfiedNeeds, alljobsWithUnsatisfiedNeeds);
return result
return result;
}
context.error(token, "Invalid format for jobs")
return []
context.error(token, "Invalid format for jobs");
return [];
}
function validateNeeds(
@@ -77,28 +58,25 @@ function validateNeeds(
alljobsWithUnsatisfiedNeeds: nodeInfo[]
) {
if (jobsWithSatisfiedNeeds.length == 0) {
context.error(
token,
"The workflow must contain at least one job with no dependencies."
)
return
context.error(token, "The workflow must contain at least one job with no dependencies.");
return;
}
// Figure out which nodes would start after current completes
while (jobsWithSatisfiedNeeds.length > 0) {
const currentJob = jobsWithSatisfiedNeeds.shift()
const currentJob = jobsWithSatisfiedNeeds.shift();
if (currentJob == undefined) {
break
break;
}
for (let i = alljobsWithUnsatisfiedNeeds.length - 1; i >= 0; i--) {
const unsatisfiedJob = alljobsWithUnsatisfiedNeeds[i]
const unsatisfiedJob = alljobsWithUnsatisfiedNeeds[i];
for (let j = unsatisfiedJob.needs.length - 1; j >= 0; j--) {
const need = unsatisfiedJob.needs[j]
const need = unsatisfiedJob.needs[j];
if (need.value == currentJob.name) {
unsatisfiedJob.needs.splice(j, 1)
unsatisfiedJob.needs.splice(j, 1);
if (unsatisfiedJob.needs.length == 0) {
jobsWithSatisfiedNeeds.push(unsatisfiedJob)
alljobsWithUnsatisfiedNeeds.splice(i, 1)
jobsWithSatisfiedNeeds.push(unsatisfiedJob);
alljobsWithUnsatisfiedNeeds.splice(i, 1);
}
}
}
@@ -107,46 +85,33 @@ function validateNeeds(
// Check whether some jobs will never execute
if (alljobsWithUnsatisfiedNeeds.length > 0) {
const jobNames = result.map((x) => x.id.value)
const jobNames = result.map(x => x.id.value);
for (const unsatisfiedJob of alljobsWithUnsatisfiedNeeds) {
for (const need of unsatisfiedJob.needs) {
if (jobNames.includes(need.value)) {
context.error(
need,
`Job '${unsatisfiedJob.name}' depends on job '${need.value}' which creates a cycle in the dependency graph.`
)
);
} else {
context.error(
need,
`Job '${unsatisfiedJob.name}' depends on unknown job '${need.value}'.`
)
context.error(need, `Job '${unsatisfiedJob.name}' depends on unknown job '${need.value}'.`);
}
}
}
}
}
function convertJob(
context: TemplateContext,
jobKey: StringToken,
token: MappingToken
): Job {
const error = new IdBuilder().tryAddKnownId(jobKey.value)
function convertJob(context: TemplateContext, jobKey: StringToken, token: MappingToken): Job {
const error = new IdBuilder().tryAddKnownId(jobKey.value);
if (error) {
context.error(jobKey, error)
context.error(jobKey, error);
}
const result: Job = {
type: "job",
id: jobKey,
name: undefined,
needs: undefined,
if: new BasicExpressionToken(
undefined,
undefined,
"success()",
undefined,
undefined
),
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined),
env: undefined,
concurrency: undefined,
environment: undefined,
@@ -155,191 +120,180 @@ function convertJob(
container: undefined,
services: undefined,
outputs: undefined,
steps: [],
}
steps: []
};
for (const item of token) {
const propertyName = item.key.assertString("job property name")
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
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
convertToJobContainer(context, item.value);
result.container = item.value;
break;
case "env":
result.env = item.value.assertMapping("job env")
break
result.env = item.value.assertMapping("job env");
break;
case "environment":
handleTemplateTokenErrors(item.value, context, undefined, () =>
convertToActionsEnvironmentRef(context, item.value)
)
result.environment = item.value
break
);
result.environment = item.value;
break;
case "name":
result.name = item.value.assertScalar("job name")
break
result.name = item.value.assertScalar("job name");
break;
case "needs":
result.needs = []
result.needs = [];
if (isString(item.value)) {
const jobNeeds = item.value.assertString("job needs id")
result.needs.push(jobNeeds)
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)
const jobNeeds = seqItem.assertString("job needs id");
result.needs.push(jobNeeds);
}
}
break
break;
case "outputs":
result.outputs = item.value.assertMapping("job outputs")
break
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
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
convertToJobServices(context, item.value);
result.services = item.value;
break;
case "steps":
result.steps = convertSteps(context, item.value)
break
result.steps = convertSteps(context, item.value);
break;
case "strategy":
result.strategy = item.value
break
result.strategy = item.value;
break;
}
}
if (!result.name) {
result.name = result.id
result.name = result.id;
}
return result
return result;
}
type RunsOn = {
labels: Set<string>
group: string
}
labels: Set<string>;
group: string;
};
function convertRunsOn(context: TemplateContext, token: TemplateToken): RunsOn {
const labels = convertRunsOnLabels(token)
const labels = convertRunsOnLabels(token);
if (!isMapping(token)) {
return {
labels,
group: "",
}
group: ""
};
}
let group = ""
let group = "";
for (const item of token) {
const key = item.key.assertString("job runs-on property name")
const key = item.key.assertString("job runs-on property name");
switch (key.value) {
case "group": {
if (item.value.isExpression) {
continue
continue;
}
const groupName = item.value.assertString(
"job runs-on group name"
).value
const names = groupName.split("/")
const groupName = item.value.assertString("job runs-on group name").value;
const names = groupName.split("/");
switch (names.length) {
case 1: {
group = groupName
break
group = groupName;
break;
}
case 2: {
if (
!["org", "organization", "ent", "enterprise"].includes(names[0])
) {
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
);
continue;
}
if (!names[1]) {
context.error(
item.value,
`Invalid runs-on group name '${groupName}'.`
)
continue
context.error(item.value, `Invalid runs-on group name '${groupName}'.`);
continue;
}
group = groupName
break
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;
}
}
break
break;
}
case "labels": {
const mapLabels = convertRunsOnLabels(item.value)
const mapLabels = convertRunsOnLabels(item.value);
for (const label of mapLabels) {
labels.add(label)
labels.add(label);
}
break
break;
}
}
}
return {
labels,
group,
}
group
};
}
function convertRunsOnLabels(token: TemplateToken): Set<string> {
const labels = new Set<string>()
const labels = new Set<string>();
if (token.isExpression) {
return labels
return labels;
}
if (isString(token)) {
labels.add(token.value)
return labels
labels.add(token.value);
return labels;
}
if (isSequence(token)) {
for (const item of token) {
if (item.isExpression) {
continue
continue;
}
const label = item.assertString("job runs-on label sequence item")
labels.add(label.value)
const label = item.assertString("job runs-on label sequence item");
labels.add(label.value);
}
}
return labels
return labels;
}
@@ -1,107 +1,84 @@
import { TemplateContext } from "../../templates/template-context"
import {
BasicExpressionToken,
MappingToken,
ScalarToken,
StringToken,
TemplateToken,
} from "../../templates/tokens"
import { isSequence } from "../../templates/tokens/type-guards"
import { isActionStep } from "../type-guards"
import { ActionStep, Step } from "../workflow-template"
import { handleTemplateTokenErrors } from "./handle-errors"
import { IdBuilder } from "./id-builder"
import {TemplateContext} from "../../templates/template-context";
import {BasicExpressionToken, MappingToken, ScalarToken, StringToken, TemplateToken} from "../../templates/tokens";
import {isSequence} from "../../templates/tokens/type-guards";
import {isActionStep} from "../type-guards";
import {ActionStep, Step} from "../workflow-template";
import {handleTemplateTokenErrors} from "./handle-errors";
import {IdBuilder} from "./id-builder";
export function convertSteps(
context: TemplateContext,
steps: TemplateToken
): Step[] {
export function convertSteps(context: TemplateContext, steps: TemplateToken): Step[] {
if (!isSequence(steps)) {
context.error(steps, "Invalid format for steps")
return []
context.error(steps, "Invalid format for steps");
return [];
}
const idBuilder = new IdBuilder()
const idBuilder = new IdBuilder();
const result: Step[] = []
const result: Step[] = [];
for (const item of steps) {
const step = handleTemplateTokenErrors(steps, context, undefined, () =>
convertStep(context, idBuilder, item)
)
const step = handleTemplateTokenErrors(steps, context, undefined, () => convertStep(context, idBuilder, item));
if (step) {
result.push(step)
result.push(step);
}
}
for (const step of result) {
if (step.id) {
continue
continue;
}
let id = ""
let id = "";
if (isActionStep(step)) {
id = createActionStepId(step)
id = createActionStepId(step);
}
if (!id) {
id = "run"
id = "run";
}
idBuilder.appendSegment(`__${id}`)
step.id = idBuilder.build()
idBuilder.appendSegment(`__${id}`);
step.id = idBuilder.build();
}
return result
return result;
}
function convertStep(
context: TemplateContext,
idBuilder: IdBuilder,
step: TemplateToken
): Step | undefined {
const mapping = step.assertMapping("steps item")
function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: TemplateToken): Step | undefined {
const mapping = step.assertMapping("steps item");
let run: ScalarToken | undefined
let id: StringToken | undefined
let name: ScalarToken | undefined
let uses: StringToken | undefined
let continueOnError: boolean | undefined
let env: MappingToken | undefined
const ifCondition = new BasicExpressionToken(
undefined,
undefined,
"success()",
undefined,
undefined
)
let run: ScalarToken | undefined;
let id: StringToken | undefined;
let name: ScalarToken | undefined;
let uses: StringToken | undefined;
let continueOnError: boolean | undefined;
let env: MappingToken | undefined;
const ifCondition = new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined);
for (const item of mapping) {
const key = item.key.assertString("steps item key")
const key = item.key.assertString("steps item key");
switch (key.value) {
case "id":
id = item.value.assertString("steps item id")
id = item.value.assertString("steps item id");
if (id) {
const error = idBuilder.tryAddKnownId(id.value)
const error = idBuilder.tryAddKnownId(id.value);
if (error) {
context.error(id, error)
context.error(id, error);
}
}
break
break;
case "name":
name = item.value.assertScalar("steps item name")
break
name = item.value.assertScalar("steps item name");
break;
case "run":
run = item.value.assertScalar("steps item run")
break
run = item.value.assertScalar("steps item run");
break;
case "uses":
uses = item.value.assertString("steps item uses")
break
uses = item.value.assertString("steps item uses");
break;
case "env":
env = item.value.assertMapping("step env")
break
env = item.value.assertMapping("step env");
break;
case "continue-on-error":
continueOnError = item.value.assertBoolean(
"steps item continue-on-error"
).value
continueOnError = item.value.assertBoolean("steps item continue-on-error").value;
}
}
@@ -112,8 +89,8 @@ function convertStep(
if: ifCondition,
"continue-on-error": continueOnError,
env,
run,
}
run
};
}
if (uses) {
@@ -123,38 +100,33 @@ function convertStep(
if: ifCondition,
"continue-on-error": continueOnError,
env,
uses,
}
uses
};
}
context.error(step, "Expected uses or run to be defined")
context.error(step, "Expected uses or run to be defined");
}
function createActionStepId(step: ActionStep): string {
const uses = step.uses.value
const uses = step.uses.value;
if (uses.startsWith("docker://")) {
return uses.substring("docker://".length)
return uses.substring("docker://".length);
}
if (uses.startsWith("./") || uses.startsWith(".\\")) {
return "self"
return "self";
}
const segments = uses.split("@")
const segments = uses.split("@");
if (segments.length != 2) {
return ""
return "";
}
const pathSegments = segments[0].split(/[\\/]/).filter((s) => s.length > 0)
const gitRef = segments[1]
const pathSegments = segments[0].split(/[\\/]/).filter(s => s.length > 0);
const gitRef = segments[1];
if (
pathSegments.length >= 2 &&
pathSegments[0] &&
pathSegments[1] &&
gitRef
) {
return `${pathSegments[0]}/${pathSegments[1]}`
if (pathSegments.length >= 2 && pathSegments[0] && pathSegments[1] && gitRef) {
return `${pathSegments[0]}/${pathSegments[1]}`;
}
return ""
return "";
}
@@ -1,14 +1,11 @@
import { SequenceToken } from "../../templates/tokens/sequence-token"
import {SequenceToken} from "../../templates/tokens/sequence-token";
export function convertStringList(
name: string,
token: SequenceToken
): string[] {
const result = [] as string[]
export function convertStringList(name: string, token: SequenceToken): string[] {
const result = [] as string[];
for (const item of token) {
result.push(item.assertString(`${name} item`).value)
result.push(item.assertString(`${name} item`).value);
}
return result
return result;
}
@@ -1,99 +1,79 @@
import { TemplateContext } from "../../templates/template-context"
import { MappingToken } from "../../templates/tokens/mapping-token"
import { ScalarToken } from "../../templates/tokens/scalar-token"
import {
InputConfig,
InputType,
WorkflowDispatchConfig,
} from "../workflow-template"
import { convertStringList } from "./string-list"
import {TemplateContext} from "../../templates/template-context";
import {MappingToken} from "../../templates/tokens/mapping-token";
import {ScalarToken} from "../../templates/tokens/scalar-token";
import {InputConfig, InputType, WorkflowDispatchConfig} from "../workflow-template";
import {convertStringList} from "./string-list";
export function convertEventWorkflowDispatchInputs(
context: TemplateContext,
token: MappingToken
): WorkflowDispatchConfig {
const result: WorkflowDispatchConfig = {}
const result: WorkflowDispatchConfig = {};
for (const item of token) {
const key = item.key.assertString("workflow dispatch input key")
const key = item.key.assertString("workflow dispatch input key");
switch (key.value) {
case "inputs":
result.inputs = convertWorkflowDispatchInputs(
context,
item.value.assertMapping("workflow dispatch inputs")
)
break
result.inputs = convertWorkflowDispatchInputs(context, item.value.assertMapping("workflow dispatch inputs"));
break;
}
}
return result
return result;
}
export function convertWorkflowDispatchInputs(
context: TemplateContext,
token: MappingToken
): {
[inputName: string]: InputConfig
[inputName: string]: InputConfig;
} {
const result: { [inputName: string]: InputConfig } = {}
const result: {[inputName: string]: InputConfig} = {};
for (const item of token) {
const inputName = item.key.assertString("input name")
const inputMapping = item.value.assertMapping("input configuration")
const inputName = item.key.assertString("input name");
const inputMapping = item.value.assertMapping("input configuration");
result[inputName.value] = convertWorkflowDispatchInput(
context,
inputMapping
)
result[inputName.value] = convertWorkflowDispatchInput(context, inputMapping);
}
return result
return result;
}
export function convertWorkflowDispatchInput(
context: TemplateContext,
token: MappingToken
): InputConfig {
export function convertWorkflowDispatchInput(context: TemplateContext, token: MappingToken): InputConfig {
const result: InputConfig = {
type: InputType.string, // Default to string
}
type: InputType.string // Default to string
};
let defaultValue: undefined | ScalarToken
let defaultValue: undefined | ScalarToken;
for (const item of token) {
const key = item.key.assertString("workflow dispatch input key")
const key = item.key.assertString("workflow dispatch input key");
switch (key.value) {
case "description":
result.description = item.value.assertString("input description").value
break
result.description = item.value.assertString("input description").value;
break;
case "required":
result.required = item.value.assertBoolean("input required").value
break
result.required = item.value.assertBoolean("input required").value;
break;
case "default":
defaultValue = item.value.assertScalar("input default")
break
defaultValue = item.value.assertScalar("input default");
break;
case "type":
result.type =
InputType[
item.value.assertString("input type")
.value as keyof typeof InputType
]
break
result.type = InputType[item.value.assertString("input type").value as keyof typeof InputType];
break;
case "options":
result.options = convertStringList(
"input options",
item.value.assertSequence("input options")
)
break
result.options = convertStringList("input options", item.value.assertSequence("input options"));
break;
default:
context.error(item.key, `Invalid key '${key.value}'`)
context.error(item.key, `Invalid key '${key.value}'`);
}
}
@@ -102,34 +82,31 @@ export function convertWorkflowDispatchInput(
try {
switch (result.type) {
case InputType.boolean:
result.default = defaultValue.assertBoolean("input default").value
result.default = defaultValue.assertBoolean("input default").value;
break
break;
case InputType.string:
case InputType.choice:
case InputType.environment:
result.default = defaultValue.assertString("input default").value
break
result.default = defaultValue.assertString("input default").value;
break;
}
} catch (e) {
context.error(defaultValue, e)
context.error(defaultValue, e);
}
}
// Validate `options` for `choice` type
if (result.type === InputType.choice) {
if (result.options === undefined || result.options.length === 0) {
context.error(token, "Missing 'options' for choice input")
context.error(token, "Missing 'options' for choice input");
}
} else {
if (result.options !== undefined) {
context.error(
token,
"Input type is not 'choice', but 'options' is defined"
)
context.error(token, "Input type is not 'choice', but 'options' is defined");
}
}
return result
return result;
}
@@ -1,9 +1,9 @@
import { ActionStep, RunStep, Step } from "./workflow-template"
import {ActionStep, RunStep, Step} from "./workflow-template";
export function isRunStep(step: Step): step is RunStep {
return (step as RunStep).run !== undefined
return (step as RunStep).run !== undefined;
}
export function isActionStep(step: Step): step is ActionStep {
return (step as ActionStep).uses !== undefined
return (step as ActionStep).uses !== undefined;
}
@@ -4,166 +4,164 @@ import {
ScalarToken,
SequenceToken,
StringToken,
TemplateToken,
} from "../templates/tokens"
TemplateToken
} from "../templates/tokens";
export type WorkflowTemplate = {
events: EventsConfig
jobs: Job[]
concurrency: TemplateToken
env: TemplateToken
events: EventsConfig;
jobs: Job[];
concurrency: TemplateToken;
env: TemplateToken;
errors?: {
Message: string
}[]
}
Message: string;
}[];
};
export type ConcurrencySetting = {
group?: StringToken
cancelInProgress?: boolean
}
group?: StringToken;
cancelInProgress?: boolean;
};
export type ActionsEnvironmentReference = {
name?: TemplateToken
url?: TemplateToken
}
name?: TemplateToken;
url?: TemplateToken;
};
export type Job = {
type: string
id: StringToken
name?: ScalarToken
needs?: StringToken[]
if: BasicExpressionToken
env?: MappingToken
concurrency?: TemplateToken
environment?: TemplateToken
strategy?: TemplateToken
"runs-on"?: TemplateToken
container?: TemplateToken
services?: TemplateToken
outputs?: MappingToken
steps: Step[]
}
type: string;
id: StringToken;
name?: ScalarToken;
needs?: StringToken[];
if: BasicExpressionToken;
env?: MappingToken;
concurrency?: TemplateToken;
environment?: TemplateToken;
strategy?: TemplateToken;
"runs-on"?: TemplateToken;
container?: TemplateToken;
services?: TemplateToken;
outputs?: MappingToken;
steps: Step[];
};
export type Container = {
image: StringToken
credentials?: Credential
env?: MappingToken
ports?: SequenceToken
volumes?: SequenceToken
options?: StringToken
}
image: StringToken;
credentials?: Credential;
env?: MappingToken;
ports?: SequenceToken;
volumes?: SequenceToken;
options?: StringToken;
};
export type Credential = {
username: StringToken | undefined
password: StringToken | undefined
}
username: StringToken | undefined;
password: StringToken | undefined;
};
export type Step = ActionStep | RunStep
export type Step = ActionStep | RunStep;
type BaseStep = {
id: string
name?: ScalarToken
if: BasicExpressionToken
"continue-on-error"?: boolean
env?: MappingToken
}
id: string;
name?: ScalarToken;
if: BasicExpressionToken;
"continue-on-error"?: boolean;
env?: MappingToken;
};
export type RunStep = BaseStep & {
run: ScalarToken
}
run: ScalarToken;
};
export type ActionStep = BaseStep & {
uses: StringToken
}
uses: StringToken;
};
export type EventsConfig = {
schedule?: ScheduleConfig[]
workflow_dispatch?: WorkflowDispatchConfig
workflow_call?: WorkflowCallConfig
schedule?: ScheduleConfig[];
workflow_dispatch?: WorkflowDispatchConfig;
workflow_call?: WorkflowCallConfig;
// Events that support filters
pull_request?: BranchFilterConfig & PathFilterConfig & TypesFilterConfig
pull_request_target?: BranchFilterConfig &
PathFilterConfig &
TypesFilterConfig
push?: BranchFilterConfig & TagFilterConfig & PathFilterConfig
workflow_run?: WorkflowFilterConfig & BranchFilterConfig & TypesFilterConfig
pull_request?: BranchFilterConfig & PathFilterConfig & TypesFilterConfig;
pull_request_target?: BranchFilterConfig & PathFilterConfig & TypesFilterConfig;
push?: BranchFilterConfig & TagFilterConfig & PathFilterConfig;
workflow_run?: WorkflowFilterConfig & BranchFilterConfig & TypesFilterConfig;
// Events that only support activity types
branch_protection_rule?: TypesFilterConfig
check_run?: TypesFilterConfig
check_suite?: TypesFilterConfig
disccusion?: TypesFilterConfig
disccusion_comment?: TypesFilterConfig
issue_comment?: TypesFilterConfig
issues?: TypesFilterConfig
label?: TypesFilterConfig
merge_group?: TypesFilterConfig
milestone?: TypesFilterConfig
project?: TypesFilterConfig
project_card?: TypesFilterConfig
project_column?: TypesFilterConfig
pull_request_review?: TypesFilterConfig
pull_request_review_comment?: TypesFilterConfig
registry_package?: TypesFilterConfig
repository_dispatch?: TypesFilterConfig
release?: TypesFilterConfig
watch?: TypesFilterConfig
branch_protection_rule?: TypesFilterConfig;
check_run?: TypesFilterConfig;
check_suite?: TypesFilterConfig;
disccusion?: TypesFilterConfig;
disccusion_comment?: TypesFilterConfig;
issue_comment?: TypesFilterConfig;
issues?: TypesFilterConfig;
label?: TypesFilterConfig;
merge_group?: TypesFilterConfig;
milestone?: TypesFilterConfig;
project?: TypesFilterConfig;
project_card?: TypesFilterConfig;
project_column?: TypesFilterConfig;
pull_request_review?: TypesFilterConfig;
pull_request_review_comment?: TypesFilterConfig;
registry_package?: TypesFilterConfig;
repository_dispatch?: TypesFilterConfig;
release?: TypesFilterConfig;
watch?: TypesFilterConfig;
// Index signature to allow easier lookup
[eventName: string]: unknown
}
[eventName: string]: unknown;
};
export type TypesFilterConfig = {
types?: string[]
}
types?: string[];
};
export type BranchFilterConfig = {
branches?: string[]
"branches-ignore"?: string[]
}
branches?: string[];
"branches-ignore"?: string[];
};
export type TagFilterConfig = {
tags?: string[]
"tags-ignore"?: string[]
}
tags?: string[];
"tags-ignore"?: string[];
};
export type PathFilterConfig = {
paths?: string[]
"paths-ignore"?: string[]
}
paths?: string[];
"paths-ignore"?: string[];
};
export type WorkflowDispatchConfig = {
inputs?: { [inputName: string]: InputConfig }
}
inputs?: {[inputName: string]: InputConfig};
};
export type WorkflowCallConfig = {
inputs: { [inputName: string]: InputConfig }
inputs: {[inputName: string]: InputConfig};
// TODO - these are supported in C# and Go but not in TS yet
// outputs: { [outputName: string]: OutputConfig }
// secrets: { [secretName: string]: SecretConfig }
}
};
export enum InputType {
string = "string",
choice = "choice",
boolean = "boolean",
environment = "environment",
environment = "environment"
}
export type InputConfig = {
type: InputType
description?: string
required?: boolean
default?: string | boolean | number
options?: string[]
}
type: InputType;
description?: string;
required?: boolean;
default?: string | boolean | number;
options?: string[];
};
export type ScheduleConfig = {
cron: string
}
cron: string;
};
export type WorkflowFilterConfig = {
workflows?: string[]
}
workflows?: string[];
};