Skip to content

g1t/apps/web/app/lib/dependency-updates.ts

83 lines3,679 bytesCodeBlame
1/**
2 * How the Security page words the dependency update file: when an entry
3 * next runs, what each rule says, and which pull requests are live. Only
4 * type imports, so it is tested on its own.
5 */
6import type { IgnoreCondition, UpdateGroup, UpdateIgnore, UpdatePull, VersionUpdateEntry } from "@g1t/contracts";
7
8/** Where the docs explain the dependency update file. */
9export const DEPENDENCY_UPDATES_DOCS = "https://docs.g1t.sh/guides/dependency-updates/";
10
11const UNITS: [string, number][] = [
12 ["d", 86_400_000],
13 ["h", 3_600_000],
14 ["m", 60_000],
15];
16
17/** A time ahead, said shortly: "in 3h", "in 2d", "due now". */
18export function timeUntil(at: string, now: number = Date.now()): string {
19 const ahead = new Date(at).getTime() - now;
20 if (!Number.isFinite(ahead) || ahead <= 60_000) return "due now";
21 const [unit, size] = UNITS.find(([, size]) => ahead >= size) ?? UNITS[UNITS.length - 1]!;
22 return `in ${Math.floor(ahead / size)}${unit}`;
23}
24
25/** A moment in UTC, for a title attribute: "2026-10-12 05:00 UTC". */
26export function utc(at: string): string {
27 const date = new Date(at);
28 if (Number.isNaN(date.getTime())) return at;
29 return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`;
30}
31
32/** Where an entry stands, in a word or two, and how to tone it. */
33export function entryStatus(entry: VersionUpdateEntry): { label: string; tone: "accent" | "neutral" | "warn" | "danger" } {
34 if (!entry.supported) return { label: "Not updated yet", tone: "neutral" };
35 if (entry.openPullRequestsLimit === 0) return { label: "Off", tone: "neutral" };
36 if (!entry.nextRunAt) return { label: "Not scheduled", tone: "warn" };
37 if (entry.lastError) return { label: "Last check failed", tone: "danger" };
38 return { label: "Active", tone: "accent" };
39}
40
41/** A `groups` rule in a line: "lint: eslint*, prettier (minor, patch)". */
42export function groupText(group: UpdateGroup): string {
43 const parts = [group.patterns.length > 0 ? group.patterns.join(", ") : "every dependency"];
44 if (group.excludePatterns.length > 0) parts.push(`not ${group.excludePatterns.join(", ")}`);
45 if (group.dependencyType) parts.push(group.dependencyType);
46 if (group.updateTypes.length > 0) parts.push(group.updateTypes.join(", "));
47 if (group.groupBy) parts.push("one per dependency");
48 const security = group.appliesTo === "security-updates" ? " · security updates" : "";
49 return `${group.name}: ${parts.join(" · ")}${security}`;
50}
51
52function level(updateType: string): string {
53 return updateType.replace("version-update:semver-", "");
54}
55
56/** An `ignore` rule in a line: "react >=19", "* major". */
57export function ignoreText(rule: UpdateIgnore): string {
58 const what = [...rule.versions, ...rule.updateTypes.map(level)];
59 return what.length > 0 ? `${rule.dependency} ${what.join(", ")}` : `${rule.dependency} (every version)`;
60}
61
62/** An ignore condition from a comment, in a few words. */
63export function conditionText(condition: IgnoreCondition): string {
64 if (condition.versions) return condition.versions;
65 if (condition.updateType) return `${level(condition.updateType)} versions`;
66 return "every version";
67}
68
69/** Update pull requests worth showing: those open or being made, newest first. */
70export function livePulls(pulls: UpdatePull[]): UpdatePull[] {
71 return pulls.filter((pull) => pull.state === "requested" || pull.state === "open" || pull.state === "needs_code");
72}
73
74/** How an update pull request's state reads. */
75export const PULL_STATES: Record<UpdatePull["state"], string> = {
76 requested: "Being made",
77 open: "Open",
78 merged: "Merged",
79 closed: "Closed",
80 superseded: "Superseded",
81 needs_code: "Needs code changes",
82 failed: "Failed",
83};