From 3f27b1e731af8405b68c4e73718c0b0feb847f71 Mon Sep 17 00:00:00 2001 From: Darrien Rushing Date: Mon, 2 Nov 2026 15:56:28 -0600 Subject: [PATCH] feat(web): run Dependabot checks on a schedule set from the web UI The Dependabot page gets a Schedule panel: on or off, days of the week and a time of day (server time). The choice is the user's, so it is stored in planning.db (append-only migration 5). A pure calendar function (previousSlot, nextSlot, isCheckDue) decides when a check is due from the saved runs rather than an in-memory timer, so a restart catches up on a missed slot, turning the schedule on at 14:00 does not owe 08:00, a check started any other way counts for the slot, and a run that could not ask some owner is retried for a few hours. DependabotScheduler ticks every minute inside the web server, takes the same lease as the CLI and the Check now button, and backs off for 30 minutes after a failure, which the page shows. The production server starts it at boot (server.mjs posts to /api/dependabot/start once it is listening); the dev server starts it with the first page. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 12 + apps/web/server.mjs | 9 +- .../src/components/dependabot-schedule.tsx | 99 +++++ apps/web/src/lib/dependabot.ts | 19 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/api.dependabot.start.ts | 17 + apps/web/src/routes/dependabot.tsx | 14 +- apps/web/src/server/dependabot.server.ts | 38 +- apps/web/src/server/dependabot.ts | 47 ++- apps/web/test/dependabot.test.ts | 73 +++- docs/dependabot-watch.md | 7 + packages/core/src/app.ts | 18 + packages/core/src/dependabot/index.ts | 2 + packages/core/src/dependabot/schedule.ts | 75 ++++ packages/core/src/dependabot/scheduler.ts | 98 +++++ packages/core/src/planning/migrations.ts | 15 + packages/core/src/planning/store.ts | 31 ++ packages/core/src/planning/types.ts | 14 + .../core/test/dependabot-schedule.test.ts | 358 ++++++++++++++++++ 19 files changed, 945 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/dependabot-schedule.tsx create mode 100644 apps/web/src/routes/api.dependabot.start.ts create mode 100644 packages/core/src/dependabot/schedule.ts create mode 100644 packages/core/src/dependabot/scheduler.ts create mode 100644 packages/core/test/dependabot-schedule.test.ts diff --git a/README.md b/README.md index ebcc059..474ebc3 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,18 @@ until you press the button; if another check is running (the CLI) it says so and the page still shows the last saved check and explains what to set. The badge on the **Dependabot** link counts the PRs that need a person (ready, review first, needs work; not those waiting on checks) and refreshes every minute. +**Schedule.** The **Schedule** panel on the Dependabot page turns on automatic checks on the days and at the time +you choose (server time; the page shows the zone, and in Docker it is UTC unless you set `TZ` in `.env`). The schedule is stored in `planning.db` because it is your choice, +not derived data. It runs inside the web server, which starts it at boot (the production server calls +`POST /api/dependabot/start` once it is listening; the dev server starts it with the first page), looks at the clock +every minute and starts a check when the latest slot is newer than the last saved check. That is also the catch-up: +a restart after a missed slot runs it at once, but turning the schedule on at 14:00 does not owe you 08:00. A check +that could not ask some owner is retried every 30 minutes for six hours; one that threw (a rejected token, GitHub +down) waits 30 minutes and shows its error under the panel. A check started any other way (the button, the CLI) +counts for the slot, and they all share the lock, so a host cron job running `gitlib dependabot check` and the +schedule can coexist. The schedule only runs while the server is up, so on a laptop that sleeps use a host scheduler +instead (see the research note's scheduling table) and leave this off. + **Saved runs.** Each check is stored in `gitlib.db` (`dependabot_runs`, `dependabot_findings`; the newest 60 runs are kept) so the findings can be read offline and compared over time; they are derived from GitHub and can be fetched again. If one owner cannot be asked (network, rate limit), the last findings for its repositories are kept, marked diff --git a/apps/web/server.mjs b/apps/web/server.mjs index 9409c36..fddd7e1 100644 --- a/apps/web/server.mjs +++ b/apps/web/server.mjs @@ -85,6 +85,13 @@ const server = http.createServer(async (req, res) => { } }); -server.listen(port, host, () => console.log(`gitlib web listening on http://${host}:${port}`)); +server.listen(port, host, () => { + console.log(`gitlib web listening on http://${host}:${port}`); + // Starts the Dependabot schedule now instead of at the first page view, so a slot missed while the server was down is + // caught up and the next one is not missed because nobody opened the app. Best effort: the app works without it. + fetch(`http://127.0.0.1:${port}/api/dependabot/start`, { method: "POST" }).catch((error) => + console.warn("Could not start the Dependabot schedule:", error.message), + ); +}); for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => server.close(() => process.exit(0))); diff --git a/apps/web/src/components/dependabot-schedule.tsx b/apps/web/src/components/dependabot-schedule.tsx new file mode 100644 index 0000000..360bdae --- /dev/null +++ b/apps/web/src/components/dependabot-schedule.tsx @@ -0,0 +1,99 @@ +import { CalendarClock } from "lucide-react"; +import { useEffect, useState } from "react"; +import { RelativeTime } from "#/components/relative-time"; +import { Button } from "#/components/ui/button"; +import { Input } from "#/components/ui/input"; +import { WEEKDAY_ORDER, WEEKDAY_NAMES, describeSchedule } from "#/lib/dependabot"; +import type { DependabotView } from "#/server/dependabot"; + +type Schedule = DependabotView["schedule"]; + +/** Turns the automatic check on or off and picks its days and time. The server's clock decides when it fires. */ +export function SchedulePanel({ + schedule, + saving, + onSave, +}: { + schedule: Schedule; + saving: boolean; + onSave: (next: { enabled: boolean; days: number[]; time: string }) => void; +}) { + const [enabled, setEnabled] = useState(schedule.enabled); + const [days, setDays] = useState(schedule.days); + const [time, setTime] = useState(schedule.time); + + // What the server saved wins, so the form never shows a schedule that is not the one in force. + useEffect(() => { + setEnabled(schedule.enabled); + setDays(schedule.days); + setTime(schedule.time); + }, [schedule.enabled, schedule.days, schedule.time]); + + const changed = enabled !== schedule.enabled || time !== schedule.time || days.join() !== schedule.days.join(); + const toggleDay = (day: number) => setDays((current) => (current.includes(day) ? current.filter((value) => value !== day) : [...current, day].sort())); + + return ( +
+
+

+ Schedule +

+

+ {schedule.enabled ? `Checks run ${describeSchedule(schedule)}.` : "Off: gitlib only checks when you ask."} + {schedule.enabled && schedule.nextRunAt ? ( + <> + {" "} + Next check: . + + ) : null} +

+
+ +
{ + event.preventDefault(); + onSave({ enabled, days, time }); + }} + > + + +
+ Days + {WEEKDAY_ORDER.map((day) => ( + + ))} +
+ +
+ + Server time ({schedule.timeZone}). + +
+
+ + {schedule.lastError ? ( +

+ The last scheduled check failed{schedule.lastAttemptAt ? <> () : null}: {schedule.lastError} +

+ ) : null} + +

+ The schedule runs inside this web server, so it only runs while the server is up; after a restart it catches up on a check it missed. Checks + from the schedule, the Check now button and gitlib dependabot check (for example from a host cron job) share one lock, so they + never overlap, and any of them counts for the slot.{!schedule.active ? " The scheduler has not started in this server yet." : ""} +

+
+ ); +} diff --git a/apps/web/src/lib/dependabot.ts b/apps/web/src/lib/dependabot.ts index 30da7a2..4250d50 100644 --- a/apps/web/src/lib/dependabot.ts +++ b/apps/web/src/lib/dependabot.ts @@ -75,3 +75,22 @@ export function snoozeUntil(days: number, now: Date = new Date()): string { /** `meddlin/gitlib#41`, the way the CLI prints a pull request. */ export const prKey = (pr: { repo: string; number: number }): string => `${pr.repo}#${pr.number}`; + +export const WEEKDAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; +/** Monday first, the way a week is usually drawn. */ +export const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0] as const; + +/** A short phrase: "off", "every day at 08:00", "weekdays at 08:00", "Mon, Wed at 06:45". */ +export function describeSchedule(schedule: { enabled: boolean; days: readonly number[]; time: string }): string { + if (!schedule.enabled) return "off"; + const days = [...schedule.days].sort((a, b) => a - b); + const which = + days.length === 7 + ? "every day" + : days.join() === "1,2,3,4,5" + ? "weekdays" + : days.join() === "0,6" + ? "weekends" + : WEEKDAY_ORDER.filter((day) => days.includes(day)).map((day) => WEEKDAY_NAMES[day]).join(", "); + return `${which} at ${schedule.time}`; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 2d643cd..5e90aca 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as PlanningBoardRouteImport } from './routes/planning.board' import { Route as ProjectsIndexRouteImport } from './routes/projects.index' import { Route as ProjectsIdRouteImport } from './routes/projects.$id' import { Route as ReposSplatRouteImport } from './routes/repos.$' +import { Route as ApiDependabotStartRouteImport } from './routes/api.dependabot.start' import { Route as PlanningIssuesIdRouteImport } from './routes/planning.issues.$id' const IndexRoute = IndexRouteImport.update({ @@ -65,6 +66,11 @@ const ReposSplatRoute = ReposSplatRouteImport.update({ path: '/repos/$', getParentRoute: () => rootRouteImport, } as any) +const ApiDependabotStartRoute = ApiDependabotStartRouteImport.update({ + id: '/api/dependabot/start', + path: '/api/dependabot/start', + getParentRoute: () => rootRouteImport, +} as any) const PlanningIssuesIdRoute = PlanningIssuesIdRouteImport.update({ id: '/planning/issues/$id', path: '/planning/issues/$id', @@ -81,6 +87,7 @@ export interface FileRoutesByFullPath { '/repos/$': typeof ReposSplatRoute '/planning/': typeof PlanningIndexRoute '/projects/': typeof ProjectsIndexRoute + '/api/dependabot/start': typeof ApiDependabotStartRoute '/planning/issues/$id': typeof PlanningIssuesIdRoute } export interface FileRoutesByTo { @@ -93,6 +100,7 @@ export interface FileRoutesByTo { '/repos/$': typeof ReposSplatRoute '/planning': typeof PlanningIndexRoute '/projects': typeof ProjectsIndexRoute + '/api/dependabot/start': typeof ApiDependabotStartRoute '/planning/issues/$id': typeof PlanningIssuesIdRoute } export interface FileRoutesById { @@ -106,6 +114,7 @@ export interface FileRoutesById { '/repos/$': typeof ReposSplatRoute '/planning/': typeof PlanningIndexRoute '/projects/': typeof ProjectsIndexRoute + '/api/dependabot/start': typeof ApiDependabotStartRoute '/planning/issues/$id': typeof PlanningIssuesIdRoute } export interface FileRouteTypes { @@ -120,6 +129,7 @@ export interface FileRouteTypes { | '/repos/$' | '/planning/' | '/projects/' + | '/api/dependabot/start' | '/planning/issues/$id' fileRoutesByTo: FileRoutesByTo to: @@ -132,6 +142,7 @@ export interface FileRouteTypes { | '/repos/$' | '/planning' | '/projects' + | '/api/dependabot/start' | '/planning/issues/$id' id: | '__root__' @@ -144,6 +155,7 @@ export interface FileRouteTypes { | '/repos/$' | '/planning/' | '/projects/' + | '/api/dependabot/start' | '/planning/issues/$id' fileRoutesById: FileRoutesById } @@ -157,6 +169,7 @@ export interface RootRouteChildren { ReposSplatRoute: typeof ReposSplatRoute PlanningIndexRoute: typeof PlanningIndexRoute ProjectsIndexRoute: typeof ProjectsIndexRoute + ApiDependabotStartRoute: typeof ApiDependabotStartRoute PlanningIssuesIdRoute: typeof PlanningIssuesIdRoute } @@ -225,6 +238,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ReposSplatRouteImport parentRoute: typeof rootRouteImport } + '/api/dependabot/start': { + id: '/api/dependabot/start' + path: '/api/dependabot/start' + fullPath: '/api/dependabot/start' + preLoaderRoute: typeof ApiDependabotStartRouteImport + parentRoute: typeof rootRouteImport + } '/planning/issues/$id': { id: '/planning/issues/$id' path: '/planning/issues/$id' @@ -245,6 +265,7 @@ const rootRouteChildren: RootRouteChildren = { ReposSplatRoute: ReposSplatRoute, PlanningIndexRoute: PlanningIndexRoute, ProjectsIndexRoute: ProjectsIndexRoute, + ApiDependabotStartRoute: ApiDependabotStartRoute, PlanningIssuesIdRoute: PlanningIssuesIdRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/api.dependabot.start.ts b/apps/web/src/routes/api.dependabot.start.ts new file mode 100644 index 0000000..239af7b --- /dev/null +++ b/apps/web/src/routes/api.dependabot.start.ts @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router"; + +// Called once by `server.mjs` when the production server starts, so the schedule runs (and catches up on a slot missed +// while the server was down) without anyone opening a page. Starting an already-started scheduler does nothing. +// See api.chat.ts for why `server` is written out here and why the directive below is needed. +export const Route = createFileRoute("/api/dependabot/start")({ + // @ts-expect-error + server: { + handlers: { + POST: async (): Promise => { + const { ensureScheduler } = await import("../server/dependabot.server"); + ensureScheduler(); + return new Response(null, { status: 204 }); + }, + }, + }, +}); diff --git a/apps/web/src/routes/dependabot.tsx b/apps/web/src/routes/dependabot.tsx index cf8c570..28933ce 100644 --- a/apps/web/src/routes/dependabot.tsx +++ b/apps/web/src/routes/dependabot.tsx @@ -2,9 +2,10 @@ import { createFileRoute } from "@tanstack/react-router"; import { RefreshCw, TriangleAlert } from "lucide-react"; import { useEffect, useState } from "react"; import { DismissedRow, FindingCard, Prose } from "#/components/dependabot-finding"; +import { SchedulePanel } from "#/components/dependabot-schedule"; import { RelativeTime } from "#/components/relative-time"; import { Button } from "#/components/ui/button"; -import { BUCKET_HELP, BUCKET_LABELS, BUCKET_ORDER, BUCKET_TONES, TRIGGER_NAMES, holderName, prKey, snoozeUntil } from "#/lib/dependabot"; +import { BUCKET_HELP, BUCKET_LABELS, BUCKET_ORDER, BUCKET_TONES, TRIGGER_NAMES, describeSchedule, holderName, prKey, snoozeUntil } from "#/lib/dependabot"; import { notifyDependabotChanged } from "#/lib/use-dependabot-badge"; import { cn } from "#/lib/utils"; import { @@ -12,6 +13,7 @@ import { dismissDependabotPr, getDependabot, restoreDependabotPr, + saveDependabotSchedule, type DependabotActionResult, type DependabotView, } from "#/server/dependabot"; @@ -30,6 +32,7 @@ function Dependabot() { const [view, setView] = useState(loaded); const [checking, setChecking] = useState(false); const [busy, setBusy] = useState(false); + const [savingSchedule, setSavingSchedule] = useState(false); const [error, setError] = useState(null); useEffect(() => setView(loaded), [loaded]); @@ -69,6 +72,7 @@ function Dependabot() { const snooze = (pr: { repo: string; number: number }) => act(() => dismissDependabotPr({ data: { ...pr, until: snoozeUntil(7) } }), setBusy); const dismiss = (pr: { repo: string; number: number }) => act(() => dismissDependabotPr({ data: pr }), setBusy); const restore = (pr: { repo: string; number: number }) => act(() => restoreDependabotPr({ data: pr }), setBusy); + const saveSchedule = (next: { enabled: boolean; days: number[]; time: string }) => act(() => saveDependabotSchedule({ data: next }), setSavingSchedule); const { run, report } = view; const total = BUCKET_ORDER.reduce((sum, bucket) => sum + view.counts[bucket], 0); @@ -99,7 +103,11 @@ function Dependabot() { ) : ( "No check has run yet." - )} + )}{" "} + + Schedule: {describeSchedule(view.schedule)} + + .

{view.unavailableReason ? {view.unavailableReason} : null} @@ -156,6 +164,8 @@ function Dependabot() { ) : null} + + {report ? : null} ); diff --git a/apps/web/src/server/dependabot.server.ts b/apps/web/src/server/dependabot.server.ts index f71c984..6099ffe 100644 --- a/apps/web/src/server/dependabot.server.ts +++ b/apps/web/src/server/dependabot.server.ts @@ -1,7 +1,24 @@ -import { BUCKETS, isGitLibError, splitDismissed, type Bucket, type GitLibApp, type StoredReport } from "@gitlib/core"; +import { BUCKETS, isGitLibError, nextSlot, splitDismissed, type Bucket, type DependabotScheduler, type GitLibApp, type StoredReport } from "@gitlib/core"; import type { DependabotBadge, DependabotView } from "./dependabot"; import { getApp } from "./gitlib.server"; +interface SchedulerHolder { + scheduler?: DependabotScheduler; +} + +// Kept on globalThis so dev-server module reloads reuse one scheduler instead of starting another timer. +const holder = ((globalThis as { __gitlibDependabot?: SchedulerHolder }).__gitlibDependabot ??= {}); + +/** + * Starts the scheduler that runs checks on the user's schedule, once per server. The production server calls this at + * boot (see `routes/api.dependabot.start.ts`); in development it starts with the first page that asks for Dependabot data. + */ +export function ensureScheduler(app: GitLibApp = getApp()): DependabotScheduler { + if (!holder.scheduler) holder.scheduler = app.createDependabotScheduler(); + holder.scheduler.start(); + return holder.scheduler; +} + function withoutItems(report: StoredReport): Omit { const { items, ...rest } = report; void items; @@ -11,7 +28,7 @@ function withoutItems(report: StoredReport): Omit { const emptyCounts = (): Record => ({ ready: 0, "review-first": 0, "needs-work": 0, waiting: 0 }); /** The newest saved check with dismissed and snoozed pull requests set apart; reads gitlib's database, not GitHub. */ -export function buildView(now: Date = new Date(), app: GitLibApp = getApp()): DependabotView { +export function buildView(now: Date = new Date(), app: GitLibApp = getApp(), scheduler: DependabotScheduler | null = holder.scheduler ?? null): DependabotView { const run = app.latestDependabotRun(); const lease = app.dependabotStore.currentLease(now); const { active, dismissed } = splitDismissed(run?.report.items ?? [], app.listDependabotDismissals(), now); @@ -19,6 +36,9 @@ export function buildView(now: Date = new Date(), app: GitLibApp = getApp()): De const counts = emptyCounts(); for (const item of active) counts[item.bucket] += 1; + const schedule = app.getDependabotSchedule(); + const next = nextSlot(schedule, now); + return { canCheck: app.dependabot !== null, unavailableReason: @@ -31,6 +51,16 @@ export function buildView(now: Date = new Date(), app: GitLibApp = getApp()): De dismissed, report: run ? withoutItems(run.report) : null, counts, + schedule: { + enabled: schedule.enabled, + days: schedule.days, + time: schedule.time, + nextRunAt: next ? next.toISOString() : null, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + active: scheduler?.started ?? false, + lastAttemptAt: scheduler?.state.lastAttemptAt ?? null, + lastError: scheduler?.state.lastError ?? null, + }, }; } @@ -55,8 +85,8 @@ export async function runCheck(app: GitLibApp = getApp()): Promise void): string | null { +/** Runs a change the user asked for (a dismissal, a schedule); resolves with a message when it was refused. */ +export function attempt(change: () => void): string | null { try { change(); return null; diff --git a/apps/web/src/server/dependabot.ts b/apps/web/src/server/dependabot.ts index 34c95da..9f13c77 100644 --- a/apps/web/src/server/dependabot.ts +++ b/apps/web/src/server/dependabot.ts @@ -1,6 +1,6 @@ import { createServerFn } from "@tanstack/react-start"; import type { Bucket, DependabotDismissal, RunTrigger, StoredFinding, StoredReport } from "@gitlib/core"; -import { applyDismissal, buildBadge, buildView, runCheck } from "./dependabot.server"; +import { attempt, buildBadge, buildView, ensureScheduler, runCheck } from "./dependabot.server"; import { getApp } from "./gitlib.server"; /** A finding the user has put aside, with how. */ @@ -21,6 +21,22 @@ export interface DependabotView { /** What the check covered and what it could not; null before the first check. */ report: Omit | null; counts: Record; + /** When the web server checks on its own. */ + schedule: { + enabled: boolean; + /** Weekdays, 0 (Sunday) to 6. */ + days: number[]; + /** `HH:MM` in the server's time zone. */ + time: string; + /** The next slot (an ISO time), or null when the schedule is off. */ + nextRunAt: string | null; + timeZone: string; + /** The scheduler is running in this server. */ + active: boolean; + lastAttemptAt: string | null; + /** Why the last scheduled check failed, until one succeeds. */ + lastError: string | null; + }; } export interface DependabotBadge { @@ -36,10 +52,16 @@ export interface DependabotActionResult { view: DependabotView; } -export const getDependabot = createServerFn({ method: "GET" }).handler(async (): Promise => buildView()); +export const getDependabot = createServerFn({ method: "GET" }).handler(async (): Promise => { + ensureScheduler(); + return buildView(); +}); -/** Counts for the NavBar badge; a cheap read of the saved check. */ -export const getDependabotBadge = createServerFn({ method: "GET" }).handler(async (): Promise => buildBadge()); +/** Counts for the NavBar badge; a cheap read of the saved check. Every page asks, so it also starts the scheduler. */ +export const getDependabotBadge = createServerFn({ method: "GET" }).handler(async (): Promise => { + ensureScheduler(); + return buildBadge(); +}); /** Asks GitHub now, saving the result. Takes the same lock as the CLI and the schedule, so it can say "busy". */ export const checkDependabotNow = createServerFn({ method: "POST" }).handler(async (): Promise => { @@ -62,13 +84,26 @@ const validatePr = (input: PrInput & { until?: string | null }) => { export const dismissDependabotPr = createServerFn({ method: "POST" }) .validator(validatePr) .handler(async ({ data }): Promise => { - const error = applyDismissal(() => void getApp().dismissDependabotPr(data.repo, data.number, data.until ?? null)); + const error = attempt(() => void getApp().dismissDependabotPr(data.repo, data.number, data.until ?? null)); return { error, view: buildView() }; }); export const restoreDependabotPr = createServerFn({ method: "POST" }) .validator(validatePr) .handler(async ({ data }): Promise => { - const error = applyDismissal(() => getApp().restoreDependabotPr(data.repo, data.number)); + const error = attempt(() => getApp().restoreDependabotPr(data.repo, data.number)); + return { error, view: buildView() }; + }); + +/** Turns the schedule on or off, or changes its days and time. The server's clock and zone decide when it fires. */ +export const saveDependabotSchedule = createServerFn({ method: "POST" }) + .validator((input: { enabled: boolean; days: number[]; time: string }) => { + if (typeof input?.enabled !== "boolean" || !Array.isArray(input?.days) || typeof input?.time !== "string") { + throw new Error("Invalid schedule."); + } + return input; + }) + .handler(async ({ data }): Promise => { + const error = attempt(() => void getApp().saveDependabotSchedule(data)); return { error, view: buildView() }; }); diff --git a/apps/web/test/dependabot.test.ts b/apps/web/test/dependabot.test.ts index 2a435b2..21df645 100644 --- a/apps/web/test/dependabot.test.ts +++ b/apps/web/test/dependabot.test.ts @@ -12,8 +12,8 @@ import { type GitLibApp, type OwnerPulls, } from "@gitlib/core"; -import { BUCKET_ORDER, ciLabel, codeSegments, holderName, prKey, safeHref, snoozeUntil } from "../src/lib/dependabot"; -import { applyDismissal, buildBadge, buildView, runCheck } from "../src/server/dependabot.server"; +import { BUCKET_ORDER, ciLabel, codeSegments, describeSchedule, holderName, prKey, safeHref, snoozeUntil } from "../src/lib/dependabot"; +import { attempt, buildBadge, buildView, ensureScheduler, runCheck } from "../src/server/dependabot.server"; // --- lib/dependabot.ts ------------------------------------------------------------------------------------------- @@ -47,6 +47,15 @@ describe("display helpers", () => { ]); }); + it("describes a schedule in a few words", () => { + expect(describeSchedule({ enabled: false, days: [1, 2], time: "08:00" })).toBe("off"); + expect(describeSchedule({ enabled: true, days: [1, 2, 3, 4, 5], time: "08:00" })).toBe("weekdays at 08:00"); + expect(describeSchedule({ enabled: true, days: [0, 1, 2, 3, 4, 5, 6], time: "06:30" })).toBe("every day at 06:30"); + expect(describeSchedule({ enabled: true, days: [6, 0], time: "10:00" })).toBe("weekends at 10:00"); + // Monday first, even though Sunday is day 0. + expect(describeSchedule({ enabled: true, days: [0, 3, 1], time: "06:45" })).toBe("Mon, Wed, Sun at 06:45"); + }); + it("names who holds the lock, and a snooze end a number of days away", () => { expect(holderName("web:123")).toBe("the web page"); expect(holderName("schedule:9")).toBe("the schedule"); @@ -161,9 +170,9 @@ describe("buildView", () => { // The PR list is the page's to show; it is not repeated inside the report part. expect(view.report).not.toHaveProperty("items"); - expect(applyDismissal(() => void app.dismissDependabotPr("meddlin/gitlib", 2))).toBeNull(); + expect(attempt(() => void app.dismissDependabotPr("meddlin/gitlib", 2))).toBeNull(); const week = new Date(Date.now() + 7 * 86_400_000).toISOString(); - expect(applyDismissal(() => void app.dismissDependabotPr("meddlin/gitlib", 4, week))).toBeNull(); + expect(attempt(() => void app.dismissDependabotPr("meddlin/gitlib", 4, week))).toBeNull(); const after = buildView(new Date(), app); expect(after.items.map((item) => item.pr.number)).toEqual([1, 3]); // Set-aside rows keep the buckets' order. @@ -222,12 +231,58 @@ describe("runCheck", () => { }); }); -describe("applyDismissal", () => { +describe("attempt", () => { it("answers with the reason when the request is refused", () => { const app = makeApp(null); - expect(applyDismissal(() => void app.dismissDependabotPr("not a repo", 1))).toContain("is not a GitHub repository"); - expect(applyDismissal(() => void app.dismissDependabotPr("a/b", 1, "someday"))).toContain("is not a date"); - expect(applyDismissal(() => app.restoreDependabotPr("a/b", 1))).toBeNull(); - expect(() => applyDismissal(() => { throw new TypeError("bug"); })).toThrow("bug"); + expect(attempt(() => void app.dismissDependabotPr("not a repo", 1))).toContain("is not a GitHub repository"); + expect(attempt(() => void app.dismissDependabotPr("a/b", 1, "someday"))).toContain("is not a date"); + expect(attempt(() => app.restoreDependabotPr("a/b", 1))).toBeNull(); + expect(() => attempt(() => { throw new TypeError("bug"); })).toThrow("bug"); + }); +}); + +describe("the schedule on the page", () => { + it("is off until saved, and then says when the next check is", () => { + const app = makeApp(null); + expect(buildView(new Date(), app).schedule).toMatchObject({ enabled: false, days: [1, 2, 3, 4, 5], time: "08:00", nextRunAt: null, active: false, lastAttemptAt: null, lastError: null }); + + expect(attempt(() => void app.saveDependabotSchedule({ enabled: true, days: [0, 1, 2, 3, 4, 5, 6], time: "09:30" }))).toBeNull(); + const view = buildView(new Date("2026-11-02T08:00:00"), app); + expect(view.schedule).toMatchObject({ enabled: true, time: "09:30", timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }); + // The next slot is today at 09:30 on the server's wall clock. + expect(new Date(view.schedule.nextRunAt!).getTime()).toBe(new Date("2026-11-02T09:30:00").getTime()); + }); + + it("refuses a schedule the server could not follow, with the reason", () => { + const app = makeApp(null); + expect(attempt(() => void app.saveDependabotSchedule({ enabled: true, days: [], time: "08:00" }))).toBe("Pick at least one day, or turn the schedule off."); + expect(attempt(() => void app.saveDependabotSchedule({ enabled: true, days: [1], time: "8am" }))).toContain("08:00"); + expect(buildView(new Date(), app).schedule.enabled).toBe(false); + }); + + it("shows the scheduler's state: running, and what went wrong last", () => { + const app = makeApp(null); + const scheduler = app.createDependabotScheduler(); + expect(buildView(new Date(), app, scheduler).schedule.active).toBe(false); + scheduler.start(3_600_000); + try { + expect(buildView(new Date(), app, scheduler).schedule).toMatchObject({ active: true, lastError: null }); + } finally { + scheduler.stop(); + } + }); + + it("starts one scheduler per server, however often it is asked", () => { + const shared = globalThis as { __gitlibDependabot?: { scheduler?: { stop(): void; started: boolean } } }; + delete shared.__gitlibDependabot; + try { + const app = makeApp(null); + const first = ensureScheduler(app); + expect(first.started).toBe(true); + expect(ensureScheduler(app)).toBe(first); + } finally { + shared.__gitlibDependabot?.scheduler?.stop(); + delete shared.__gitlibDependabot; + } }); }); diff --git a/docs/dependabot-watch.md b/docs/dependabot-watch.md index d2502dd..743e6f5 100644 --- a/docs/dependabot-watch.md +++ b/docs/dependabot-watch.md @@ -26,6 +26,13 @@ The verdict never reads the PR body. Choices the plan left open: and dismiss, a "Check now" button on the same lock, a coverage footer (not checked, skipped, quota) and the NavBar badge (ready + review first + needs work, minus anything put aside). Text from GitHub (titles, check names) is drawn as plain text, links are limited to https pages on github.com, and nothing from a PR body is shown. +- Slice 4, the in-app schedule (chosen over a host-only schedule, which the Recommendation preferred): days and a + time of day in `planning.db` (migration 5), a pure calendar function (`previousSlot`/`nextSlot`/`isCheckDue`) and + a runner that ticks every minute inside the web server. It answers the weaknesses listed under "Where the existing + scheduler falls short": it starts at boot (the production server calls `POST /api/dependabot/start`), is + calendar-based, catches up from the stored runs instead of an in-memory timer, and takes the same lease as the CLI. + The weakness that remains is the one the note names for any in-process timer: it does not run while the server or + the machine is down. Host cron or a systemd timer calling `gitlib dependabot check` still works beside it. - Added to the plan: a lookup of which watched repositories the token can see, so a private repository the token cannot reach is reported as `NOT CHECKED` instead of looking like "no open PRs". - 11 (a) has **not** been run: the tests use fixtures shaped like the GraphQL response in "Verify with your own diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index d93d9c6..67180ea 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -5,11 +5,13 @@ import { loadConfig, type AppConfig, type Env } from "./config.js"; import { checkDependabot, DependabotBusyError, + DependabotScheduler, DependabotStore, DependabotUnavailableError, GitHubDependabotBackend, isValidOwner, isValidRepoName, + normalizeSchedule, resolveGitHubToken, type DependabotBackend, type DependabotReport, @@ -23,6 +25,7 @@ import { LocalIssueNotFoundError, PlanningStore, type DependabotDismissal, + type StoredDependabotSchedule, normalizeIssueBody, normalizeIssueTitle, normalizeProjectName, @@ -283,6 +286,21 @@ export class GitLibApp { return this.planning.listDependabotDismissals(); } + /** When the web server checks on its own; off until the user turns it on. */ + getDependabotSchedule(): StoredDependabotSchedule { + return this.planning.getDependabotSchedule(); + } + + /** Validates and saves the schedule. Throws `DependabotScheduleError` for one that cannot be followed. */ + saveDependabotSchedule(input: { enabled?: unknown; days?: unknown; time?: unknown }): StoredDependabotSchedule { + return this.planning.saveDependabotSchedule(normalizeSchedule(input), utcNow()); + } + + /** A scheduler over this application; the caller starts it (the web server does, at boot). */ + createDependabotScheduler(): DependabotScheduler { + return new DependabotScheduler(this); + } + listRepositories(options: FilterOptions & { refresh?: boolean } = {}): RepoRecord[] { const { refresh = true, ...filters } = options; const repositories = refresh ? this.refresh() : this.loadRepositories(); diff --git a/packages/core/src/dependabot/index.ts b/packages/core/src/dependabot/index.ts index f98b34a..e768a79 100644 --- a/packages/core/src/dependabot/index.ts +++ b/packages/core/src/dependabot/index.ts @@ -3,5 +3,7 @@ export * from "./classify.js"; export * from "./dismissals.js"; export * from "./github-backend.js"; export * from "./metadata.js"; +export * from "./schedule.js"; +export * from "./scheduler.js"; export * from "./store.js"; export * from "./types.js"; diff --git a/packages/core/src/dependabot/schedule.ts b/packages/core/src/dependabot/schedule.ts new file mode 100644 index 0000000..db50b74 --- /dev/null +++ b/packages/core/src/dependabot/schedule.ts @@ -0,0 +1,75 @@ +import { GitLibError } from "../errors.js"; +import type { DependabotSchedule, StoredDependabotSchedule } from "../planning/types.js"; +import type { StoredRun } from "./store.js"; + +/** The schedule the user typed is not one the server can follow. The message is safe to show. */ +export class DependabotScheduleError extends GitLibError {} + +const TIME = /^([01]\d|2[0-3]):([0-5]\d)$/; + +/** A run that could not ask some owner is tried again after this long... */ +export const RETRY_AFTER_MS = 30 * 60_000; +/** ...but only until this long after the slot, so a permanent failure does not retry all day. */ +export const RETRY_WINDOW_MS = 6 * 3_600_000; + +/** Checks what the page sent and returns it in canonical form (days sorted and unique). */ +export function normalizeSchedule(input: { enabled?: unknown; days?: unknown; time?: unknown }): DependabotSchedule { + if (typeof input.enabled !== "boolean") throw new DependabotScheduleError("Say whether the schedule is on or off."); + if (!Array.isArray(input.days) || !input.days.every((day) => Number.isInteger(day) && day >= 0 && day <= 6)) { + throw new DependabotScheduleError("Days must be weekdays from Sunday to Saturday."); + } + if (typeof input.time !== "string" || !TIME.test(input.time)) { + throw new DependabotScheduleError("The time must look like 08:00 (24-hour)."); + } + const days = [...new Set(input.days as number[])].sort((a, b) => a - b); + if (input.enabled && days.length === 0) throw new DependabotScheduleError("Pick at least one day, or turn the schedule off."); + return { enabled: input.enabled, days, time: input.time }; +} + +/** `time` on the calendar day of `day`, in the process's time zone (which handles a day with a clock change). */ +function slotOn(day: Date, time: string): Date { + const [hours, minutes] = time.split(":").map(Number) as [number, number]; + return new Date(day.getFullYear(), day.getMonth(), day.getDate(), hours, minutes, 0, 0); +} + +const dayOffset = (from: Date, offset: number): Date => new Date(from.getFullYear(), from.getMonth(), from.getDate() + offset, 12); + +/** The latest slot at or before `now`, or null when the schedule is off or has no days. */ +export function previousSlot(schedule: DependabotSchedule, now: Date): Date | null { + if (!schedule.enabled || schedule.days.length === 0) return null; + for (let offset = 0; offset >= -7; offset -= 1) { + const day = dayOffset(now, offset); + if (!schedule.days.includes(day.getDay())) continue; + const slot = slotOn(day, schedule.time); + if (slot.getTime() <= now.getTime()) return slot; + } + return null; +} + +/** The earliest slot after `after`, or null when the schedule is off or has no days. */ +export function nextSlot(schedule: DependabotSchedule, after: Date): Date | null { + if (!schedule.enabled || schedule.days.length === 0) return null; + for (let offset = 0; offset <= 7; offset += 1) { + const day = dayOffset(after, offset); + if (!schedule.days.includes(day.getDay())) continue; + const slot = slotOn(day, schedule.time); + if (slot.getTime() > after.getTime()) return slot; + } + return null; +} + +/** + * Whether a check should start now. The latest slot has to be newer than both the last run (any run, however it was + * started: a fresh check satisfies the slot) and the moment the schedule was saved (so turning it on at 14:00 does not + * "catch up" 08:00). A run that could not ask some owner is retried after a while, within a few hours of the slot. + * This is also what makes a restart catch up on a slot missed while the server was down. + */ +export function isCheckDue(schedule: StoredDependabotSchedule, lastRun: StoredRun | null, now: Date): boolean { + const slot = previousSlot(schedule, now); + if (!slot) return false; + const anchor = Math.max(schedule.updatedAt ? Date.parse(schedule.updatedAt) : 0, lastRun ? Date.parse(lastRun.startedAt) : 0); + if (anchor < slot.getTime()) return true; + + const failed = lastRun !== null && lastRun.report.errors.length > 0; + return failed && now.getTime() - Date.parse(lastRun.finishedAt) >= RETRY_AFTER_MS && now.getTime() - slot.getTime() < RETRY_WINDOW_MS; +} diff --git a/packages/core/src/dependabot/scheduler.ts b/packages/core/src/dependabot/scheduler.ts new file mode 100644 index 0000000..a6bef9b --- /dev/null +++ b/packages/core/src/dependabot/scheduler.ts @@ -0,0 +1,98 @@ +import { isGitLibError } from "../errors.js"; +import { DependabotBusyError, type StoredRun } from "./store.js"; +import { isCheckDue } from "./schedule.js"; +import type { StoredDependabotSchedule } from "../planning/types.js"; +import type { DependabotBackend } from "./types.js"; + +/** After a check threw (a rejected token, GitHub down), leave GitHub alone this long before trying again. */ +export const FAILURE_BACKOFF_MS = 30 * 60_000; +/** How often a started scheduler looks at the clock. */ +export const TICK_MS = 60_000; + +/** What the scheduler needs from the application; `GitLibApp` provides all of it. */ +export interface SchedulerHost { + readonly dependabot: DependabotBackend | null; + getDependabotSchedule(): StoredDependabotSchedule; + latestDependabotRun(): StoredRun | null; + runDependabotCheck(options: { trigger: "schedule"; now: Date }): Promise; +} + +export type TickOutcome = + /** The schedule is off, or no check is due. */ + | "idle" + /** There is no GitHub token, so nothing can run. */ + | "no-token" + /** A check is due but one failed a moment ago. */ + | "backoff" + /** A check is due but another process holds the lock; it will do (or has done) the work. */ + | "busy" + | "ran" + | "failed"; + +export interface SchedulerState { + /** When the scheduler last tried to run a check (successfully or not). */ + lastAttemptAt: string | null; + /** Why the last scheduled attempt failed, until one succeeds. */ + lastError: string | null; +} + +/** + * Starts a check when the user's schedule says one is due. It keeps no record of what it has done beyond the failure + * back-off: whether a check is due is read from the saved runs, so a restart or a second process (the CLI, a host + * cron job) cannot make it run twice or skip a slot. + */ +export class DependabotScheduler { + private timer: ReturnType | undefined; + private retryAt = 0; + private running = false; + private readonly status: SchedulerState = { lastAttemptAt: null, lastError: null }; + + constructor(private readonly host: SchedulerHost) {} + + get state(): SchedulerState { + return { ...this.status }; + } + + get started(): boolean { + return this.timer !== undefined; + } + + /** Looks at the clock now and then every minute. The timer never keeps the process alive. */ + start(intervalMs = TICK_MS): void { + if (this.timer) return; + const tick = () => void this.tick(new Date()).catch((error: unknown) => console.warn("Scheduled Dependabot check failed:", error)); + this.timer = setInterval(tick, intervalMs); + this.timer.unref(); + tick(); + } + + stop(): void { + clearInterval(this.timer); + this.timer = undefined; + } + + /** One look at the clock. Never throws for an expected failure; it is recorded in `state` instead. */ + async tick(now: Date): Promise { + if (this.running) return "busy"; + if (!this.host.dependabot) return "no-token"; + if (!isCheckDue(this.host.getDependabotSchedule(), this.host.latestDependabotRun(), now)) return "idle"; + if (now.getTime() < this.retryAt) return "backoff"; + + this.running = true; + this.status.lastAttemptAt = now.toISOString(); + try { + await this.host.runDependabotCheck({ trigger: "schedule", now }); + this.status.lastError = null; + return "ran"; + } catch (error) { + if (error instanceof DependabotBusyError) return "busy"; + // A bug is rethrown; anything the user can act on is a GitLibError with a message fit to show. + if (!isGitLibError(error)) throw error; + this.status.lastError = error.message; + this.retryAt = now.getTime() + FAILURE_BACKOFF_MS; + return "failed"; + } finally { + this.running = false; + } + } +} diff --git a/packages/core/src/planning/migrations.ts b/packages/core/src/planning/migrations.ts index 7ff54be..04d26bf 100644 --- a/packages/core/src/planning/migrations.ts +++ b/packages/core/src/planning/migrations.ts @@ -82,6 +82,21 @@ export const MIGRATIONS: readonly Migration[] = [ ); `, }, + { + version: 5, + name: "dependabot schedule", + sql: ` + -- When the web server should check Dependabot on its own. One row, written by the user from the page. + -- days is a comma-separated list of weekdays (0 = Sunday); time is HH:MM in the server's time zone. + CREATE TABLE dependabot_schedule ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL, + days TEXT NOT NULL, + time TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `, + }, ]; export const LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]!.version; diff --git a/packages/core/src/planning/store.ts b/packages/core/src/planning/store.ts index 67ffd9b..028eb2b 100644 --- a/packages/core/src/planning/store.ts +++ b/packages/core/src/planning/store.ts @@ -5,6 +5,8 @@ import { GitLibError } from "../errors.js"; import { MIGRATIONS, migrate, schemaVersion, type Migration } from "./migrations.js"; import type { DependabotDismissal, + DependabotSchedule, + StoredDependabotSchedule, IssueStatus, LocalIssue, LocalIssueChanges, @@ -21,6 +23,9 @@ type DatabaseSync = DatabaseSyncType; /** How long a connection waits on another process's write lock before giving up. */ const BUSY_TIMEOUT_MS = 5_000; +/** Off, on weekdays at 08:00, until the user chooses otherwise. */ +export const DEFAULT_DEPENDABOT_SCHEDULE: DependabotSchedule = { enabled: false, days: [1, 2, 3, 4, 5], time: "08:00" }; + export class LocalIssueNotFoundError extends GitLibError {} export class ProjectNotFoundError extends GitLibError {} export class ProjectNameTakenError extends GitLibError {} @@ -324,6 +329,32 @@ export class PlanningStore { ); } + /** The saved schedule, or the default (off) until the user saves one. */ + getDependabotSchedule(): StoredDependabotSchedule { + return this.withConnection((db) => { + const row = db.prepare("SELECT enabled, days, time, updated_at FROM dependabot_schedule WHERE id = 1").get() as unknown as + | { enabled: number; days: string; time: string; updated_at: string } + | undefined; + if (!row) return { ...DEFAULT_DEPENDABOT_SCHEDULE, days: [...DEFAULT_DEPENDABOT_SCHEDULE.days], updatedAt: null }; + const days = row.days.split(",").flatMap((day) => (/^[0-6]$/.test(day) ? [Number(day)] : [])); + return { enabled: Boolean(row.enabled), days, time: row.time, updatedAt: row.updated_at }; + }); + } + + /** Saves the schedule. The caller has validated it (see `normalizeSchedule`). */ + saveDependabotSchedule(schedule: DependabotSchedule, now: string): StoredDependabotSchedule { + this.transaction( + (db) => + void db + .prepare( + `INSERT INTO dependabot_schedule (id, enabled, days, time, updated_at) VALUES (1, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled, days = excluded.days, time = excluded.time, updated_at = excluded.updated_at`, + ) + .run(schedule.enabled ? 1 : 0, schedule.days.join(","), schedule.time, now), + ); + return { ...schedule, days: [...schedule.days], updatedAt: now }; + } + /** * Writes a consistent snapshot of the database to `destination` (SQLite `VACUUM INTO`), which is * safe to run while the web app is using the database. Never overwrites an existing file. diff --git a/packages/core/src/planning/types.ts b/packages/core/src/planning/types.ts index 2107ab9..6ea637b 100644 --- a/packages/core/src/planning/types.ts +++ b/packages/core/src/planning/types.ts @@ -79,3 +79,17 @@ export interface DependabotDismissal { snoozedUntil: string | null; createdAt: string; } + +/** When the web server checks Dependabot on its own: on these weekdays, at this time of day. */ +export interface DependabotSchedule { + enabled: boolean; + /** Weekdays it runs on, 0 (Sunday) to 6, sorted and without repeats. */ + days: number[]; + /** `HH:MM`, 24-hour, in the server's time zone. */ + time: string; +} + +export interface StoredDependabotSchedule extends DependabotSchedule { + /** When the user last saved it; null while it is still the default. A slot before this is never "missed". */ + updatedAt: string | null; +} diff --git a/packages/core/test/dependabot-schedule.test.ts b/packages/core/test/dependabot-schedule.test.ts new file mode 100644 index 0000000..3de989a --- /dev/null +++ b/packages/core/test/dependabot-schedule.test.ts @@ -0,0 +1,358 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + DependabotAuthError, + DependabotBusyError, + DependabotScheduleError, + DependabotScheduler, + DEFAULT_DEPENDABOT_SCHEDULE, + FAILURE_BACKOFF_MS, + LATEST_SCHEMA_VERSION, + RETRY_AFTER_MS, + RETRY_WINDOW_MS, + createApplication, + isCheckDue, + isGitLibError, + nextSlot, + normalizeSchedule, + previousSlot, + type DependabotSchedule, + type SchedulerHost, + type StoredDependabotSchedule, + type StoredRun, +} from "../src/index.js"; +import { FakeBackend, pr } from "./dependabot-fixtures.js"; +import { Fixture } from "./fixture.js"; + +let fx: Fixture; +let originalTz: string | undefined; +beforeAll(() => { + originalTz = process.env["TZ"]; +}); +afterAll(() => { + if (originalTz === undefined) delete process.env["TZ"]; + else process.env["TZ"] = originalTz; +}); +beforeEach(() => { + // Slots are wall-clock times in the server's zone; pin it so the dates below mean what they say. + process.env["TZ"] = "UTC"; + fx = new Fixture(); +}); +afterEach(() => fx.cleanup()); + +/** 2026-11-02 is a Monday. */ +const at = (day: number, time: string) => new Date(`2026-11-${String(day).padStart(2, "0")}T${time}:00Z`); +const WEEKDAYS: DependabotSchedule = { enabled: true, days: [1, 2, 3, 4, 5], time: "08:00" }; + +describe("normalizeSchedule", () => { + it("returns the days sorted and without repeats", () => { + expect(normalizeSchedule({ enabled: true, days: [5, 1, 1, 3], time: "07:30" })).toEqual({ enabled: true, days: [1, 3, 5], time: "07:30" }); + }); + + it("lets a schedule that is off have no days", () => { + expect(normalizeSchedule({ enabled: false, days: [], time: "00:00" })).toEqual({ enabled: false, days: [], time: "00:00" }); + }); + + it("refuses what the server could not follow, with a message for the page", () => { + const refused = (input: object, message: string | RegExp) => { + expect(() => normalizeSchedule(input)).toThrow(DependabotScheduleError); + expect(() => normalizeSchedule(input)).toThrow(message); + }; + refused({ enabled: "yes", days: [1], time: "08:00" }, "on or off"); + refused({ enabled: true, days: "1,2", time: "08:00" }, "weekdays"); + refused({ enabled: true, days: [7], time: "08:00" }, "weekdays"); + refused({ enabled: true, days: [1.5], time: "08:00" }, "weekdays"); + refused({ enabled: true, days: [-1], time: "08:00" }, "weekdays"); + refused({ enabled: true, days: [1], time: "8:00" }, "08:00"); + refused({ enabled: true, days: [1], time: "24:00" }, "08:00"); + refused({ enabled: true, days: [1], time: "08:60" }, "08:00"); + refused({ enabled: true, days: [1] }, "08:00"); + refused({ enabled: true, days: [], time: "08:00" }, "at least one day"); + expect(isGitLibError(new DependabotScheduleError("x"))).toBe(true); + }); +}); + +describe("slots", () => { + it("finds the latest slot at or before now and the earliest after it", () => { + // Monday 07:00: this morning's slot has not come yet, so the latest one was Friday's. + expect(previousSlot(WEEKDAYS, at(2, "07:00"))).toEqual(new Date("2026-10-30T08:00:00Z")); + expect(nextSlot(WEEKDAYS, at(2, "07:00"))).toEqual(at(2, "08:00")); + // Exactly on a slot: it is the previous one, and the next is a day later. + expect(previousSlot(WEEKDAYS, at(2, "08:00"))).toEqual(at(2, "08:00")); + expect(nextSlot(WEEKDAYS, at(2, "08:00"))).toEqual(at(3, "08:00")); + expect(previousSlot(WEEKDAYS, at(2, "08:01"))).toEqual(at(2, "08:00")); + }); + + it("skips days that are not scheduled, across a weekend", () => { + // Sunday 8 November at noon. + expect(previousSlot(WEEKDAYS, at(8, "12:00"))).toEqual(at(6, "08:00")); + expect(nextSlot(WEEKDAYS, at(8, "12:00"))).toEqual(at(9, "08:00")); + expect(nextSlot(WEEKDAYS, at(6, "08:00"))).toEqual(at(9, "08:00")); + }); + + it("reaches a whole week back or forward for a single weekly day", () => { + const wednesdays: DependabotSchedule = { enabled: true, days: [3], time: "09:15" }; + expect(previousSlot(wednesdays, at(4, "09:14"))).toEqual(new Date("2026-10-28T09:15:00Z")); + expect(nextSlot(wednesdays, at(4, "09:15"))).toEqual(at(11, "09:15")); + }); + + it("has no slots when the schedule is off or has no days", () => { + expect(previousSlot({ ...WEEKDAYS, enabled: false }, at(2, "12:00"))).toBeNull(); + expect(nextSlot({ ...WEEKDAYS, enabled: false }, at(2, "12:00"))).toBeNull(); + expect(previousSlot({ ...WEEKDAYS, days: [] }, at(2, "12:00"))).toBeNull(); + expect(nextSlot({ ...WEEKDAYS, days: [] }, at(2, "12:00"))).toBeNull(); + }); + + it("means the server's wall clock, including a zone that is not a whole hour from UTC", () => { + process.env["TZ"] = "Asia/Kolkata"; // UTC+05:30, so 08:00 there is 02:30 UTC. + expect(nextSlot(WEEKDAYS, at(2, "00:00"))).toEqual(at(2, "02:30")); + expect(previousSlot(WEEKDAYS, at(2, "03:00"))).toEqual(at(2, "02:30")); + }); + + it("stays ordered across a clock change", () => { + process.env["TZ"] = "America/New_York"; // 2026-03-08: 02:30 does not exist. + const daily: DependabotSchedule = { enabled: true, days: [0, 1, 2, 3, 4, 5, 6], time: "02:30" }; + let slot = nextSlot(daily, new Date("2026-03-06T12:00:00Z"))!; + for (let i = 0; i < 5; i += 1) { + const following = nextSlot(daily, slot)!; + expect(following.getTime()).toBeGreaterThan(slot.getTime()); + expect(following.getTime() - slot.getTime()).toBeLessThan(26 * 3_600_000); + slot = following; + } + }); +}); + +describe("isCheckDue", () => { + const saved = (overrides: Partial = {}): StoredDependabotSchedule => ({ ...WEEKDAYS, updatedAt: "2026-10-01T00:00:00.000Z", ...overrides }); + const runAt = (startedAt: Date, options: { errors?: boolean; finishedAfterMs?: number } = {}): StoredRun => + ({ + id: 1, + trigger: "schedule", + startedAt: startedAt.toISOString(), + finishedAt: new Date(startedAt.getTime() + (options.finishedAfterMs ?? 5_000)).toISOString(), + report: { errors: options.errors ? [{ owner: "x", message: "down" }] : [] }, + }) as unknown as StoredRun; + + it("is due when the latest slot came after the last run", () => { + expect(isCheckDue(saved(), null, at(2, "08:01"))).toBe(true); + expect(isCheckDue(saved(), runAt(at(2, "08:00")), at(3, "08:01"))).toBe(true); + // Mid-morning, still the same slot. + expect(isCheckDue(saved(), runAt(at(2, "08:00")), at(2, "11:00"))).toBe(false); + }); + + it("is not due before the first slot, or when the schedule is off", () => { + expect(isCheckDue(saved({ enabled: false }), null, at(2, "12:00"))).toBe(false); + expect(isCheckDue(saved({ days: [] }), null, at(2, "12:00"))).toBe(false); + }); + + it("counts a check started any other way as satisfying the slot", () => { + // Someone pressed Check now at 08:30; the 08:00 slot needs nothing more. + expect(isCheckDue(saved(), runAt(at(2, "08:30")), at(2, "09:00"))).toBe(false); + }); + + it("catches up on a slot missed while the server was down", () => { + // Last ran Monday 08:00; the server was off through Tuesday's slot and is back on Tuesday afternoon. + expect(isCheckDue(saved(), runAt(at(2, "08:00")), at(3, "15:00"))).toBe(true); + }); + + it("does not catch up on a slot from before the schedule was saved", () => { + // Turned on Monday at 14:00: this morning's 08:00 is not owed. + expect(isCheckDue(saved({ updatedAt: at(2, "14:00").toISOString() }), null, at(2, "14:01"))).toBe(false); + expect(isCheckDue(saved({ updatedAt: at(2, "14:00").toISOString() }), null, at(3, "08:00"))).toBe(true); + }); + + it("retries a check that could not ask some owner, after a while and not for ever", () => { + const failed = runAt(at(2, "08:00"), { errors: true }); + const finished = Date.parse(failed.finishedAt); + expect(isCheckDue(saved(), failed, new Date(finished + RETRY_AFTER_MS - 1_000))).toBe(false); + expect(isCheckDue(saved(), failed, new Date(finished + RETRY_AFTER_MS))).toBe(true); + // Hours later with the same permanent failure: leave it until the next slot. + expect(isCheckDue(saved(), runAt(at(2, "08:00"), { errors: true }), new Date(at(2, "08:00").getTime() + RETRY_WINDOW_MS))).toBe(false); + // A clean run is never retried. + expect(isCheckDue(saved(), runAt(at(2, "08:00")), new Date(finished + RETRY_AFTER_MS))).toBe(false); + }); +}); + +describe("DependabotScheduler", () => { + class Host implements SchedulerHost { + dependabot: SchedulerHost["dependabot"] = new FakeBackend({}); + schedule: StoredDependabotSchedule = { ...WEEKDAYS, updatedAt: "2026-10-01T00:00:00.000Z" }; + last: StoredRun | null = null; + runs: Date[] = []; + failWith: unknown = null; + gate: Promise | null = null; + + getDependabotSchedule() { + return this.schedule; + } + latestDependabotRun() { + return this.last; + } + async runDependabotCheck(options: { trigger: "schedule"; now: Date }): Promise { + this.runs.push(options.now); + await this.gate; + if (this.failWith) throw this.failWith; + this.last = { + id: this.runs.length, + trigger: options.trigger, + startedAt: options.now.toISOString(), + finishedAt: options.now.toISOString(), + report: { errors: [] }, + } as unknown as StoredRun; + return this.last; + } + } + + it("does nothing while the schedule is off or nothing is due", async () => { + const host = new Host(); + const scheduler = new DependabotScheduler(host); + host.schedule = { ...host.schedule, enabled: false }; + expect(await scheduler.tick(at(2, "09:00"))).toBe("idle"); + host.schedule = { ...host.schedule, enabled: true }; + expect(await scheduler.tick(at(2, "07:00"))).toBe("ran"); // Friday's slot was never run. + expect(await scheduler.tick(at(2, "07:30"))).toBe("idle"); + expect(host.runs).toHaveLength(1); + }); + + it("runs a due check once and then leaves it until the next slot", async () => { + const host = new Host(); + const scheduler = new DependabotScheduler(host); + + expect(await scheduler.tick(at(2, "08:00"))).toBe("ran"); + expect(host.runs).toEqual([at(2, "08:00")]); + expect(await scheduler.tick(at(2, "08:01"))).toBe("idle"); + expect(await scheduler.tick(at(2, "20:00"))).toBe("idle"); + expect(await scheduler.tick(at(3, "08:00"))).toBe("ran"); + expect(scheduler.state).toEqual({ lastAttemptAt: at(3, "08:00").toISOString(), lastError: null }); + }); + + it("does not run without a GitHub token", async () => { + const host = new Host(); + host.dependabot = null; + expect(await new DependabotScheduler(host).tick(at(2, "09:00"))).toBe("no-token"); + expect(host.runs).toEqual([]); + }); + + it("records a failure, backs off, and tries again afterwards", async () => { + const host = new Host(); + host.failWith = new DependabotAuthError("GitHub rejected the token (401)."); + const scheduler = new DependabotScheduler(host); + + expect(await scheduler.tick(at(2, "08:00"))).toBe("failed"); + expect(scheduler.state.lastError).toBe("GitHub rejected the token (401)."); + // Every minute afterwards the check is still due, but GitHub is left alone. + expect(await scheduler.tick(at(2, "08:01"))).toBe("backoff"); + expect(await scheduler.tick(new Date(at(2, "08:00").getTime() + FAILURE_BACKOFF_MS - 1_000))).toBe("backoff"); + expect(host.runs).toHaveLength(1); + + host.failWith = null; + expect(await scheduler.tick(new Date(at(2, "08:00").getTime() + FAILURE_BACKOFF_MS))).toBe("ran"); + expect(scheduler.state.lastError).toBeNull(); + }); + + it("stays quiet when another process is already checking", async () => { + const host = new Host(); + host.failWith = new DependabotBusyError("A Dependabot check is already running (started now by cli)."); + const scheduler = new DependabotScheduler(host); + expect(await scheduler.tick(at(2, "08:00"))).toBe("busy"); + expect(scheduler.state.lastError).toBeNull(); + // No back-off either: the other process may well have finished by the next minute. + host.failWith = null; + expect(await scheduler.tick(at(2, "08:01"))).toBe("ran"); + }); + + it("lets a bug through rather than hiding it", async () => { + const host = new Host(); + host.failWith = new TypeError("a bug"); + await expect(new DependabotScheduler(host).tick(at(2, "08:00"))).rejects.toThrow("a bug"); + }); + + it("never starts a second check while one is in flight", async () => { + const host = new Host(); + let release!: () => void; + host.gate = new Promise((resolve) => (release = resolve)); + const scheduler = new DependabotScheduler(host); + + const first = scheduler.tick(at(2, "08:00")); + expect(await scheduler.tick(at(2, "08:00"))).toBe("busy"); + release(); + expect(await first).toBe("ran"); + expect(host.runs).toHaveLength(1); + }); + + describe("on a timer", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("looks at the clock at once and then every interval, until stopped", async () => { + vi.setSystemTime(at(2, "08:00")); + const host = new Host(); + const scheduler = new DependabotScheduler(host); + expect(scheduler.started).toBe(false); + + scheduler.start(60_000); + scheduler.start(60_000); // a second start changes nothing + expect(scheduler.started).toBe(true); + await vi.advanceTimersByTimeAsync(0); + expect(host.runs).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(24 * 3_600_000); + expect(host.runs).toHaveLength(2); // Tuesday's slot, and no more + + scheduler.stop(); + expect(scheduler.started).toBe(false); + await vi.advanceTimersByTimeAsync(24 * 3_600_000); + expect(host.runs).toHaveLength(2); + }); + }); +}); + +describe("the saved schedule", () => { + it("is off until the user saves one, then survives a new application over the same data", () => { + fx.writeConfig(); + const app = createApplication(fx.env()); + expect(app.getDependabotSchedule()).toEqual({ ...DEFAULT_DEPENDABOT_SCHEDULE, updatedAt: null }); + expect(DEFAULT_DEPENDABOT_SCHEDULE.enabled).toBe(false); + + const saved = app.saveDependabotSchedule({ enabled: true, days: [2, 4], time: "06:45" }); + expect(saved).toMatchObject({ enabled: true, days: [2, 4], time: "06:45" }); + expect(saved.updatedAt).toMatch(/^\d{4}-\d\d-\d\dT/); + + expect(createApplication(fx.env()).getDependabotSchedule()).toEqual(saved); + app.saveDependabotSchedule({ enabled: false, days: [], time: "06:45" }); + expect(createApplication(fx.env()).getDependabotSchedule()).toMatchObject({ enabled: false, days: [] }); + }); + + it("is refused when invalid, and nothing is saved", () => { + fx.writeConfig(); + const app = createApplication(fx.env()); + expect(() => app.saveDependabotSchedule({ enabled: true, days: [], time: "06:45" })).toThrow(DependabotScheduleError); + expect(app.getDependabotSchedule().updatedAt).toBeNull(); + }); + + it("arrives through migration 5", () => { + expect(LATEST_SCHEMA_VERSION).toBeGreaterThanOrEqual(5); + }); +}); + +describe("a scheduler over a real application", () => { + it("runs a due check as the schedule, saves it, and then has nothing left to do", async () => { + fx.writeConfig(); + const repoPath = fx.initRealGitRepo("gitlib"); + fx.git(repoPath, "remote", "add", "origin", "https://github.com/meddlin/gitlib.git"); + const app = createApplication(fx.env(), undefined, null, null, new FakeBackend({ meddlin: { prs: [pr({ number: 1 })] } })); + app.refresh(); + app.saveRepositoryFavorite("gitlib", true); + app.saveDependabotSchedule({ enabled: true, days: [0, 1, 2, 3, 4, 5, 6], time: "00:00" }); + const scheduler = app.createDependabotScheduler(); + + // Saved just now, so today's slot is not owed; tomorrow's is. + expect(await scheduler.tick(new Date())).toBe("idle"); + const tomorrow = new Date(Date.now() + 25 * 3_600_000); + expect(await scheduler.tick(tomorrow)).toBe("ran"); + + expect(app.latestDependabotRun()).toMatchObject({ trigger: "schedule" }); + expect(app.latestDependabotRun()?.report.items).toHaveLength(1); + expect(await scheduler.tick(new Date(tomorrow.getTime() + 60_000))).toBe("idle"); + expect(app.dependabotStore.currentLease(new Date())).toBeNull(); + }); +});