diff --git a/package-lock.json b/package-lock.json index d7025d81..bf7dd727 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "nodemailer": "^6.5.0", "notifme-sdk": "^1.10.0", "prettier": "^2.2.1", + "rrule": "^2.8.1", "shelljs": "^0.8.4", "tcp-ping": "^0.1.1", "ws": "^7.4.6" @@ -11955,6 +11956,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -23461,6 +23470,14 @@ "glob": "^7.1.3" } }, + "rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "requires": { + "tslib": "^2.4.0" + } + }, "rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", diff --git a/package.json b/package.json index 00960807..ed5ced35 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "nodemailer": "^6.5.0", "notifme-sdk": "^1.10.0", "prettier": "^2.2.1", + "rrule": "^2.8.1", "shelljs": "^0.8.4", "tcp-ping": "^0.1.1", "ws": "^7.4.6" diff --git a/src/update.ts b/src/update.ts index a72dc7f7..3ed9e725 100644 --- a/src/update.ts +++ b/src/update.ts @@ -2,6 +2,7 @@ import dns from "dns"; import { isIP, isIPv6 } from "net"; import slugify from "@sindresorhus/slugify"; import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; import { mkdirp, readFile, writeFile } from "fs-extra"; import { load } from "js-yaml"; import { join } from "path"; @@ -17,6 +18,10 @@ import { curl } from "./helpers/request"; import { getOwnerRepo, getSecret } from "./helpers/secrets"; import { SiteHistory } from "./interfaces"; import { generateSummary } from "./summary"; +import { rrulestr } from 'rrule'; +import { Octokit } from "@octokit/rest"; + +dayjs.extend(utc); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -41,6 +46,45 @@ function getHumanReadableTimeDifference(startTime: Date): string { return result.join(", "); } +function getDurationMinutes(duration: string): number { + const unit = duration.slice(-1).toUpperCase(); + + try { + if (unit === "M") { + return parseInt(duration.slice(0, -1)); + } else if (unit === "H") { + return parseInt(duration.slice(0, -1)) * 60; + } else if (unit === "D") { + return parseInt(duration.slice(0, -1)) * 60 * 24; + } else { + return 0; + } + } catch (error) { + console.error("Error parsing duration", duration, error); + return 0; + } +} + +async function closeMaintenanceIssue(octokit: Octokit, owner: string, repo: string, incidentNumber: number) { + await octokit.issues.unlock({ + owner, + repo, + issue_number: incidentNumber, + }); + await octokit.issues.update({ + owner, + repo, + issue_number: incidentNumber, + state: "closed", + }); + await octokit.issues.lock({ + owner, + repo, + issue_number: incidentNumber, + }); + console.log("Closed maintenance completed event", incidentNumber); +} + export const update = async (shouldCommit = false) => { if (!(await shouldContinue())) return; await mkdirp("history"); @@ -77,38 +121,63 @@ export const update = async (shouldCommit = false) => { metadata[i.split(/:(.+)/)[0].trim()] = i.split(/:(.+)/)[1].trim(); }); } - if (metadata.start && metadata.end) { - let expectedDown: string[] = []; - let expectedDegraded: string[] = []; - if (metadata.expectedDown) - expectedDown = metadata.expectedDown - .split(",") - .map((i) => i.trim()) - .filter((i) => i.length); - if (metadata.expectedDown) - expectedDegraded = metadata.expectedDown - .split(",") - .map((i) => i.trim()) - .filter((i) => i.length); - if (dayjs(metadata.end).isBefore(dayjs())) { - await octokit.issues.unlock({ - owner, - repo, - issue_number: incident.number, - }); - await octokit.issues.update({ - owner, - repo, - issue_number: incident.number, - state: "closed", - }); - await octokit.issues.lock({ - owner, - repo, - issue_number: incident.number, + let expectedDown: string[] = []; + let expectedDegraded: string[] = []; + if (metadata.expectedDown) + expectedDown = metadata.expectedDown + .split(",") + .map((i) => i.trim()) + .filter((i) => i.length); + if (metadata.expectedDegraded) + expectedDegraded = metadata.expectedDegraded + .split(",") + .map((i) => i.trim()) + .filter((i) => i.length); + + if (metadata.rrule && metadata.duration && metadata.start) { + // The DTSTART and UNTIL params in RRules should be in UTC format without colons and dashes + if (!metadata.rrule.includes("DTSTART")) { + const cleanStartTime = dayjs(metadata.start) + .utc() + .format() + .replaceAll(":", "") + .replaceAll("-", ""); + metadata.rrule += `;DTSTART=${cleanStartTime}`; + } + if (!metadata.rrule.includes("UNTIL") && metadata.end) { + const cleanEndTime = dayjs(metadata.end) + .utc() + .format() + .replaceAll(":", "") + .replaceAll("-", ""); + metadata.rrule += `;UNTIL=${cleanEndTime}`; + } + const rule = rrulestr(metadata.rrule); + + if (metadata.end && dayjs(metadata.end).isBefore(dayjs())) { + await closeMaintenanceIssue(octokit, owner, repo, incident.number); + } else { + // Get all potentially valid occurrences of this rule (started up to `duration` ago) + // Limit to 1000 results to avoid any potential long-running operations + const durationMinutes = getDurationMinutes(metadata.duration); + const after = dayjs().subtract(durationMinutes, "minutes").toDate(); + rule.between(after, new Date(), true, (_, i) => i < 1000).forEach((startDate) => { + const endDate = dayjs(startDate).add(durationMinutes, "minutes").toDate(); + + const start = startDate.toISOString(); + const end = endDate.toISOString(); + + ongoingMaintenanceEvents.push({ + issueNumber: incident.number, + metadata: { start, end, expectedDegraded, expectedDown }, + }); }); - console.log("Closed maintenance completed event", incident.number); + } + + } else if (metadata.start && metadata.end) { + if (dayjs(metadata.end).isBefore(dayjs())) { + await closeMaintenanceIssue(octokit, owner, repo, incident.number); } else if (dayjs(metadata.start).isBefore(dayjs())) { ongoingMaintenanceEvents.push({ issueNumber: incident.number, @@ -519,3 +588,4 @@ generator: Upptime }; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +