Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion apps/web/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
99 changes: 99 additions & 0 deletions apps/web/src/components/dependabot-schedule.tsx
Original file line number Diff line number Diff line change
@@ -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<number[]>(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 (
<section id="schedule" aria-labelledby="schedule-heading" className="flex scroll-mt-16 flex-col gap-3 rounded-lg border p-4">
<div>
<h2 id="schedule-heading" className="flex items-center gap-2 text-lg font-medium">
<CalendarClock className="size-4" aria-hidden /> Schedule
</h2>
<p className="text-sm text-muted-foreground" aria-live="polite">
{schedule.enabled ? `Checks run ${describeSchedule(schedule)}.` : "Off: gitlib only checks when you ask."}
{schedule.enabled && schedule.nextRunAt ? (
<>
{" "}
Next check: <RelativeTime iso={schedule.nextRunAt} />.
</>
) : null}
</p>
</div>

<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
onSave({ enabled, days, time });
}}
>
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} className="size-4 accent-primary" />
Check automatically
</label>

<fieldset className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm" disabled={!enabled}>
<legend className="sr-only">Days</legend>
{WEEKDAY_ORDER.map((day) => (
<label key={day} className="inline-flex items-center gap-1.5">
<input type="checkbox" checked={days.includes(day)} onChange={() => toggleDay(day)} className="size-4 accent-primary" />
{WEEKDAY_NAMES[day]}
</label>
))}
</fieldset>

<div className="flex flex-wrap items-center gap-3 text-sm">
<label className="inline-flex items-center gap-2">
At
<Input type="time" value={time} onChange={(event) => setTime(event.target.value)} disabled={!enabled} required className="h-8 w-28" />
</label>
<span className="text-muted-foreground">Server time ({schedule.timeZone}).</span>
<Button type="submit" size="sm" disabled={saving || !changed} className="ml-auto">
{saving ? "Saving…" : "Save schedule"}
</Button>
</div>
</form>

{schedule.lastError ? (
<p role="alert" className="text-sm text-destructive">
The last scheduled check failed{schedule.lastAttemptAt ? <> (<RelativeTime iso={schedule.lastAttemptAt} />)</> : null}: {schedule.lastError}
</p>
) : null}

<p className="text-xs text-muted-foreground">
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 <code className="rounded bg-muted px-1 font-mono">gitlib dependabot check</code> (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." : ""}
</p>
</section>
);
}
19 changes: 19 additions & 0 deletions apps/web/src/lib/dependabot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
21 changes: 21 additions & 0 deletions apps/web/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -120,6 +129,7 @@ export interface FileRouteTypes {
| '/repos/$'
| '/planning/'
| '/projects/'
| '/api/dependabot/start'
| '/planning/issues/$id'
fileRoutesByTo: FileRoutesByTo
to:
Expand All @@ -132,6 +142,7 @@ export interface FileRouteTypes {
| '/repos/$'
| '/planning'
| '/projects'
| '/api/dependabot/start'
| '/planning/issues/$id'
id:
| '__root__'
Expand All @@ -144,6 +155,7 @@ export interface FileRouteTypes {
| '/repos/$'
| '/planning/'
| '/projects/'
| '/api/dependabot/start'
| '/planning/issues/$id'
fileRoutesById: FileRoutesById
}
Expand All @@ -157,6 +169,7 @@ export interface RootRouteChildren {
ReposSplatRoute: typeof ReposSplatRoute
PlanningIndexRoute: typeof PlanningIndexRoute
ProjectsIndexRoute: typeof ProjectsIndexRoute
ApiDependabotStartRoute: typeof ApiDependabotStartRoute
PlanningIssuesIdRoute: typeof PlanningIssuesIdRoute
}

Expand Down Expand Up @@ -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'
Expand All @@ -245,6 +265,7 @@ const rootRouteChildren: RootRouteChildren = {
ReposSplatRoute: ReposSplatRoute,
PlanningIndexRoute: PlanningIndexRoute,
ProjectsIndexRoute: ProjectsIndexRoute,
ApiDependabotStartRoute: ApiDependabotStartRoute,
PlanningIssuesIdRoute: PlanningIssuesIdRoute,
}
export const routeTree = rootRouteImport
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/routes/api.dependabot.start.ts
Original file line number Diff line number Diff line change
@@ -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<Response> => {
const { ensureScheduler } = await import("../server/dependabot.server");
ensureScheduler();
return new Response(null, { status: 204 });
},
},
},
});
14 changes: 12 additions & 2 deletions apps/web/src/routes/dependabot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ 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 {
checkDependabotNow,
dismissDependabotPr,
getDependabot,
restoreDependabotPr,
saveDependabotSchedule,
type DependabotActionResult,
type DependabotView,
} from "#/server/dependabot";
Expand All @@ -30,6 +32,7 @@ function Dependabot() {
const [view, setView] = useState<DependabotView>(loaded);
const [checking, setChecking] = useState(false);
const [busy, setBusy] = useState(false);
const [savingSchedule, setSavingSchedule] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => setView(loaded), [loaded]);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -99,7 +103,11 @@ function Dependabot() {
</>
) : (
"No check has run yet."
)}
)}{" "}
<a href="#schedule" className="underline-offset-2 hover:underline">
Schedule: {describeSchedule(view.schedule)}
</a>
.
</p>

{view.unavailableReason ? <Banner tone="warning">{view.unavailableReason}</Banner> : null}
Expand Down Expand Up @@ -156,6 +164,8 @@ function Dependabot() {
</details>
) : null}

<SchedulePanel schedule={view.schedule} saving={savingSchedule} onSave={saveSchedule} />

{report ? <CoverageNotes report={report} /> : null}
</div>
);
Expand Down
Loading
Loading