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}
+
+
+
+
+
+ {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();
+ });
+});