| 1 | /** |
| 2 | * When routines run, and what a routine must have: pure, so it is tested |
| 3 | * on its own (routines.ts runs them). |
| 4 | */ |
| 5 | import type { NewRoutine, RoutineEvent, RoutineSchedule } from "@g1t/contracts"; |
| 6 | |
| 7 | import { EVENT_KEYS } from "./suggest.ts"; |
| 8 | |
| 9 | const EVERY = ["hour", "day", "weekday", "week"] as const; |
| 10 | |
| 11 | /** A schedule as given, checked; a message when it isn't one. */ |
| 12 | export function checkSchedule(input: unknown): { ok: true; value: RoutineSchedule } | { ok: false; message: string } { |
| 13 | if (!input || typeof input !== "object") return { ok: false, message: "Say when it runs." }; |
| 14 | const s = input as Record<string, unknown>; |
| 15 | const every = EVERY.find((e) => e === s.every); |
| 16 | if (!every) return { ok: false, message: "A routine runs every hour, day, weekday or week." }; |
| 17 | const int = (value: unknown, min: number, max: number) => (typeof value === "number" && Number.isInteger(value) && value >= min && value <= max ? value : null); |
| 18 | const minute = int(s.minute ?? 0, 0, 59); |
| 19 | const hour = int(s.hour ?? 9, 0, 23); |
| 20 | const weekday = int(s.weekday ?? 1, 0, 6); |
| 21 | if (minute === null || hour === null || weekday === null) return { ok: false, message: "The minute is 0 to 59, the hour 0 to 23 and the day 0 (Sunday) to 6." }; |
| 22 | return { ok: true, value: { every, minute, hour, weekday } }; |
| 23 | } |
| 24 | |
| 25 | /** When a routine next runs after `after`, in UTC. */ |
| 26 | export function nextRun(schedule: RoutineSchedule, after: Date): Date { |
| 27 | const next = new Date(after.getTime()); |
| 28 | next.setUTCSeconds(0, 0); |
| 29 | if (schedule.every === "hour") { |
| 30 | next.setUTCMinutes(schedule.minute); |
| 31 | if (next <= after) next.setUTCHours(next.getUTCHours() + 1); |
| 32 | return next; |
| 33 | } |
| 34 | next.setUTCHours(schedule.hour, schedule.minute); |
| 35 | if (next <= after) next.setUTCDate(next.getUTCDate() + 1); |
| 36 | for (let i = 0; i < 8; i++) { |
| 37 | const day = next.getUTCDay(); |
| 38 | const fits = schedule.every === "day" || (schedule.every === "weekday" ? day >= 1 && day <= 5 : day === schedule.weekday); |
| 39 | if (fits) return next; |
| 40 | next.setUTCDate(next.getUTCDate() + 1); |
| 41 | } |
| 42 | return next; |
| 43 | } |
| 44 | |
| 45 | /** A schedule in words: "Every weekday at 09:00 UTC". */ |
| 46 | export function describeSchedule(schedule: RoutineSchedule): string { |
| 47 | const pad = (n: number) => String(n).padStart(2, "0"); |
| 48 | const at = `${pad(schedule.hour)}:${pad(schedule.minute)} UTC`; |
| 49 | const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; |
| 50 | switch (schedule.every) { |
| 51 | case "hour": |
| 52 | return `Every hour at :${pad(schedule.minute)}`; |
| 53 | case "day": |
| 54 | return `Every day at ${at}`; |
| 55 | case "weekday": |
| 56 | return `Every weekday at ${at}`; |
| 57 | case "week": |
| 58 | return `Every ${days[schedule.weekday]} at ${at}`; |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | export type CheckedRoutine = NewRoutine & { schedule: RoutineSchedule | null; events: RoutineEvent[]; repos: string[] }; |
| 63 | |
| 64 | /** A routine as given, checked: a name, instructions, and a schedule, events, or both. */ |
| 65 | export function checkRoutine(input: NewRoutine): { ok: true; value: CheckedRoutine } | { ok: false; message: string } { |
| 66 | const name = typeof input?.name === "string" ? input.name.trim().slice(0, 80) : ""; |
| 67 | const instructions = typeof input?.instructions === "string" ? input.instructions.trim().slice(0, 8000) : ""; |
| 68 | if (!name) return { ok: false, message: "Give the routine a name." }; |
| 69 | if (instructions.length < 10) return { ok: false, message: "Say what the routine does, in a sentence or more." }; |
| 70 | if (typeof input.channel_id !== "string" || !input.channel_id) return { ok: false, message: "Choose the channel it posts in." }; |
| 71 | const given = Array.isArray(input.events) ? input.events : []; |
| 72 | if (given.some((e) => !EVENT_KEYS.includes(e))) return { ok: false, message: "That isn't something a routine can run on." }; |
| 73 | const events = [...new Set(given)]; |
| 74 | const repos = [...new Set((Array.isArray(input.repos) ? input.repos : []).filter((r): r is string => typeof r === "string").map((r) => r.trim().toLowerCase()).filter(Boolean))]; |
| 75 | if (repos.some((r) => !/^[a-z0-9._-]+\/[a-z0-9._-]+$/.test(r))) return { ok: false, message: "Name repositories as workspace/name." }; |
| 76 | if (repos.length > 20) return { ok: false, message: "A routine follows at most 20 repositories; leave it empty for all of them." }; |
| 77 | let schedule: RoutineSchedule | null = null; |
| 78 | if (input.schedule) { |
| 79 | const checked = checkSchedule(input.schedule); |
| 80 | if (!checked.ok) return checked; |
| 81 | schedule = checked.value; |
| 82 | } |
| 83 | if (!schedule && !events.length) return { ok: false, message: "A routine runs on a schedule, when something happens, or both." }; |
| 84 | return { ok: true, value: { ...input, name, instructions, schedule, events, repos, enabled: input.enabled !== false } }; |
| 85 | } |