diff --git a/README.md b/README.md
index d8d5a17..ebcc059 100644
--- a/README.md
+++ b/README.md
@@ -182,6 +182,13 @@ PRs, plus one small lookup that tells which watched repositories your token cann
few points of the 5,000 an hour; the report prints the actual cost. Anything it could not check is listed under
`NOT CHECKED` or `INCOMPLETE` rather than left out, and the command exits with status 1 if a whole owner failed.
+**In the web app.** The **Dependabot** page (`/dependabot`) lists the newest saved check by bucket with each PR's
+reasons and suggested comments, **Snooze** (a week) and **Dismiss** (until it closes) buttons, a **Put aside** list
+with **Restore**, and a **Check now** button that takes the same lock as the CLI. It reads the database, never GitHub,
+until you press the button; if another check is running (the CLI) it says so and picks up the result. Without a token
+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.
+
**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
@@ -274,6 +281,8 @@ npm run build -w @gitlib/web && npm run preview -w @gitlib/web # production bu
Each missing item on the Community tab has an **Add to planning** button that files a gitlib
[planning](#planning) issue for it (stored in gitlib only); once filed, the row links to that issue.
- The **Planning** page (`/planning`) holds gitlib's own issues; see [Planning](#planning).
+- The **Dependabot** page (`/dependabot`) shows the newest saved check of your starred repositories' Dependabot PRs,
+ with a **Check now** button and a count badge in the top bar; see [Dependabot watch](#dependabot-watch).
- The **Ask** button opens a chat panel for questions about your repositories; see [Chat](#chat).
- Lists every git repo found under your configured scan roots (scanned on first load; use
**Rescan** after adding repos).
diff --git a/apps/web/src/components/dependabot-finding.tsx b/apps/web/src/components/dependabot-finding.tsx
new file mode 100644
index 0000000..eb45277
--- /dev/null
+++ b/apps/web/src/components/dependabot-finding.tsx
@@ -0,0 +1,151 @@
+import { ExternalLink } from "lucide-react";
+import type { ReactNode } from "react";
+import { RelativeTime } from "#/components/relative-time";
+import { Button } from "#/components/ui/button";
+import { ciLabel, codeSegments, prKey, safeHref } from "#/lib/dependabot";
+import { cn } from "#/lib/utils";
+import type { DismissedFinding } from "#/server/dependabot";
+import type { StoredFinding } from "@gitlib/core";
+
+/** Text in which `backticked` pieces are drawn as code. Nothing is parsed as HTML. */
+export function Prose({ text }: { text: string }) {
+ return (
+ <>
+ {codeSegments(text).map((segment, index) =>
+ segment.code ? (
+
+ {segment.text}
+
+ ) : (
+ {segment.text}
+ ),
+ )}
+ >
+ );
+}
+
+function Facts({ finding, checkedAt }: { finding: StoredFinding; checkedAt: string }) {
+ const { pr } = finding;
+ const facts: ReactNode[] = [
+ prKey(pr),
+ pr.ecosystem,
+ pr.updateType === "unknown" ? "update type unknown" : `${pr.updateType} update`,
+
+ {ciLabel(pr.ci)}
+ ,
+ ];
+ // A finding carried over from an earlier check says so: GitHub could not be asked about its repository this time.
+ if (finding.observedAt !== checkedAt) {
+ facts.push(
+
+ last seen
+ ,
+ );
+ }
+ return (
+
+ {facts
+ .filter((fact) => fact !== null)
+ .map((fact, index) => (
+
+ {index > 0 ? · : null}
+ {fact}
+
+ ))}
+
+ );
+}
+
+function Title({ finding }: { finding: StoredFinding }) {
+ const href = safeHref(finding.pr.url);
+ const title = finding.pr.title || prKey(finding.pr);
+ return href ? (
+
+ {title}
+
+
+ ) : (
+ {title}
+ );
+}
+
+/** One pull request: what it is, why it is in its bucket, what to do, and buttons to put it aside. */
+export function FindingCard({
+ finding,
+ checkedAt,
+ busy,
+ onSnooze,
+ onDismiss,
+}: {
+ finding: StoredFinding;
+ checkedAt: string;
+ busy: boolean;
+ onSnooze: () => void;
+ onDismiss: () => void;
+}) {
+ return (
+
+
+
+
+
+
+
+ {/* The tooltip would otherwise become the buttons' names, which should say which pull request they act on. */}
+
+ Snooze
+
+
+ Dismiss
+
+
+
+
+ {finding.reasons.map((reason) => (
+
+
+
+ ))}
+
+ {finding.actions.length > 0 ? (
+
+ {finding.actions.map((action) => (
+
+ Next:
+
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+/** A pull request the user put aside, with how long and a way back. */
+export function DismissedRow({ finding, busy, onRestore }: { finding: DismissedFinding; busy: boolean; onRestore: () => void }) {
+ const { snoozedUntil } = finding.dismissal;
+ const href = safeHref(finding.pr.url);
+ return (
+
+
+ {href ? (
+
+ {prKey(finding.pr)}
+
+ ) : (
+ prKey(finding.pr)
+ )}
+ · {finding.pr.title}
+ · {snoozedUntil ? <>until > : "until closed"}
+
+
+ Restore
+
+
+ );
+}
diff --git a/apps/web/src/components/nav-bar.tsx b/apps/web/src/components/nav-bar.tsx
index 4335a8c..d64a384 100644
--- a/apps/web/src/components/nav-bar.tsx
+++ b/apps/web/src/components/nav-bar.tsx
@@ -1,18 +1,22 @@
import { Link } from "@tanstack/react-router";
-import { FolderKanban, GitBranch, ListTodo, MessageSquare, Settings } from "lucide-react";
+import { FolderKanban, GitBranch, GitPullRequest, ListTodo, MessageSquare, Settings } from "lucide-react";
+import { BUCKET_LABELS, BUCKET_ORDER } from "#/lib/dependabot";
import { useLibraryState } from "#/lib/library-state";
+import { useDependabotBadge } from "#/lib/use-dependabot-badge";
import { cn } from "#/lib/utils";
const LINKS = [
{ to: "/", label: "Repositories", icon: GitBranch },
{ to: "/projects", label: "Projects", icon: FolderKanban },
{ to: "/planning", label: "Planning", icon: ListTodo },
+ { to: "/dependabot", label: "Dependabot", icon: GitPullRequest },
{ to: "/settings", label: "Settings", icon: Settings },
] as const;
export function NavBar({ chatOpen, onToggleChat }: { chatOpen: boolean; onToggleChat: () => void }) {
// Links back to Repositories bring the table's filters and sort with them.
const library = useLibraryState();
+ const dependabot = useDependabotBadge();
return (
@@ -33,9 +37,20 @@ export function NavBar({ chatOpen, onToggleChat }: { chatOpen: boolean; onToggle
)}
activeProps={{ className: "bg-accent text-accent-foreground font-medium", "aria-current": "page" }}
>
- {/* Four labelled links don't fit a phone's width with their icons. */}
+ {/* Five labelled links don't fit a phone's width with their icons. */}
{label}
+ {to === "/dependabot" && dependabot && dependabot.actionable > 0 ? (
+
dependabot.counts[bucket] > 0)
+ .map((bucket) => `${dependabot.counts[bucket]} ${BUCKET_LABELS[bucket].toLowerCase()}`)
+ .join(", ")}
+ >
+ {dependabot.actionable} pull requests to look at:
+ {dependabot.actionable}
+
+ ) : null}
))}
diff --git a/apps/web/src/components/relative-time.tsx b/apps/web/src/components/relative-time.tsx
new file mode 100644
index 0000000..af49eb0
--- /dev/null
+++ b/apps/web/src/components/relative-time.tsx
@@ -0,0 +1,21 @@
+import { useEffect, useState } from "react";
+import { formatAgo } from "#/lib/format";
+
+/**
+ * "5 min ago" for a timestamp; a time still to come is shown as the date. The server and the browser disagree about the time, so it renders the UTC time first
+ * and switches to the relative form once mounted; the full time is in the tooltip.
+ */
+export function RelativeTime({ iso }: { iso: string }) {
+ const [now, setNow] = useState
(null);
+ useEffect(() => {
+ setNow(Date.now());
+ const timer = setInterval(() => setNow(Date.now()), 30_000);
+ return () => clearInterval(timer);
+ }, []);
+ const absolute = `${iso.slice(0, 16).replace("T", " ")} UTC`;
+ return (
+
+ {now === null || Date.parse(iso) > now ? absolute : formatAgo(iso, now) || absolute}
+
+ );
+}
diff --git a/apps/web/src/lib/dependabot.ts b/apps/web/src/lib/dependabot.ts
new file mode 100644
index 0000000..30da7a2
--- /dev/null
+++ b/apps/web/src/lib/dependabot.ts
@@ -0,0 +1,77 @@
+/**
+ * Display helpers for the Dependabot page, free of server code so they are shared with the UI and unit-tested.
+ * Only types come from @gitlib/core here: client bundles must not keep a runtime import of it.
+ */
+import type { Bucket, CiState, RunTrigger } from "@gitlib/core";
+
+export const BUCKET_ORDER: readonly Bucket[] = ["ready", "review-first", "needs-work", "waiting"];
+
+export const BUCKET_LABELS: Record = {
+ ready: "Ready",
+ "review-first": "Review first",
+ "needs-work": "Needs work",
+ waiting: "Waiting",
+};
+
+export const BUCKET_HELP: Record = {
+ ready: "Mergeable, up to date, checks passing, and a patch or minor update. Merge it on GitHub.",
+ "review-first": "Nothing is broken, but look first: a major bump, an unclear update type, a security fix, a pending review, or checks gitlib cannot read.",
+ "needs-work": "Conflicts, behind the base branch, changes requested, or failing checks.",
+ waiting: "Checks are still running, it is a draft, or GitHub is still working out mergeability. Check again later.",
+};
+
+/** Text and dot colours for each bucket; readable in light and dark. */
+export const BUCKET_TONES: Record = {
+ ready: { dot: "bg-emerald-500", text: "text-emerald-700 dark:text-emerald-400" },
+ "review-first": { dot: "bg-amber-500", text: "text-amber-700 dark:text-amber-400" },
+ "needs-work": { dot: "bg-red-500", text: "text-red-700 dark:text-red-400" },
+ waiting: { dot: "bg-slate-400", text: "text-muted-foreground" },
+};
+
+export const TRIGGER_NAMES: Record = { cli: "the CLI", web: "the web page", schedule: "the schedule" };
+
+/** The lease holder is `:`; this is the trigger in words, or "another process" for anything else. */
+export function holderName(holder: string): string {
+ const trigger = holder.split(":")[0] as RunTrigger;
+ return TRIGGER_NAMES[trigger] ?? "another process";
+}
+
+/** One short phrase for a PR's CI; the long reason for "unknown" is in the PR's reasons. */
+export function ciLabel(ci: CiState): string {
+ switch (ci.state) {
+ case "success":
+ return "CI passing";
+ case "failing":
+ return ci.failed.length > 0 ? `CI failing (${ci.failed.join(", ")})` : "CI failing";
+ case "pending":
+ return ci.pending.length > 0 ? `CI pending (${ci.pending.join(", ")})` : "CI pending";
+ case "unknown":
+ return "CI unknown";
+ }
+}
+
+/** A link target worth following: only https pages on github.com, whatever the stored value says. */
+export function safeHref(url: string): string | null {
+ try {
+ const parsed = new URL(url);
+ return parsed.protocol === "https:" && parsed.hostname === "github.com" ? parsed.href : null;
+ } catch {
+ return null;
+ }
+}
+
+/** Splits text on `backticks` so the pieces in them can be drawn as code without any HTML being parsed. */
+export function codeSegments(text: string): { text: string; code: boolean }[] {
+ return text
+ .split("`")
+ .map((piece, index) => ({ text: piece, code: index % 2 === 1 }))
+ .filter((segment) => segment.text !== "");
+}
+
+/** The ISO time `days` days after `now`, for a snooze. */
+export function snoozeUntil(days: number, now: Date = new Date()): string {
+ return new Date(now.getTime() + days * 86_400_000).toISOString();
+}
+
+/** `meddlin/gitlib#41`, the way the CLI prints a pull request. */
+export const prKey = (pr: { repo: string; number: number }): string => `${pr.repo}#${pr.number}`;
diff --git a/apps/web/src/lib/use-dependabot-badge.ts b/apps/web/src/lib/use-dependabot-badge.ts
new file mode 100644
index 0000000..28ce93b
--- /dev/null
+++ b/apps/web/src/lib/use-dependabot-badge.ts
@@ -0,0 +1,38 @@
+import { useEffect, useState } from "react";
+import { getDependabotBadge, type DependabotBadge } from "#/server/dependabot";
+
+const CHANGED = "gitlib:dependabot-changed";
+/** The NavBar reads the saved check, which a schedule or the CLI can replace at any time. */
+const POLL_MS = 60_000;
+
+/** Tells every NavBar badge on the page to read the saved check again, e.g. after "Check now". */
+export function notifyDependabotChanged(): void {
+ window.dispatchEvent(new Event(CHANGED));
+}
+
+/** The numbers behind the Dependabot badge; null until the first read, or if reading fails. */
+export function useDependabotBadge(): DependabotBadge | null {
+ const [badge, setBadge] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ const load = () =>
+ getDependabotBadge()
+ .then((value) => !cancelled && setBadge(value))
+ // A badge that cannot be read is simply absent; it must never break the page around it.
+ .catch(() => undefined);
+ void load();
+ const timer = setInterval(load, POLL_MS);
+ const onVisible = () => document.visibilityState === "visible" && void load();
+ window.addEventListener(CHANGED, load);
+ document.addEventListener("visibilitychange", onVisible);
+ return () => {
+ cancelled = true;
+ clearInterval(timer);
+ window.removeEventListener(CHANGED, load);
+ document.removeEventListener("visibilitychange", onVisible);
+ };
+ }, []);
+
+ return badge;
+}
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 1325c43..2d643cd 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as DependabotRouteImport } from './routes/dependabot'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as ApiChatRouteImport } from './routes/api.chat'
import { Route as PlanningIndexRouteImport } from './routes/planning.index'
@@ -24,6 +25,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const DependabotRoute = DependabotRouteImport.update({
+ id: '/dependabot',
+ path: '/dependabot',
+ getParentRoute: () => rootRouteImport,
+} as any)
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
@@ -67,6 +73,7 @@ const PlanningIssuesIdRoute = PlanningIssuesIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/dependabot': typeof DependabotRoute
'/settings': typeof SettingsRoute
'/api/chat': typeof ApiChatRoute
'/planning/board': typeof PlanningBoardRoute
@@ -78,6 +85,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/dependabot': typeof DependabotRoute
'/settings': typeof SettingsRoute
'/api/chat': typeof ApiChatRoute
'/planning/board': typeof PlanningBoardRoute
@@ -90,6 +98,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/dependabot': typeof DependabotRoute
'/settings': typeof SettingsRoute
'/api/chat': typeof ApiChatRoute
'/planning/board': typeof PlanningBoardRoute
@@ -103,6 +112,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/dependabot'
| '/settings'
| '/api/chat'
| '/planning/board'
@@ -114,6 +124,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/dependabot'
| '/settings'
| '/api/chat'
| '/planning/board'
@@ -125,6 +136,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
+ | '/dependabot'
| '/settings'
| '/api/chat'
| '/planning/board'
@@ -137,6 +149,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ DependabotRoute: typeof DependabotRoute
SettingsRoute: typeof SettingsRoute
ApiChatRoute: typeof ApiChatRoute
PlanningBoardRoute: typeof PlanningBoardRoute
@@ -156,6 +169,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/dependabot': {
+ id: '/dependabot'
+ path: '/dependabot'
+ fullPath: '/dependabot'
+ preLoaderRoute: typeof DependabotRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/settings': {
id: '/settings'
path: '/settings'
@@ -217,6 +237,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ DependabotRoute: DependabotRoute,
SettingsRoute: SettingsRoute,
ApiChatRoute: ApiChatRoute,
PlanningBoardRoute: PlanningBoardRoute,
diff --git a/apps/web/src/routes/dependabot.tsx b/apps/web/src/routes/dependabot.tsx
new file mode 100644
index 0000000..cf8c570
--- /dev/null
+++ b/apps/web/src/routes/dependabot.tsx
@@ -0,0 +1,227 @@
+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 { 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 { notifyDependabotChanged } from "#/lib/use-dependabot-badge";
+import { cn } from "#/lib/utils";
+import {
+ checkDependabotNow,
+ dismissDependabotPr,
+ getDependabot,
+ restoreDependabotPr,
+ type DependabotActionResult,
+ type DependabotView,
+} from "#/server/dependabot";
+
+export const Route = createFileRoute("/dependabot")({
+ loader: () => getDependabot(),
+ head: () => ({ meta: [{ title: "Dependabot · Gitlib" }] }),
+ component: Dependabot,
+});
+
+/** How often to look again while another process (the CLI, the schedule) is checking. */
+const WATCH_MS = 3_000;
+
+function Dependabot() {
+ const loaded = Route.useLoaderData();
+ const [view, setView] = useState(loaded);
+ const [checking, setChecking] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => setView(loaded), [loaded]);
+
+ // Someone else is asking GitHub: show it, and pick up the result when they finish.
+ const watching = view.running !== null && !checking;
+ useEffect(() => {
+ if (!watching) return;
+ const timer = setInterval(() => {
+ getDependabot()
+ .then((next) => {
+ setView(next);
+ if (next.running === null) notifyDependabotChanged();
+ })
+ .catch(() => undefined);
+ }, WATCH_MS);
+ return () => clearInterval(timer);
+ }, [watching]);
+
+ /** Runs one action, shows the page's data as the server now has it, and the reason if it was refused. */
+ async function act(run: () => Promise, setWorking: (working: boolean) => void) {
+ setWorking(true);
+ setError(null);
+ try {
+ const result = await run();
+ setView(result.view);
+ setError(result.error);
+ notifyDependabotChanged();
+ } catch (caught) {
+ setError(caught instanceof Error ? caught.message : "Something went wrong.");
+ } finally {
+ setWorking(false);
+ }
+ }
+
+ const checkNow = () => act(() => checkDependabotNow(), setChecking);
+ 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 { run, report } = view;
+ const total = BUCKET_ORDER.reduce((sum, bucket) => sum + view.counts[bucket], 0);
+ const checkDisabled = !view.canCheck || checking || view.running !== null;
+
+ return (
+
+
+
+
+ {view.running ? (
+ <>A check is running ({holderName(view.running.by)}, started ).>
+ ) : run ? (
+ <>
+ Checked by {TRIGGER_NAMES[run.trigger]}.
+ >
+ ) : (
+ "No check has run yet."
+ )}
+
+
+ {view.unavailableReason ?
{view.unavailableReason} : null}
+ {error ?
{error} : null}
+ {report && report.errors.length > 0 ? (
+
+ The last check could not ask GitHub about {report.errors.map((entry) => entry.owner).join(", ")}; what was seen before is shown, marked
+ “last seen”. {report.errors[0]!.message}
+
+ ) : null}
+
+ {run && total === 0 && view.dismissed.length === 0 ? (
+
No open Dependabot pull requests on the repositories that were checked.
+ ) : null}
+
+ {BUCKET_ORDER.map((bucket) => {
+ const items = view.items.filter((item) => item.bucket === bucket);
+ if (items.length === 0) return null;
+ const tone = BUCKET_TONES[bucket];
+ return (
+
+
+
+
+ {BUCKET_LABELS[bucket]}
+ {items.length}
+
+
{BUCKET_HELP[bucket]}
+
+
+ {items.map((finding) => (
+ snooze(finding.pr)}
+ onDismiss={() => dismiss(finding.pr)}
+ />
+ ))}
+
+
+ );
+ })}
+
+ {view.dismissed.length > 0 ? (
+
+ Put aside ({view.dismissed.length})
+
+ {view.dismissed.map((finding) => (
+ restore(finding.pr)} />
+ ))}
+
+
+ ) : null}
+
+ {report ?
: null}
+
+ );
+}
+
+function Banner({ tone, children }: { tone: "warning" | "error"; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+/** What the last check covered and what it could not, so an empty list is never mistaken for "all clear". */
+function CoverageNotes({ report }: { report: NonNullable }) {
+ const unreachable = report.watched.filter((repo) => repo.status === "unreachable");
+ const quiet = report.watched.filter((repo) => repo.status === "checked" && repo.openPrs === 0);
+ const lines: React.ReactNode[] = [];
+
+ if (unreachable.length > 0) {
+ lines.push(
+ <>
+ Not checked: GitHub does not show {unreachable.map((repo) => repo.slug).join(", ")} to your token (private without access, renamed or deleted).
+ >,
+ );
+ }
+ for (const owner of report.truncatedOwners) {
+ lines.push(
+ <>
+ Incomplete: {owner} has more matching pull requests than GitHub search returns, so some are missing.
+ >,
+ );
+ }
+ for (const warning of report.warnings) lines.push(<>GitHub warning: {warning}>);
+ if (report.skipped.length > 0) {
+ lines.push(
+ <>
+ Skipped {report.skipped.length} starred {report.skipped.length === 1 ? "repository" : "repositories"} with no GitHub remote to ask about:{" "}
+ {report.skipped.map((repo) => `${repo.relativePath} (${repo.reason})`).join("; ")}.
+ >,
+ );
+ }
+ if (report.watched.length === 0) {
+ lines.push(<>No starred repository has a GitHub remote, so there is nothing to check. Star repositories on the Repositories page, then rescan.>);
+ }
+ if (quiet.length > 0) lines.push(<>No open Dependabot pull requests: {quiet.map((repo) => repo.slug).join(", ")}.>);
+ if (report.rateLimit) {
+ lines.push(
+ <>
+ GitHub quota: the last check cost {report.rateLimit.cost}; {report.rateLimit.remaining} left until .
+ >,
+ );
+ }
+ if (lines.length === 0) return null;
+ return (
+
+ {lines.map((line, index) => (
+ {typeof line === "string" ? : line}
+ ))}
+
+ );
+}
diff --git a/apps/web/src/server/dependabot.server.ts b/apps/web/src/server/dependabot.server.ts
new file mode 100644
index 0000000..f71c984
--- /dev/null
+++ b/apps/web/src/server/dependabot.server.ts
@@ -0,0 +1,67 @@
+import { BUCKETS, isGitLibError, splitDismissed, type Bucket, type GitLibApp, type StoredReport } from "@gitlib/core";
+import type { DependabotBadge, DependabotView } from "./dependabot";
+import { getApp } from "./gitlib.server";
+
+function withoutItems(report: StoredReport): Omit {
+ const { items, ...rest } = report;
+ void items;
+ return rest;
+}
+
+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 {
+ const run = app.latestDependabotRun();
+ const lease = app.dependabotStore.currentLease(now);
+ const { active, dismissed } = splitDismissed(run?.report.items ?? [], app.listDependabotDismissals(), now);
+
+ const counts = emptyCounts();
+ for (const item of active) counts[item.bucket] += 1;
+
+ return {
+ canCheck: app.dependabot !== null,
+ unavailableReason:
+ app.dependabot === null
+ ? "Checking needs a GitHub token. Set GITLIB_GITHUB_TOKEN (or GITHUB_TOKEN) in the server's environment and restart it."
+ : null,
+ running: lease ? { by: lease.holder, since: lease.takenAt } : null,
+ run: run ? { id: run.id, trigger: run.trigger, checkedAt: run.report.checkedAt, finishedAt: run.finishedAt } : null,
+ items: active,
+ dismissed,
+ report: run ? withoutItems(run.report) : null,
+ counts,
+ };
+}
+
+/** Just the numbers for the NavBar: how many pull requests need a person, by bucket. */
+export function buildBadge(now: Date = new Date(), app: GitLibApp = getApp()): DependabotBadge {
+ const { counts, run } = buildView(now, app);
+ return {
+ actionable: BUCKETS.filter((bucket) => bucket !== "waiting").reduce((sum, bucket) => sum + counts[bucket], 0),
+ counts,
+ checkedAt: run?.checkedAt ?? null,
+ };
+}
+
+/** Runs a check as the web page; resolves with a message when it could not be done (busy, no token, GitHub down). */
+export async function runCheck(app: GitLibApp = getApp()): Promise {
+ try {
+ await app.runDependabotCheck({ trigger: "web" });
+ return null;
+ } catch (caught) {
+ if (isGitLibError(caught)) return caught.message;
+ throw caught;
+ }
+}
+
+/** Runs a change to the dismissals; resolves with a message when the request was refused. */
+export function applyDismissal(change: () => void): string | null {
+ try {
+ change();
+ return null;
+ } catch (caught) {
+ if (isGitLibError(caught)) return caught.message;
+ throw caught;
+ }
+}
diff --git a/apps/web/src/server/dependabot.ts b/apps/web/src/server/dependabot.ts
new file mode 100644
index 0000000..34c95da
--- /dev/null
+++ b/apps/web/src/server/dependabot.ts
@@ -0,0 +1,74 @@
+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 { getApp } from "./gitlib.server";
+
+/** A finding the user has put aside, with how. */
+export type DismissedFinding = StoredFinding & { dismissal: DependabotDismissal };
+
+export interface DependabotView {
+ /** False without a GitHub token: the page can show what was stored but cannot check. */
+ canCheck: boolean;
+ /** Why a check cannot run, in words fit for the page. */
+ unavailableReason: string | null;
+ /** Someone (this page, the CLI, the schedule) is asking GitHub right now. `by` is `:`. */
+ running: { by: string; since: string } | null;
+ /** The newest saved check, or null before the first. */
+ run: { id: number; trigger: RunTrigger; checkedAt: string; finishedAt: string } | null;
+ /** Pull requests to act on, by bucket then repository. */
+ items: StoredFinding[];
+ dismissed: DismissedFinding[];
+ /** What the check covered and what it could not; null before the first check. */
+ report: Omit | null;
+ counts: Record;
+}
+
+export interface DependabotBadge {
+ /** Pull requests that need a person: everything except those waiting on checks or GitHub. */
+ actionable: number;
+ counts: Record;
+ checkedAt: string | null;
+}
+
+/** The result of an action on the page: what went wrong, if anything, and the page's data as it is now. */
+export interface DependabotActionResult {
+ error: string | null;
+ view: DependabotView;
+}
+
+export const getDependabot = createServerFn({ method: "GET" }).handler(async (): Promise => buildView());
+
+/** Counts for the NavBar badge; a cheap read of the saved check. */
+export const getDependabotBadge = createServerFn({ method: "GET" }).handler(async (): Promise => 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 => {
+ const error = await runCheck();
+ return { error, view: buildView() };
+});
+
+interface PrInput {
+ repo: string;
+ number: number;
+}
+
+const validatePr = (input: PrInput & { until?: string | null }) => {
+ if (typeof input?.repo !== "string" || !input.repo || !Number.isInteger(input?.number)) throw new Error("Invalid pull request.");
+ if (input.until !== undefined && input.until !== null && typeof input.until !== "string") throw new Error("Invalid date.");
+ return input;
+};
+
+/** Puts a PR aside until `until` (an ISO time), or until it closes when `until` is absent. */
+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));
+ 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));
+ return { error, view: buildView() };
+ });
diff --git a/apps/web/test/dependabot.test.ts b/apps/web/test/dependabot.test.ts
new file mode 100644
index 0000000..2a435b2
--- /dev/null
+++ b/apps/web/test/dependabot.test.ts
@@ -0,0 +1,233 @@
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ DependabotUnavailableError,
+ createApplication,
+ parsePullRequest,
+ type DependabotBackend,
+ type DependabotPr,
+ 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";
+
+// --- lib/dependabot.ts -------------------------------------------------------------------------------------------
+
+describe("display helpers", () => {
+ it("words each CI state, naming what failed or is running", () => {
+ expect(ciLabel({ state: "success" })).toBe("CI passing");
+ expect(ciLabel({ state: "failing", failed: ["lint", "test"] })).toBe("CI failing (lint, test)");
+ expect(ciLabel({ state: "failing", failed: [] })).toBe("CI failing");
+ expect(ciLabel({ state: "pending", pending: ["build"] })).toBe("CI pending (build)");
+ expect(ciLabel({ state: "unknown", reason: "long reason" })).toBe("CI unknown");
+ });
+
+ it("only links to https pages on github.com", () => {
+ expect(safeHref("https://github.com/meddlin/gitlib/pull/41")).toBe("https://github.com/meddlin/gitlib/pull/41");
+ for (const bad of ["javascript:alert(1)", "http://github.com/a/b/pull/1", "https://evil.example/github.com", "https://github.com.evil.example/x", "//github.com/x", "not a url", ""]) {
+ expect(safeHref(bad)).toBeNull();
+ }
+ });
+
+ it("splits text on backticks without interpreting anything else", () => {
+ expect(codeSegments("Comment `@dependabot rebase` on the PR.")).toEqual([
+ { text: "Comment ", code: false },
+ { text: "@dependabot rebase", code: true },
+ { text: " on the PR.", code: false },
+ ]);
+ expect(codeSegments(" ")).toEqual([{ text: " ", code: false }]);
+ expect(codeSegments("")).toEqual([]);
+ expect(codeSegments("dangling `tick")).toEqual([
+ { text: "dangling ", code: false },
+ { text: "tick", code: true },
+ ]);
+ });
+
+ 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");
+ expect(holderName("cli:1")).toBe("the CLI");
+ expect(holderName("mystery")).toBe("another process");
+ expect(snoozeUntil(7, new Date("2026-11-02T08:00:00Z"))).toBe("2026-11-09T08:00:00.000Z");
+ expect(prKey({ repo: "a/b", number: 4 })).toBe("a/b#4");
+ expect(BUCKET_ORDER).toEqual(["ready", "review-first", "needs-work", "waiting"]);
+ });
+});
+
+// --- server/dependabot.server.ts ---------------------------------------------------------------------------------
+
+function pr(overrides: Partial = {}): DependabotPr {
+ const number = overrides.number ?? 1;
+ const repo = overrides.repo ?? "meddlin/gitlib";
+ const node = {
+ number,
+ title: `bump thing (#${number})`,
+ url: `https://github.com/${repo}/pull/${number}`,
+ headRefName: "dependabot/npm_and_yarn/thing-1.0.1",
+ isDraft: false,
+ mergeable: "MERGEABLE",
+ mergeStateStatus: "CLEAN",
+ reviewDecision: null,
+ author: { login: "dependabot" },
+ repository: { nameWithOwner: repo },
+ labels: { nodes: [{ name: "dependencies" }] },
+ commits: {
+ totalCount: 1,
+ nodes: [
+ {
+ commit: {
+ message: "bump\n\n---\nupdated-dependencies:\n- dependency-name: thing\n update-type: version-update:semver-patch\n...\n",
+ statusCheckRollup: { state: "SUCCESS", contexts: { nodes: [] } },
+ },
+ },
+ ],
+ },
+ };
+ return { ...parsePullRequest(node)!, ...overrides };
+}
+
+class FakeBackend implements DependabotBackend {
+ readonly label = "fake";
+ calls = 0;
+ constructor(private readonly answer: Partial | Error) {}
+ async fetchOwner(owner: string): Promise {
+ this.calls += 1;
+ if (this.answer instanceof Error) throw this.answer;
+ return { owner, prs: [], truncated: false, unreachable: [], warnings: [], rateLimit: null, ...this.answer };
+ }
+}
+
+let base: string;
+beforeEach(() => {
+ base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "gitlib-web-dependabot-")));
+});
+afterEach(() => fs.rmSync(base, { recursive: true, force: true }));
+
+/** An app over a starred clone of meddlin/gitlib, with the given GitHub behind it (null = no token). */
+function makeApp(backend: DependabotBackend | null): GitLibApp {
+ const home = path.join(base, "home");
+ const clone = path.join(base, "git", "gitlib");
+ if (!fs.existsSync(clone)) {
+ fs.mkdirSync(clone, { recursive: true });
+ execFileSync("git", ["init", "-q", clone]);
+ execFileSync("git", ["-C", clone, "remote", "add", "origin", "https://github.com/meddlin/gitlib.git"]);
+ fs.mkdirSync(home);
+ fs.writeFileSync(path.join(home, "config.toml"), `roots = ["${path.join(base, "git")}"]\n\n[search]\nenabled = false\n`);
+ }
+ const app = createApplication({ GITLIB_HOME: home }, undefined, null, null, backend);
+ app.refresh();
+ app.saveRepositoryFavorite("gitlib", true);
+ return app;
+}
+
+describe("buildView", () => {
+ it("is empty before the first check, and says a check needs a token when there is none", () => {
+ const view = buildView(new Date(), makeApp(null));
+ expect(view).toMatchObject({
+ canCheck: false,
+ running: null,
+ run: null,
+ items: [],
+ dismissed: [],
+ report: null,
+ counts: { ready: 0, "review-first": 0, "needs-work": 0, waiting: 0 },
+ });
+ expect(view.unavailableReason).toContain("GITLIB_GITHUB_TOKEN");
+ expect(buildView(new Date(), makeApp(new FakeBackend({}))).unavailableReason).toBeNull();
+ });
+
+ it("shows the saved check with counts, setting aside what the user dismissed or snoozed", async () => {
+ const backend = new FakeBackend({
+ prs: [pr({ number: 1 }), pr({ number: 2, mergeStateStatus: "BEHIND" }), pr({ number: 3, mergeable: "UNKNOWN" }), pr({ number: 4, updateType: "major" })],
+ });
+ const app = makeApp(backend);
+ expect(await runCheck(app)).toBeNull();
+
+ const view = buildView(new Date(), app);
+ expect(view.canCheck).toBe(true);
+ expect(view.run).toMatchObject({ id: 1, trigger: "web" });
+ expect(view.items.map((item) => [item.pr.number, item.bucket])).toEqual([
+ [1, "ready"],
+ [4, "review-first"],
+ [2, "needs-work"],
+ [3, "waiting"],
+ ]);
+ expect(view.counts).toEqual({ ready: 1, "review-first": 1, "needs-work": 1, waiting: 1 });
+ expect(view.report?.watched).toMatchObject([{ slug: "meddlin/gitlib", status: "checked", openPrs: 4 }]);
+ // 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();
+ const week = new Date(Date.now() + 7 * 86_400_000).toISOString();
+ expect(applyDismissal(() => 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.
+ expect(after.dismissed.map((item) => [item.pr.number, item.dismissal.snoozedUntil])).toEqual([
+ [4, week],
+ [2, null],
+ ]);
+ expect(after.counts).toEqual({ ready: 1, "review-first": 0, "needs-work": 0, waiting: 1 });
+
+ // The snooze ends: the PR comes back without anyone doing anything.
+ expect(buildView(new Date(Date.now() + 8 * 86_400_000), app).items.map((item) => item.pr.number)).toEqual([1, 4, 3]);
+ });
+
+ it("shows a check that is running right now", () => {
+ const app = makeApp(new FakeBackend({}));
+ app.dependabotStore.acquireLease("schedule:77", new Date(), 60_000);
+ expect(buildView(new Date(), app).running).toMatchObject({ by: "schedule:77" });
+ expect(buildView(new Date(Date.now() + 120_000), app).running).toBeNull();
+ });
+});
+
+describe("buildBadge", () => {
+ it("counts what needs a person: everything except what is waiting", async () => {
+ const app = makeApp(new FakeBackend({ prs: [pr({ number: 1 }), pr({ number: 2 }), pr({ number: 3, mergeStateStatus: "BEHIND" }), pr({ number: 4, mergeable: "UNKNOWN" })] }));
+ expect(buildBadge(new Date(), app)).toEqual({ actionable: 0, counts: { ready: 0, "review-first": 0, "needs-work": 0, waiting: 0 }, checkedAt: null });
+
+ await runCheck(app);
+ const badge = buildBadge(new Date(), app);
+ expect(badge.actionable).toBe(3);
+ expect(badge.counts).toEqual({ ready: 2, "review-first": 0, "needs-work": 1, waiting: 1 });
+ expect(badge.checkedAt).toMatch(/^\d{4}-\d\d-\d\dT/);
+
+ app.dismissDependabotPr("meddlin/gitlib", 3);
+ expect(buildBadge(new Date(), app).actionable).toBe(2);
+ });
+});
+
+describe("runCheck", () => {
+ it("answers with the reason, not an exception, when a check cannot run", async () => {
+ expect(await runCheck(makeApp(null))).toContain("needs a GitHub token");
+
+ const app = makeApp(new FakeBackend({}));
+ app.dependabotStore.acquireLease("cli:5", new Date(), 60_000);
+ expect(await runCheck(app)).toMatch(/already running \(started \S+ by cli\)/);
+ });
+
+ it("saves a check in which GitHub could not be reached, so the page can say so", async () => {
+ const app = makeApp(new FakeBackend(new DependabotUnavailableError("Could not reach GitHub at api.github.com (ENOTFOUND).")));
+ expect(await runCheck(app)).toBeNull();
+ expect(buildView(new Date(), app).report?.errors).toEqual([{ owner: "meddlin", message: "Could not reach GitHub at api.github.com (ENOTFOUND)." }]);
+ });
+
+ it("lets a bug through instead of hiding it as a message", async () => {
+ const boom = new TypeError("a bug");
+ await expect(runCheck(makeApp(new FakeBackend(boom)))).rejects.toBe(boom);
+ });
+});
+
+describe("applyDismissal", () => {
+ 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");
+ });
+});
diff --git a/docs/dependabot-watch.md b/docs/dependabot-watch.md
index b2000fc..d2502dd 100644
--- a/docs/dependabot-watch.md
+++ b/docs/dependabot-watch.md
@@ -22,6 +22,10 @@ The verdict never reads the PR body. Choices the plan left open:
by default; `show`, `dismiss` and `restore` were added. Findings of an owner that could not be asked are carried
forward from the previous run with their original time, which is the "partial failure per PR" of "Where the
existing scheduler falls short", point 4.
+- Slice 3, the page (11 d): `/dependabot` with the buckets, reasons and suggested comments as text, snooze (a week)
+ 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.
- 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