diff --git a/package.json b/package.json index 893ea4d7..674c4447 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "jsonwebtoken": "^7.2.1", "node-fetch": "^1.6.3", "node-pg-migrate": "^2.7.0", + "node-schedule": "^1.2.5", "pg-promise": "^6.1.0", "ts-jest": "^21", "ts-node": "^3.0.2", diff --git a/source/danger/_tests/_danger_run.test.ts b/source/danger/_tests/_danger_run.test.ts index fbe38003..0ed7f6ee 100644 --- a/source/danger/_tests/_danger_run.test.ts +++ b/source/danger/_tests/_danger_run.test.ts @@ -12,10 +12,12 @@ describe("for ping", () => { const rules = { ping: "dangerfile.js" } expect(dangerRunForRules("ping", null, rules)).toEqual({ action: null, + branch: "master", dangerfilePath: "dangerfile.js", dslType: dsl.import, event: "ping", feedback: feedback.silent, + repoSlug: undefined, }) }) @@ -30,10 +32,12 @@ describe("for PRs", () => { const rules = { pull_request: "dangerfile.js" } expect(dangerRunForRules("pull_request", "created", rules)).toEqual({ action: "created", + branch: "master", dangerfilePath: "dangerfile.js", dslType: dsl.pr, event: "pull_request", feedback: feedback.commentable, + repoSlug: undefined, }) }) @@ -42,10 +46,12 @@ describe("for PRs", () => { const rules = { "pull_request.*": "dangerfile.js" } expect(dangerRunForRules("pull_request", "updated", rules)).toEqual({ action: "updated", + branch: "master", dangerfilePath: "dangerfile.js", dslType: dsl.pr, event: "pull_request", feedback: feedback.commentable, + repoSlug: undefined, }) }) @@ -58,29 +64,52 @@ describe("for PRs", () => { const rules = { "pull_request.deleted": "dangerfile.js" } expect(dangerRunForRules("pull_request", "deleted", rules)).toEqual({ action: "deleted", + branch: "master", dangerfilePath: "dangerfile.js", dslType: dsl.pr, event: "pull_request", feedback: feedback.commentable, + repoSlug: undefined, }) }) }) describe("dangerRepresentationforPath", () => { - it("returns just the path when there is no repo reference", () => { + it("returns just the path with master and no repo with just a path", () => { const path = "dangerfile.ts" expect(dangerRepresentationforPath(path)).toEqual({ + branch: "master", dangerfilePath: "dangerfile.ts", + repoSlug: undefined, }) }) - it("returns just the path when there is no repo reference", () => { + it("returns the path and repo", () => { const path = "orta/eigen@dangerfile.ts" expect(dangerRepresentationforPath(path)).toEqual({ + branch: "master", + dangerfilePath: "dangerfile.ts", + repoSlug: "orta/eigen", + }) + }) + + it("returns just the path when there is no repo reference", () => { + const path = "orta/eigen@dangerfile.ts#branch" + expect(dangerRepresentationforPath(path)).toEqual({ + branch: "branch", dangerfilePath: "dangerfile.ts", repoSlug: "orta/eigen", }) }) + + it("handles a branch with no repo ref", () => { + const path = "dangerfile.ts#branch" + expect(dangerRepresentationforPath(path)).toEqual({ + branch: "branch", + dangerfilePath: "dangerfile.ts", + repoSlug: undefined, + }) + }) }) describe("dslTypeForEvent", () => { diff --git a/source/danger/danger_run.ts b/source/danger/danger_run.ts index 94008863..50d150af 100644 --- a/source/danger/danger_run.ts +++ b/source/danger/danger_run.ts @@ -21,15 +21,11 @@ export enum feedback { } /** Represents runs that Danger should do based on Rules and Events */ -export interface DangerRun { +export interface DangerRun extends RepresentationForURL { /** What event name triggered this */ event: string /** What action inside that event trigger this run */ action: string | null - /** What slug should this run come from? */ - repoSlug?: string - /** Where should we look in that repo for the Dangerfile? */ - dangerfilePath: string /** What type of DSL should the run use? */ dslType: dsl /** Can Danger provide commentable feedback? */ @@ -69,15 +65,19 @@ export const dangerRunForRules = ( } } +interface RepresentationForURL { + dangerfilePath: string + branch: string + repoSlug: string | undefined +} + /** Takes a DangerfileReferenceString and lets you know where to find it globally */ -export const dangerRepresentationforPath = (value: DangerfileReferenceString) => { - if (!value.includes("@")) { - return { dangerfilePath: value } - } else { - return { - dangerfilePath: value.split("@")[1] as string, - repoSlug: value.split("@")[0] as string, - } +export const dangerRepresentationforPath = (value: DangerfileReferenceString): RepresentationForURL => { + const afterAt = value.includes("@") ? value.split("@")[1] : value + return { + branch: value.includes("#") ? value.split("#")[1] : "master", + dangerfilePath: afterAt.split("#")[0], + repoSlug: value.includes("@") ? value.split("@")[0] : undefined, } } diff --git a/source/danger/danger_runner.ts b/source/danger/danger_runner.ts index 02449e3e..9bf2316a 100644 --- a/source/danger/danger_runner.ts +++ b/source/danger/danger_runner.ts @@ -24,9 +24,7 @@ import { getTemporaryAccessTokenForInstallation } from "../api/github" import perilPlatform from "./peril_platform" /** Logs */ -const log = (message: string) => { - winston.info(`[runner] - ${message}`) -} +const log = (message: string) => winston.info(`[runner] - ${message}`) // What does the Peril object look like inside the runtime // TODO: Expose this usefully somehow diff --git a/source/db/_tests/__snapshots__/_json.test.ts.snap b/source/db/_tests/__snapshots__/_json.test.ts.snap index ed71d479..60ef5f18 100644 --- a/source/db/_tests/__snapshots__/_json.test.ts.snap +++ b/source/db/_tests/__snapshots__/_json.test.ts.snap @@ -25,6 +25,7 @@ Object { "issue": "orta/peril@issue.ts", "pull_request": "orta/peril@pr.ts", }, + "scheduler": Object {}, "settings": Object { "env_vars": Array [], "ignored_repos": Array [], diff --git a/source/db/index.ts b/source/db/index.ts index ee94a4c3..e3e0b4d3 100644 --- a/source/db/index.ts +++ b/source/db/index.ts @@ -30,13 +30,58 @@ export interface GitHubInstallation { */ id: number /** - * In our DB this is represented as a JSON type, so you should always have settings + * In our DB this is represented as a JSON type, so you should anticipate have settings * as a nullable type. These are the entire installation settings. */ settings: GitHubInstallationSettings /** Having rules in here would mean that it would happen on _any_ event, another JSON type in the DB */ rules: RunnerRuleset + + /** + * Scheduled tasks to run using a cron-like syntax. + * + * This uses [node-schedule](https://github.com/node-schedule/node-schedule) under the hood. The + * object is similar to the rules section, in that you define a cron-string with the following format: + * + * * * * * * * + * ┬ ┬ ┬ ┬ ┬ ┬ + * │ │ │ │ │ | + * │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun) + * │ │ │ │ └───── month (1 - 12) + * │ │ │ └────────── day of month (1 - 31) + * │ │ └─────────────── hour (0 - 23) + * │ └──────────────────── minute (0 - 59) + * └───────────────────────── second (0 - 59, OPTIONAL) + * + * Which would look something like: + * + * "scheduler": { + * "0 0 12 * * ?": "schedule/daily_at_twelve.ts", + * "0 9 * * 1-5": "schedule/weekday_wakeup_email.ts" + * } + * + * in practice. There's a lot of great resources on the net showing the general syntax. + */ + scheduler: RunnerRuleset + + /** + * A set of repos and their additional event hooks, these are + * in addition to the ones provided by `"rules"` which are applied + * to every repo. + * + * "repos" : { + * "orta/ORStackView": { + * "issue.created": "orta/peril@lock_issues.ts" + * } + * } + * + */ + repos: UniqueRepoRuleset +} + +export interface UniqueRepoRuleset { + [name: string]: RunnerRuleset } export interface RunnerRuleset { diff --git a/source/db/json.ts b/source/db/json.ts index 38d2273c..248c6ada 100644 --- a/source/db/json.ts +++ b/source/db/json.ts @@ -26,7 +26,8 @@ For example: */ /** Logs */ -const info = (message: string) => winston.info(`[db] - ${message}`) +const info = (message: string) => winston.info(`[json db] - ${message}`) +const error = (message: string) => winston.error(`[json db] - ${message}`) const getInstallationId = (id: string | undefined): number => { let installationId: number | undefined = parseInt(id as string, 10) @@ -56,8 +57,7 @@ const jsonDatabase = (dangerFilePath: DangerfileReferenceString): DatabaseAdapto /** Gets a Github repo from the DB */ getRepo: async (installationID: number, repoName: string): Promise => { - // Type this? - const repos = (org as any).repos + const repos = org.repos if (!repos[repoName]) { return null } @@ -101,19 +101,31 @@ const jsonDatabase = (dangerFilePath: DangerfileReferenceString): DatabaseAdapto throwNoJSONFileFound(dangerFilePath) } - org = JSON.parse(file) - // Ensure settings are non-null - org.settings.env_vars = org.settings.env_vars || [] - org.settings.ignored_repos = org.settings.ignored_repos || [] - org.settings.modules = org.settings.modules || [] - org.id = getInstallationId(PERIL_ORG_INSTALLATION_ID) + const parsedOrg = JSON.parse(file) as Partial + if (!parsedOrg) { + error(`Could not run JSON.parse on the contents of ${dangerFilePath}.`) + process.exitCode = 1 + } else { + // Set our write-once org variable that is then re-used for all of the different + // installation related calls + + org = { + id: getInstallationId(PERIL_ORG_INSTALLATION_ID), + repos: parsedOrg.repos || {}, + rules: parsedOrg.rules || {}, + scheduler: parsedOrg.scheduler || {}, + settings: { + env_vars: (parsedOrg.settings && parsedOrg.settings.env_vars) || [], + ignored_repos: (parsedOrg.settings && parsedOrg.settings.ignored_repos) || [], + modules: (parsedOrg.settings && parsedOrg.settings.modules) || [], + }, + } + } }, }) export default jsonDatabase -// Some error handling. - const throwNoPerilInstallationID = () => { /* tslint:disable: max-line-length */ const msg = diff --git a/source/db/postgres.ts b/source/db/postgres.ts index af98b192..bec90d6f 100644 --- a/source/db/postgres.ts +++ b/source/db/postgres.ts @@ -16,10 +16,6 @@ const info = (message: string) => { } const database: DatabaseAdaptor = { - setup: async () => { - db = pg()(DATABASE_URL as string) - }, - /** Saves an Integration */ saveInstallation: async (installation: GitHubInstallation) => { info(`Saving installation with id: ${installation.id}`) @@ -39,6 +35,9 @@ const database: DatabaseAdaptor = { ) }, + setup: async () => { + db = pg()(DATABASE_URL as string) + }, /** Gets an Integration */ getInstallation: async (installationID: number): Promise => { return db.oneOrNone("select * from installations where id=$1", [installationID]) diff --git a/source/github/events/_tests/_github_runner-runs.test.ts b/source/github/events/_tests/_github_runner-runs.test.ts index 1fc0345b..a2fe82cc 100644 --- a/source/github/events/_tests/_github_runner-runs.test.ts +++ b/source/github/events/_tests/_github_runner-runs.test.ts @@ -32,9 +32,11 @@ const getSettings = (overwrites: Partial) => ({ it("handles a platform only run", () => { const installation = { id: 12, + repos: {}, rules: { pull_request: "orta/peril-dangerfiles@pr.ts", }, + scheduler: {}, settings: defaultSettings, } @@ -44,6 +46,7 @@ it("handles a platform only run", () => { expect(runs).toEqual([ { action: "created", + branch: "master", dangerfilePath: "pr.ts", dslType: 0, event: "pull_request", @@ -56,9 +59,11 @@ it("handles a platform only run", () => { it("gets the expected runs for platform + repo rules", () => { const installation: GitHubInstallation = { id: 12, + repos: {}, rules: { pull_request: "orta/peril-dangerfiles@pr.ts", }, + scheduler: {}, settings: defaultSettings, } @@ -68,6 +73,7 @@ it("gets the expected runs for platform + repo rules", () => { expect(runs).toEqual([ { action: "created", + branch: "master", dangerfilePath: "pr.ts", dslType: 0, event: "pull_request", @@ -76,10 +82,12 @@ it("gets the expected runs for platform + repo rules", () => { }, { action: "created", + branch: "master", dangerfilePath: "pr.ts", dslType: 0, event: "pull_request", feedback: 0, + repoSlug: undefined, }, ]) }) @@ -87,9 +95,11 @@ it("gets the expected runs for platform + repo rules", () => { it("gets the expected runs for platform", () => { const installation = { id: 12, + repos: {}, rules: { pull_request: "orta/peril-dangerfiles@pr.ts", }, + scheduler: {}, settings: defaultSettings, } @@ -108,10 +118,12 @@ it("gets the expected runs for platform", () => { expect(runs).toEqual([ { action: "created", + branch: "master", dangerfilePath: "pr.ts", dslType: 1, event: "issues", feedback: 0, + repoSlug: undefined, }, ]) }) diff --git a/source/github/events/_tests/_github_runner-validations.test.ts b/source/github/events/_tests/_github_runner-validations.test.ts index 5444421a..932f9c66 100644 --- a/source/github/events/_tests/_github_runner-validations.test.ts +++ b/source/github/events/_tests/_github_runner-validations.test.ts @@ -19,7 +19,9 @@ it("Does not run a dangerfile in an ignored repo", async () => { const installationSettings: GitHubInstallation = { id: 123, + repos: {}, rules: {}, + scheduler: {}, settings: { env_vars: [], ignored_repos: [body.pull_request.head.repo.full_name], diff --git a/source/github/events/create_installation.ts b/source/github/events/create_installation.ts index 18ea6327..ebca8817 100644 --- a/source/github/events/create_installation.ts +++ b/source/github/events/create_installation.ts @@ -9,9 +9,11 @@ import db from "../../db/getDB" export async function createInstallation(installationJSON: Installation, req: express.Request, res: express.Response) { const installation: GitHubInstallation = { id: installationJSON.id, + repos: {}, rules: { pull_request: "dangerfile.js", }, + scheduler: {}, settings: { env_vars: [], ignored_repos: [], diff --git a/source/peril.ts b/source/peril.ts index 7010a341..9b0038ac 100644 --- a/source/peril.ts +++ b/source/peril.ts @@ -7,9 +7,9 @@ import { PERIL_WEBHOOK_SECRET, PUBLIC_FACING_API } from "./globals" import prDSLRunner from "./api/pr/dsl" import logger from "./logger" import webhook from "./routing/router" +import startScheduler from "./scheduler/startScheduler" const peril = () => { - // Error logging process.on("unhandledRejection", (reason: string, p: any) => { console.log("Error: ", reason) // tslint:disable-line @@ -32,9 +32,9 @@ const peril = () => { // Start server app.listen(app.get("port"), () => { - console.log(`Started server at http://localhost:${process.env.PORT || 5000}`) // tslint:disable-line - logger.info("Started up server.") + logger.info(`Started server at http://localhost:${process.env.PORT || 5000}`) // tslint:disable-line + startScheduler() }) } -export default peril; \ No newline at end of file +export default peril diff --git a/source/scheduler/_tests/_runJob.test.ts b/source/scheduler/_tests/_runJob.test.ts new file mode 100644 index 00000000..a956b90e --- /dev/null +++ b/source/scheduler/_tests/_runJob.test.ts @@ -0,0 +1,45 @@ +import { dsl } from "../../danger/danger_run" +import { GitHubInstallation } from "../../db/index" +import runJob from "../runJob" + +jest.mock("../../api/github", () => ({ getTemporaryAccessTokenForInstallation: () => Promise.resolve("token123") })) + +jest.mock("../../github/lib/github_helpers", () => ({ + getGitHubFileContents: jest.fn(), +})) +import { getGitHubFileContents } from "../../github/lib/github_helpers" + +jest.mock("../../danger/danger_runner", () => ({ + runDangerForInstallation: jest.fn(), +})) +import { runDangerForInstallation } from "../../danger/danger_runner" + +const installation: GitHubInstallation = { + id: 123, + repos: {}, + rules: {}, + scheduler: {}, + settings: { + env_vars: [], + ignored_repos: [], + modules: [], + }, +} + +it("runs a dangerfile", async () => { + ;(getGitHubFileContents as any).mockImplementationOnce(() => Promise.resolve("file")) + + await runJob(installation, "danger/danger-repo@hello.ts") + + expect(runDangerForInstallation).toBeCalledWith("file", "hello.ts", null, dsl.import, installation, {}) +}) + +jest.mock("../../globals.ts", () => ({ DATABASE_JSON_FILE: "private/repo" })) + +it("uses the project settings repo when no repo is passsed", async () => { + ;(getGitHubFileContents as any).mockImplementationOnce(() => Promise.resolve("file")) + + await runJob(installation, "weekly.ts") + + expect(getGitHubFileContents).toBeCalledWith("token123", "private/repo", "weekly.ts", "master") +}) diff --git a/source/scheduler/_tests/_startScheduler.test.ts b/source/scheduler/_tests/_startScheduler.test.ts new file mode 100644 index 00000000..6da0f7fc --- /dev/null +++ b/source/scheduler/_tests/_startScheduler.test.ts @@ -0,0 +1,23 @@ +const mockSchedule = { scheduleJob: jest.fn() } +jest.mock("node-schedule", () => mockSchedule) + +jest.mock("../../db/getDB", () => ({ default: { getInstallation: jest.fn() } })) +import db from "../../db/getDB" + +const mockInstallation: jest.Mock = db.getInstallation as any + +import installationFactory from "../../testing/installationFactory" +import startScheduler from "../startScheduler" + +it("runs scheduleJob for your tasks", async () => { + const scheduler = { + "1 2 3 4 5": "every_so_often.ts", + } + const installation = installationFactory({ scheduler }) + mockInstallation.mockImplementationOnce(() => Promise.resolve(installation)) + + await startScheduler() + + expect(db.getInstallation).toBeCalledWith(0) + expect(mockSchedule.scheduleJob).toBeCalledWith("1 2 3 4 5", expect.anything()) +}) diff --git a/source/scheduler/runJob.ts b/source/scheduler/runJob.ts new file mode 100644 index 00000000..a1d35abb --- /dev/null +++ b/source/scheduler/runJob.ts @@ -0,0 +1,33 @@ +import { scheduleJob } from "node-schedule" +import { getTemporaryAccessTokenForInstallation } from "../api/github" +import { dangerRepresentationforPath, dangerRunForRules, dsl } from "../danger/danger_run" +import { runDangerForInstallation } from "../danger/danger_runner" +import { DangerfileReferenceString, GitHubInstallation } from "../db/index" +import { getGitHubFileContents } from "../github/lib/github_helpers" +import { DATABASE_JSON_FILE } from "../globals" +import winston from "../logger" + +const error = (message: string) => { + winston.info(`[github auth] - ${message}`) + console.error(message) // tslint:disable-line +} + +const runJob = async (installation: GitHubInstallation, rules: DangerfileReferenceString) => { + const rep = dangerRepresentationforPath(rules) + if (rep.repoSlug === undefined) { + if (DATABASE_JSON_FILE) { + // If you don't provide a repo slug, assume that the + // dangerfile comes from inside the same repo as your settings. + rep.repoSlug = DATABASE_JSON_FILE.split("@")[0] + } else { + error(`Error: could not determine a repo for ${rules} - skipping the scheduled run`) + } + } + + const dangerDSL = {} + const token = await getTemporaryAccessTokenForInstallation(installation.id) + const dangerfile = await getGitHubFileContents(token, rep.repoSlug!, rep.dangerfilePath, rep.branch) + return runDangerForInstallation(dangerfile, rep.dangerfilePath, null, dsl.import, installation, dangerDSL) +} + +export default runJob diff --git a/source/scheduler/startScheduler.ts b/source/scheduler/startScheduler.ts new file mode 100644 index 00000000..db55d53e --- /dev/null +++ b/source/scheduler/startScheduler.ts @@ -0,0 +1,22 @@ +import { scheduleJob } from "node-schedule" + +import db from "../db/getDB" +import runJob from "./runJob" + +const startScheduler = async () => { + // TODO: This will only work for JSON-based setups right now + const installation = await db.getInstallation(0) + if (!installation) { + return + } + + // Loop through the object's properties and set up the scheduler + for (const cronTask in installation.scheduler) { + if (installation.scheduler.hasOwnProperty(cronTask)) { + const rules = installation.scheduler[cronTask] + scheduleJob(cronTask, () => runJob(installation, rules)) + } + } +} + +export default startScheduler diff --git a/source/testing/installationFactory.ts b/source/testing/installationFactory.ts new file mode 100644 index 00000000..5a90e432 --- /dev/null +++ b/source/testing/installationFactory.ts @@ -0,0 +1,15 @@ +import { GitHubInstallation } from "../db/index" + +const emptyInstallation: GitHubInstallation = { + id: 123, + repos: {}, + rules: {}, + scheduler: {}, + settings: { + env_vars: [], + ignored_repos: [], + modules: [], + }, +} + +export default (diff: Partial): GitHubInstallation => Object.assign({}, this.state, diff) diff --git a/yarn.lock b/yarn.lock index ea4f7ee3..6c21a850 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1585,6 +1585,13 @@ create-thenable@~1.0.0: object.omit "~2.0.0" unique-concat "~0.2.2" +cron-parser@^2.4.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-2.4.2.tgz#883c55143632fc71161231549a18a1e71c0d47ba" + dependencies: + is-nan "^1.2.1" + moment-timezone "^0.5.0" + cross-spawn@^5.0.1: version "5.1.0" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" @@ -1735,7 +1742,7 @@ defaults@^1.0.3: dependencies: clone "^1.0.2" -define-properties@^1.1.2: +define-properties@^1.1.1, define-properties@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.2.tgz#83a73f2fea569898fb737193c8f873caf6d45c94" dependencies: @@ -2704,6 +2711,12 @@ is-glob@^2.0.0, is-glob@^2.0.1: dependencies: is-extglob "^1.0.0" +is-nan@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.2.1.tgz#9faf65b6fb6db24b7f5c0628475ea71f988401e2" + dependencies: + define-properties "^1.1.1" + is-npm@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4" @@ -3481,6 +3494,10 @@ log-update@^1.0.2: ansi-escapes "^1.0.0" cli-cursor "^1.0.2" +long-timeout@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/long-timeout/-/long-timeout-0.1.1.tgz#9721d788b47e0bcb5a24c2e2bee1a0da55dab514" + longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" @@ -3623,7 +3640,13 @@ minimist@1.2.0, minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0, minimist@~1.2 dependencies: minimist "0.0.8" -moment@2.x.x, moment@>=2.14.0: +moment-timezone@^0.5.0: + version "0.5.13" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.13.tgz#99ce5c7d827262eb0f1f702044177f60745d7b90" + dependencies: + moment ">= 2.9.0" + +moment@2.x.x, "moment@>= 2.9.0", moment@>=2.14.0: version "2.18.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f" @@ -3730,6 +3753,14 @@ node-pre-gyp@^0.6.36: tar "^2.2.1" tar-pack "^3.4.0" +node-schedule@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/node-schedule/-/node-schedule-1.2.5.tgz#fb30f4e4d1dd1e81c536f9495d5da0e9e2d7de14" + dependencies: + cron-parser "^2.4.0" + long-timeout "0.1.1" + sorted-array-functions "^1.0.0" + node-status-codes@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-status-codes/-/node-status-codes-1.0.0.tgz#5ae5541d024645d32a58fcddc9ceecea7ae3ac2f" @@ -4598,6 +4629,10 @@ sort-keys@^1.0.0: dependencies: is-plain-obj "^1.0.0" +sorted-array-functions@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/sorted-array-functions/-/sorted-array-functions-1.0.0.tgz#c0b554d9e709affcbe56d34c1b2514197fd38279" + source-map-support@^0.4.0, source-map-support@^0.4.2, source-map-support@^0.4.4: version "0.4.14" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.14.tgz#9d4463772598b86271b4f523f6c1f4e02a7d6aef"