Skip to content
85 linesCodeBlameRaw
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 */
5import type { NewRoutine, RoutineEvent, RoutineSchedule } from "@g1t/contracts";
6
7import { EVENT_KEYS } from "./suggest.ts";
8
9const EVERY = ["hour", "day", "weekday", "week"] as const;
10
11/** A schedule as given, checked; a message when it isn't one. */
12export 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. */
26export 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". */
46export 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
62export 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. */
65export 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}