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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
151 changes: 151 additions & 0 deletions apps/web/src/components/dependabot-finding.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<code key={index} className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">
{segment.text}
</code>
) : (
<span key={index}>{segment.text}</span>
),
)}
</>
);
}

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`,
<span key="ci" className={cn(pr.ci.state === "unknown" && "font-medium text-amber-700 dark:text-amber-400")}>
{ciLabel(pr.ci)}
</span>,
];
// 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(
<span key="seen" className="text-amber-700 dark:text-amber-400">
last seen <RelativeTime iso={finding.observedAt} />
</span>,
);
}
return (
<p className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
{facts
.filter((fact) => fact !== null)
.map((fact, index) => (
<span key={index} className="inline-flex items-center gap-2">
{index > 0 ? <span aria-hidden>·</span> : null}
{fact}
</span>
))}
</p>
);
}

function Title({ finding }: { finding: StoredFinding }) {
const href = safeHref(finding.pr.url);
const title = finding.pr.title || prKey(finding.pr);
return href ? (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-start gap-1 break-words font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
{title}
<ExternalLink className="mt-1 size-3 shrink-0 text-muted-foreground" aria-label="opens GitHub" />
</a>
) : (
<span className="break-words font-medium">{title}</span>
);
}

/** 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 (
<li className="flex flex-col gap-2 rounded-lg border bg-card p-3 text-card-foreground">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<Title finding={finding} />
<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>
);
}
19 changes: 17 additions & 2 deletions apps/web/src/components/nav-bar.tsx
Original file line number Diff line number Diff line change
@@ -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">
Expand All @@ -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>
))}
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/relative-time.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
77 changes: 77 additions & 0 deletions apps/web/src/lib/dependabot.ts
Original file line number Diff line number Diff line change
@@ -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}`;
38 changes: 38 additions & 0 deletions apps/web/src/lib/use-dependabot-badge.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading