From 0b8d649cb8fe1a7688c59ee4c1a291c6d1b3e2f8 Mon Sep 17 00:00:00 2001 From: Darrien Rushing Date: Mon, 2 Nov 2026 15:50:39 -0600 Subject: [PATCH] feat(web): add a Dependabot page with Check now, snooze/dismiss and a NavBar badge /dependabot shows the newest saved check by bucket, each PR's reasons and suggested comments (as text), snooze (a week) and dismiss buttons, a Put aside list with Restore, and a coverage footer (not checked, skipped, quota). Check now runs a check through the same lock as the CLI and says so when another check is running. The page reads gitlib's database, never GitHub, until the button is pressed. The NavBar link carries a count of PRs that need a person (ready, review first, needs work; not those waiting), refreshed every minute and after any action. Text that comes from GitHub is drawn as plain text, links are limited to https pages on github.com, and nothing from a PR body is shown. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 9 + .../web/src/components/dependabot-finding.tsx | 151 ++++++++++++ apps/web/src/components/nav-bar.tsx | 19 +- apps/web/src/components/relative-time.tsx | 21 ++ apps/web/src/lib/dependabot.ts | 77 ++++++ apps/web/src/lib/use-dependabot-badge.ts | 38 +++ apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/dependabot.tsx | 227 +++++++++++++++++ apps/web/src/server/dependabot.server.ts | 67 +++++ apps/web/src/server/dependabot.ts | 74 ++++++ apps/web/test/dependabot.test.ts | 233 ++++++++++++++++++ docs/dependabot-watch.md | 4 + 12 files changed, 939 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/dependabot-finding.tsx create mode 100644 apps/web/src/components/relative-time.tsx create mode 100644 apps/web/src/lib/dependabot.ts create mode 100644 apps/web/src/lib/use-dependabot-badge.ts create mode 100644 apps/web/src/routes/dependabot.tsx create mode 100644 apps/web/src/server/dependabot.server.ts create mode 100644 apps/web/src/server/dependabot.ts create mode 100644 apps/web/test/dependabot.test.ts 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 ( +
  • +
    +
    + + <Facts finding={finding} checkedAt={checkedAt} /> + </div> + <div className="flex shrink-0 gap-1"> + {/* The tooltip would otherwise become the buttons' names, which should say which pull request they act on. */} + <Button type="button" variant="outline" size="xs" disabled={busy} onClick={onSnooze} aria-label={`Snooze ${prKey(finding.pr)} for a week`} title="Hide it for a week"> + Snooze + </Button> + <Button type="button" variant="ghost" size="xs" disabled={busy} onClick={onDismiss} aria-label={`Dismiss ${prKey(finding.pr)} until it is closed`} title="Hide it until it is closed"> + Dismiss + </Button> + </div> + </div> + <ul className="list-disc space-y-0.5 pl-5 text-sm"> + {finding.reasons.map((reason) => ( + <li key={reason}> + <Prose text={reason} /> + </li> + ))} + </ul> + {finding.actions.length > 0 ? ( + <ul className="space-y-0.5 text-xs text-muted-foreground"> + {finding.actions.map((action) => ( + <li key={action}> + <span className="font-medium">Next: </span> + <Prose text={action} /> + </li> + ))} + </ul> + ) : null} + </li> + ); +} + +/** 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 ( + <li className="flex items-center justify-between gap-3 text-sm"> + <span className="min-w-0 truncate"> + {href ? ( + <a href={href} target="_blank" rel="noreferrer noopener" className="hover:underline"> + {prKey(finding.pr)} + </a> + ) : ( + prKey(finding.pr) + )} + <span className="text-muted-foreground"> · {finding.pr.title}</span> + <span className="text-muted-foreground"> · {snoozedUntil ? <>until <RelativeTime iso={snoozedUntil} /></> : "until closed"}</span> + </span> + <Button type="button" variant="outline" size="xs" disabled={busy} onClick={onRestore} aria-label={`Restore ${prKey(finding.pr)}`}> + Restore + </Button> + </li> + ); +} 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 ( <nav aria-label="Main" className="sticky top-0 z-20 h-12 min-w-0 shrink-0 border-b bg-background/90 backdrop-blur"> <div className="mx-auto flex h-full max-w-6xl items-center gap-6 px-4 sm:px-6"> @@ -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. */} <Icon className="size-3.5 max-sm:hidden" aria-hidden /> {label} + {to === "/dependabot" && dependabot && dependabot.actionable > 0 ? ( + <span + className="rounded-full bg-primary px-1.5 text-[0.7rem] font-medium leading-5 text-primary-foreground" + title={BUCKET_ORDER.filter((bucket) => dependabot.counts[bucket] > 0) + .map((bucket) => `${dependabot.counts[bucket]} ${BUCKET_LABELS[bucket].toLowerCase()}`) + .join(", ")} + > + <span className="sr-only">{dependabot.actionable} pull requests to look at: </span> + {dependabot.actionable} + </span> + ) : null} </Link> </li> ))} 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<number | null>(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 ( + <time dateTime={iso} title={absolute}> + {now === null || Date.parse(iso) > now ? absolute : formatAgo(iso, now) || absolute} + </time> + ); +} 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<Bucket, string> = { + ready: "Ready", + "review-first": "Review first", + "needs-work": "Needs work", + waiting: "Waiting", +}; + +export const BUCKET_HELP: Record<Bucket, string> = { + 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<Bucket, { dot: string; text: string }> = { + 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<RunTrigger, string> = { cli: "the CLI", web: "the web page", schedule: "the schedule" }; + +/** The lease holder is `<trigger>:<pid>`; 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<DependabotBadge | null>(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<DependabotView>(loaded); + const [checking, setChecking] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(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<DependabotActionResult>, 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 ( + <div className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 px-4 py-8 sm:px-6"> + <header className="flex flex-wrap items-start justify-between gap-3"> + <div className="flex flex-col gap-1"> + <h1 className="text-2xl font-semibold tracking-tight">Dependabot</h1> + <p className="text-sm text-muted-foreground"> + Open Dependabot pull requests on your starred repositories, sorted by what you can do about them. The verdict + comes from fields GitHub returns, never from the pull request text. + </p> + </div> + <Button type="button" onClick={checkNow} disabled={checkDisabled}> + <RefreshCw className={cn(checking && "animate-spin")} aria-hidden /> + {checking ? "Checking…" : "Check now"} + </Button> + </header> + + <p className="text-sm text-muted-foreground" aria-live="polite"> + {view.running ? ( + <>A check is running ({holderName(view.running.by)}, started <RelativeTime iso={view.running.since} />).</> + ) : run ? ( + <> + Checked <RelativeTime iso={run.checkedAt} /> by {TRIGGER_NAMES[run.trigger]}. + </> + ) : ( + "No check has run yet." + )} + </p> + + {view.unavailableReason ? <Banner tone="warning">{view.unavailableReason}</Banner> : null} + {error ? <Banner tone="error">{error}</Banner> : null} + {report && report.errors.length > 0 ? ( + <Banner tone="error"> + 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} + </Banner> + ) : null} + + {run && total === 0 && view.dismissed.length === 0 ? ( + <p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">No open Dependabot pull requests on the repositories that were checked.</p> + ) : 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 ( + <section key={bucket} aria-labelledby={`bucket-${bucket}`} className="flex flex-col gap-3"> + <div> + <h2 id={`bucket-${bucket}`} className="flex items-center gap-2 text-lg font-medium"> + <span className={cn("size-2.5 rounded-full", tone.dot)} aria-hidden /> + <span className={tone.text}>{BUCKET_LABELS[bucket]}</span> + <span className="text-sm font-normal text-muted-foreground">{items.length}</span> + </h2> + <p className="text-sm text-muted-foreground">{BUCKET_HELP[bucket]}</p> + </div> + <ul className="flex flex-col gap-2"> + {items.map((finding) => ( + <FindingCard + key={prKey(finding.pr)} + finding={finding} + checkedAt={report?.checkedAt ?? ""} + busy={busy} + onSnooze={() => snooze(finding.pr)} + onDismiss={() => dismiss(finding.pr)} + /> + ))} + </ul> + </section> + ); + })} + + {view.dismissed.length > 0 ? ( + <details className="rounded-lg border p-3"> + <summary className="cursor-pointer text-sm font-medium">Put aside ({view.dismissed.length})</summary> + <ul className="mt-3 flex flex-col gap-2"> + {view.dismissed.map((finding) => ( + <DismissedRow key={prKey(finding.pr)} finding={finding} busy={busy} onRestore={() => restore(finding.pr)} /> + ))} + </ul> + </details> + ) : null} + + {report ? <CoverageNotes report={report} /> : null} + </div> + ); +} + +function Banner({ tone, children }: { tone: "warning" | "error"; children: React.ReactNode }) { + return ( + <div + role={tone === "error" ? "alert" : "status"} + className={cn( + "flex items-start gap-2 rounded-lg border p-3 text-sm", + tone === "error" ? "border-destructive/40 text-destructive" : "border-amber-500/40 text-amber-800 dark:text-amber-300", + )} + > + <TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> + <div>{children}</div> + </div> + ); +} + +/** 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<DependabotView["report"]> }) { + 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( + <> + <strong>Not checked:</strong> 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( + <> + <strong>Incomplete:</strong> {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 <RelativeTime iso={report.rateLimit.resetAt} />. + </>, + ); + } + if (lines.length === 0) return null; + return ( + <section aria-label="What was checked" className="flex flex-col gap-1.5 border-t pt-4 text-xs text-muted-foreground"> + {lines.map((line, index) => ( + <p key={index}>{typeof line === "string" ? <Prose text={line} /> : line}</p> + ))} + </section> + ); +} 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<StoredReport, "items"> { + const { items, ...rest } = report; + void items; + return rest; +} + +const emptyCounts = (): Record<Bucket, number> => ({ 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<string | null> { + 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 `<trigger>:<pid>`. */ + 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<StoredReport, "items"> | null; + counts: Record<Bucket, number>; +} + +export interface DependabotBadge { + /** Pull requests that need a person: everything except those waiting on checks or GitHub. */ + actionable: number; + counts: Record<Bucket, number>; + 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<DependabotView> => buildView()); + +/** Counts for the NavBar badge; a cheap read of the saved check. */ +export const getDependabotBadge = createServerFn({ method: "GET" }).handler(async (): Promise<DependabotBadge> => 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<DependabotActionResult> => { + 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<DependabotActionResult> => { + 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<DependabotActionResult> => { + 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("<img src=x onerror=alert(1)>")).toEqual([{ text: "<img src=x onerror=alert(1)>", 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> = {}): 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<OwnerPulls> | Error) {} + async fetchOwner(owner: string): Promise<OwnerPulls> { + 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