diff --git a/actions-workflow-parser/src/model/converter/cron.ts b/actions-workflow-parser/src/model/converter/cron.ts index aa46ca5..f8305fa 100644 --- a/actions-workflow-parser/src/model/converter/cron.ts +++ b/actions-workflow-parser/src/model/converter/cron.ts @@ -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 } -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): number { + if (names && names[value.toLowerCase()] !== undefined) { + return +names[value.toLowerCase()] } else { return +value diff --git a/actions-workflow-parser/src/templates/template-reader.ts b/actions-workflow-parser/src/templates/template-reader.ts index 171ae72..4919c90 100644 --- a/actions-workflow-parser/src/templates/template-reader.ts +++ b/actions-workflow-parser/src/templates/template-reader.ts @@ -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; diff --git a/package-lock.json b/package-lock.json index 3009b6f..90f53ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "./actions-languageserver", "./browser-playground" ], + "dependencies": { + "cronstrue": "^2.21.0" + }, "devDependencies": { "lerna": "^6.0.3" } @@ -5273,6 +5276,14 @@ "node": ">= 6" } }, + "node_modules/cronstrue": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.21.0.tgz", + "integrity": "sha512-YxabE1ZSHA1zJZMPCTSEbc0u4cRRenjqqTgCwJT7OvkspPSvfYFITuPFtsT+VkBuavJtFv2kJXT+mKSnlUJxfg==", + "bin": { + "cronstrue": "bin/cli.js" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -18052,6 +18063,11 @@ } } }, + "cronstrue": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.21.0.tgz", + "integrity": "sha512-YxabE1ZSHA1zJZMPCTSEbc0u4cRRenjqqTgCwJT7OvkspPSvfYFITuPFtsT+VkBuavJtFv2kJXT+mKSnlUJxfg==" + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", diff --git a/package.json b/package.json index d28e7ac..6032727 100644 --- a/package.json +++ b/package.json @@ -10,5 +10,8 @@ "devDependencies": { "lerna": "^6.0.3" }, - "name": "actions-languageservices" + "name": "actions-languageservices", + "dependencies": { + "cronstrue": "^2.21.0" + } }