Re-add parsing and validation for cron, and use cronstrue for sentences
This commit is contained in:
@@ -1,47 +1,12 @@
|
||||
const MONTHS = {
|
||||
jan: 1,
|
||||
feb: 2,
|
||||
mar: 3,
|
||||
apr: 4,
|
||||
may: 5,
|
||||
jun: 6,
|
||||
jul: 7,
|
||||
aug: 8,
|
||||
sep: 9,
|
||||
oct: 10,
|
||||
nov: 11,
|
||||
dec: 12,
|
||||
}
|
||||
import cronstrue from 'cronstrue';
|
||||
|
||||
const DAYS = {
|
||||
sun: 0,
|
||||
mon: 1,
|
||||
tue: 2,
|
||||
wed: 3,
|
||||
thu: 4,
|
||||
fri: 5,
|
||||
sat: 6,
|
||||
}
|
||||
|
||||
// TODO: make this parseCron
|
||||
export function isValidCron(cron: string): boolean {
|
||||
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
|
||||
|
||||
const parts = cron.split(/ +/)
|
||||
if (parts.length != 5) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [minutes, hours, dom, months, dow] = parts
|
||||
|
||||
return (
|
||||
validateRange(minutes, { min: 0, max: 59 }) &&
|
||||
validateRange(hours, { min: 0, max: 23 }) &&
|
||||
validateRange(dom, { min: 1, max: 31 }) &&
|
||||
validateRange(months, { min: 1, max: 12, names: MONTHS }) &&
|
||||
validateRange(dow, { min: 0, max: 6, names: DAYS })
|
||||
)
|
||||
}
|
||||
import {
|
||||
MONTH_RANGE,
|
||||
HOUR_RANGE,
|
||||
MINUTE_RANGE,
|
||||
DOM_RANGE,
|
||||
DOW_RANGE
|
||||
} from './cron-constants'
|
||||
|
||||
type Range = {
|
||||
min: number
|
||||
@@ -49,7 +14,112 @@ type Range = {
|
||||
names?: Record<string, number>
|
||||
}
|
||||
|
||||
function validateRange(
|
||||
type Schedule = {
|
||||
minutes?: number[]
|
||||
hours?: number[]
|
||||
dom?: number[]
|
||||
months?: number[]
|
||||
dow?: number[]
|
||||
}
|
||||
|
||||
export function isValidCron(cron: string): boolean {
|
||||
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
|
||||
|
||||
const parts = cron.split(/ +/)
|
||||
if (parts.length != 5) {
|
||||
return false
|
||||
}
|
||||
const [minutes, hours, dom, months, dow] = parts
|
||||
|
||||
return (
|
||||
validateCronPart(minutes, MINUTE_RANGE) &&
|
||||
validateCronPart(hours, HOUR_RANGE) &&
|
||||
validateCronPart(dom, DOM_RANGE) &&
|
||||
validateCronPart(months, MONTH_RANGE) &&
|
||||
validateCronPart(dow, DOW_RANGE)
|
||||
)
|
||||
}
|
||||
|
||||
export function getSentence(cronspec: string): string | undefined {
|
||||
const schedule = getSchedule(cronspec)
|
||||
if (!schedule) {
|
||||
return
|
||||
}
|
||||
|
||||
let desc = ''
|
||||
try {
|
||||
desc = cronstrue.toString(cronspec, {
|
||||
dayOfWeekStartIndexZero: true,
|
||||
monthStartIndexZero: false,
|
||||
use24HourTimeFormat: true,
|
||||
// cronstrue sets the description as the error if throwExceptionOnParseError is false
|
||||
// so we need to distinguish between an error and a valid description
|
||||
throwExceptionOnParseError: true,
|
||||
})
|
||||
} catch (err) {
|
||||
return
|
||||
}
|
||||
|
||||
return desc
|
||||
}
|
||||
|
||||
function parseCronPart(part: string, range: Range): number[] | undefined {
|
||||
const values: number[] = []
|
||||
|
||||
if (part === "*") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (part.includes(",")) {
|
||||
part.split(",").forEach((v) => {
|
||||
const value = parseCronPart(v, range)
|
||||
if (value) {
|
||||
values.push(...value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (part.includes("/")) {
|
||||
const [stepRange, step] = part.split("/")
|
||||
const stepNumber = +step
|
||||
let startNumber, endNumber
|
||||
if (stepRange.includes("-")) {
|
||||
const [start, end] = stepRange.split("-")
|
||||
startNumber = convertToNumber(start, range.names)
|
||||
endNumber = convertToNumber(end, range.names)
|
||||
}
|
||||
else {
|
||||
if (stepRange === "*") {
|
||||
startNumber = range.min
|
||||
}
|
||||
else {
|
||||
startNumber = convertToNumber(stepRange, range.names)
|
||||
}
|
||||
endNumber = range.max
|
||||
}
|
||||
for (let i = startNumber; i <= endNumber; i += stepNumber) {
|
||||
values.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
if (part.includes("-")) {
|
||||
const [start, end] = part.split("-")
|
||||
const startNumber = convertToNumber(start, range.names)
|
||||
const endNumber = convertToNumber(end, range.names)
|
||||
for (let i = startNumber; i <= endNumber; i++) {
|
||||
values.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
const number = convertToNumber(part, range.names)
|
||||
if (!isNaN(number)) {
|
||||
values.push(number)
|
||||
}
|
||||
|
||||
return values.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
function validateCronPart(
|
||||
value: string,
|
||||
range: Range,
|
||||
allowSeparators = true
|
||||
@@ -67,7 +137,7 @@ function validateRange(
|
||||
if (!allowSeparators) {
|
||||
return false
|
||||
}
|
||||
return value.split(",").every((v) => v && validateRange(v, range))
|
||||
return value.split(",").every((v) => v && validateCronPart(v, range))
|
||||
}
|
||||
|
||||
if (value.includes("/")) {
|
||||
@@ -83,7 +153,7 @@ function validateRange(
|
||||
|
||||
// Separators are only allowed in the part before the `/`, e.g. `1-5/2`
|
||||
return (
|
||||
validateRange(start, range) && validateRange(step, range, false)
|
||||
validateCronPart(start, range) && validateCronPart(step, range, false)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,16 +161,17 @@ function validateRange(
|
||||
if (!allowSeparators) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [start, end, ...rest] = value.split("-")
|
||||
if (rest.length > 0 || !start || !end) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert name to integers so we can make sure end >= start
|
||||
const startNumber = convertToNumber(range, start)
|
||||
const endNumber = convertToNumber(range, end)
|
||||
const startNumber = convertToNumber(start, range.names)
|
||||
const endNumber = convertToNumber(end, range.names)
|
||||
return (
|
||||
validateRange(start, range, false) && validateRange(end, range, false) && endNumber >= startNumber
|
||||
validateCronPart(start, range, false) && validateCronPart(end, range, false) && endNumber >= startNumber
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,9 +179,27 @@ function validateRange(
|
||||
return !isNaN(number) && number >= range.min && number <= range.max
|
||||
}
|
||||
|
||||
function convertToNumber(range: Range, value: string): number {
|
||||
if (range.names && range.names[value.toLowerCase()] !== undefined) {
|
||||
return +range.names[value.toLowerCase()]
|
||||
export function getSchedule(cron: string): Schedule | undefined {
|
||||
if (!isValidCron(cron)) {
|
||||
return
|
||||
}
|
||||
|
||||
const [minutes, hours, dom, months, dow] = cron.split(/ +/)
|
||||
return {
|
||||
minutes: parseCronPart(minutes, MINUTE_RANGE),
|
||||
hours: parseCronPart(hours, HOUR_RANGE),
|
||||
dom: parseCronPart(dom, DOM_RANGE),
|
||||
months: parseCronPart(months, MONTH_RANGE),
|
||||
dow: parseCronPart(dow, DOW_RANGE)
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
// Converts a string integer or a short name to a number
|
||||
function convertToNumber(value: string, names?: Record<string, number>): number {
|
||||
if (names && names[value.toLowerCase()] !== undefined) {
|
||||
return +names[value.toLowerCase()]
|
||||
}
|
||||
else {
|
||||
return +value
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// template-reader *just* does schema validation
|
||||
|
||||
import {getSentence} from "../model/converter/cron";
|
||||
import {ObjectReader} from "./object-reader";
|
||||
import {TemplateSchema} from "./schema";
|
||||
import {DefinitionInfo} from "./schema/definition-info";
|
||||
@@ -215,6 +216,7 @@ class TemplateReader {
|
||||
const nextPropertyDef = this._schema.matchPropertyAndFilter(mappingDefinitions, nextKey.value);
|
||||
if (nextPropertyDef) {
|
||||
const nextDefinition = new DefinitionInfo(definition, nextPropertyDef.type);
|
||||
const nextValue = this.readValue(nextDefinition);
|
||||
|
||||
// Store the definition on the key, the value may have its own definition
|
||||
nextKey.definitionInfo = nextDefinition;
|
||||
@@ -224,8 +226,17 @@ class TemplateReader {
|
||||
if (nextPropertyDef.description) {
|
||||
nextKey.description = nextPropertyDef.description;
|
||||
}
|
||||
|
||||
const nextValue = this.readValue(nextDefinition);
|
||||
else if (upperKey === "CRON") {
|
||||
// Schedules are a special case, we have to calculate the description
|
||||
if (isString(nextValue)) {
|
||||
nextValue.description = getSentence((nextValue as StringToken).value)
|
||||
nextValue.description += "\nActions schedules run at most every 5 minutes." +
|
||||
" [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)";
|
||||
}
|
||||
else {
|
||||
nextValue.description = "Cron expression must be a string"
|
||||
}
|
||||
}
|
||||
|
||||
mapping.add(nextKey, nextValue);
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user