Skip to content

g1t/apps/web/app/components/dependency-updates.tsx

418 lines15,557 bytesCodeBlame
1/**
2 * The dependency update file, as a project's Security page shows it: which
3 * file was read, its problems with their lines, each `updates` entry with
4 * its schedule and last check, the private registries, the update pull
5 * requests g1t has open, and ignore conditions set in comments. "Check for
6 * updates" posts the `check_updates` intent to its page, the Security
7 * section's Dependency updates tab (`routes/repo/security-updates.tsx`).
8 */
9import {
10 CalendarClock,
11 CircleCheck,
12 CircleDot,
13 ExternalLink,
14 FileCode,
15 FileWarning,
16 GitMerge,
17 GitPullRequest,
18 Info,
19 KeyRound,
20 Loader,
21 RefreshCw,
22 Wrench,
23 EyeOff,
24} from "lucide-react";
25import { Link, useFetcher } from "react-router";
26
27import type { UpdatePull, VersionUpdateEntry, VersionUpdatesState } from "@g1t/contracts";
28
29import {
30 DEPENDENCY_UPDATES_DOCS,
31 PULL_STATES,
32 conditionText,
33 entryStatus,
34 groupText,
35 ignoreText,
36 livePulls,
37 timeUntil,
38 utc,
39} from "../lib/dependency-updates";
40import { TimeAgo } from "./ui";
41import { Badge, type BadgeTone } from "./ui/badge";
42
43type Done = { ok: boolean; error?: string } | undefined;
44
45const PULL_TONES: Record<UpdatePull["state"], BadgeTone> = {
46 requested: "info",
47 open: "accent",
48 merged: "merged",
49 closed: "neutral",
50 superseded: "neutral",
51 needs_code: "warn",
52 failed: "danger",
53};
54
55/** Text with `code` spans, as the service writes its messages. */
56function Said({ text }: { text: string }) {
57 return (
58 <>
59 {text.split("`").map((part, index) =>
60 index % 2 === 1 ? (
61 <code key={index} className="text-fg-soft">
62 {part}
63 </code>
64 ) : (
65 part
66 ),
67 )}
68 </>
69 );
70}
71
72function Heading({ children }: { children: React.ReactNode }) {
73 return <h4 className="text-xs font-medium tracking-wide text-faint uppercase">{children}</h4>;
74}
75
76/** One `updates` entry: what it covers, when it runs, what it found. */
77function Entry({ entry, action, canCheck }: { entry: VersionUpdateEntry; action: string; canCheck: boolean }) {
78 const check = useFetcher<Done>();
79 const status = entryStatus(entry);
80 const checking = check.state !== "idle";
81 const rules: { label: string; values: string[] }[] = [
82 { label: "Groups", values: entry.groups.map(groupText) },
83 { label: "Ignored", values: entry.ignore.map(ignoreText) },
84 {
85 label: "Allowed",
86 values: entry.allow.map((rule) =>
87 [rule.dependency, rule.dependencyType, ...rule.updateTypes.map((type) => type.replace("version-update:semver-", ""))]
88 .filter(Boolean)
89 .join(" · "),
90 ),
91 },
92 { label: "Assignees", values: entry.assignees },
93 { label: "Reviewers", values: entry.reviewers },
94 ].filter((rule) => rule.values.length > 0);
95 return (
96 <li className="rounded-lg border border-line p-3">
97 <div className="flex flex-wrap items-start justify-between gap-3">
98 <div className="min-w-0">
99 <p className="flex flex-wrap items-center gap-2 text-sm">
100 <span className="font-mono font-medium text-fg">{entry.ecosystem}</span>
101 <span className="font-mono text-muted wrap-anywhere">{entry.directories.join(", ")}</span>
102 {entry.targetBranch && <span className="font-mono text-xs text-faint">→ {entry.targetBranch}</span>}
103 <Badge tone={status.tone}>{status.label}</Badge>
104 </p>
105 <p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
106 {entry.schedule && (
107 <span className="inline-flex items-center gap-1">
108 <CalendarClock size={12} className="shrink-0" />
109 {entry.schedule}
110 </span>
111 )}
112 {entry.nextRunAt && (
113 <span title={utc(entry.nextRunAt)}>Next check {timeUntil(entry.nextRunAt)}</span>
114 )}
115 <span>
116 {entry.lastCheckedAt ? (
117 <>
118 Last checked <TimeAgo at={entry.lastCheckedAt} />
119 </>
120 ) : (
121 "Not checked yet"
122 )}
123 </span>
124 {entry.supported && <span>Up to {entry.openPullRequestsLimit} open</span>}
125 </p>
126 </div>
127 {entry.supported && canCheck && (
128 <check.Form method="post" action={action} className="shrink-0">
129 <input type="hidden" name="intent" value="check_updates" />
130 <input type="hidden" name="entry" value={entry.id} />
131 <button
132 type="submit"
133 disabled={checking || entry.openPullRequestsLimit === 0}
134 className="inline-flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1 text-xs text-fg/80 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg disabled:opacity-50"
135 >
136 <RefreshCw size={12} className={checking ? "animate-spin" : ""} />
137 {checking ? "Checking…" : "Check for updates"}
138 </button>
139 </check.Form>
140 )}
141 </div>
142 {check.data?.error && <p className="mt-2 text-xs text-danger">{check.data.error}</p>}
143 {entry.lastError ? (
144 <p className="mt-2 flex items-start gap-1.5 text-xs text-danger">
145 <FileWarning size={12} className="mt-0.5 shrink-0" />
146 <span className="wrap-anywhere">
147 <Said text={entry.lastError} />
148 </span>
149 </p>
150 ) : (
151 entry.lastResult && (
152 <p className="mt-2 flex items-start gap-1.5 text-xs text-muted">
153 <CircleCheck size={12} className="mt-0.5 shrink-0 text-accent" />
154 <span className="wrap-anywhere">
155 <Said text={entry.lastResult} />
156 </span>
157 </p>
158 )
159 )}
160 {rules.length > 0 && (
161 <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs">
162 {rules.map((rule) => (
163 <div key={rule.label} className="contents">
164 <dt className="text-faint">{rule.label}</dt>
165 <dd className="font-mono text-muted wrap-anywhere">{rule.values.join("; ")}</dd>
166 </div>
167 ))}
168 </dl>
169 )}
170 {entry.notes.length > 0 && (
171 <ul className="mt-2 space-y-1">
172 {entry.notes.map((note) => (
173 <li key={note} className="flex items-start gap-1.5 text-xs text-faint">
174 <Info size={12} className="mt-0.5 shrink-0" />
175 <span>
176 <Said text={note} />
177 </span>
178 </li>
179 ))}
180 </ul>
181 )}
182 </li>
183 );
184}
185
186/** One update pull request g1t has open or is making. */
187function PullRow({ pull, base }: { pull: UpdatePull; base: string }) {
188 const icon =
189 pull.state === "needs_code" ? (
190 <Wrench size={13} className="text-warn" />
191 ) : pull.state === "requested" ? (
192 <Loader size={13} className="text-info" />
193 ) : (
194 <GitPullRequest size={13} className="text-accent" />
195 );
196 const count = pull.dependencies.length;
197 return (
198 <li className="flex items-start gap-2 py-2">
199 <span className="mt-0.5 shrink-0">{icon}</span>
200 <div className="min-w-0 flex-1">
201 <p className="text-sm">
202 {pull.pull ? (
203 <Link to={`${base}/pulls/${pull.pull}`} className="hover:underline">
204 {pull.title} <span className="text-muted">#{pull.pull}</span>
205 </Link>
206 ) : (
207 pull.title
208 )}
209 </p>
210 <p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
211 <Badge tone={PULL_TONES[pull.state]}>{PULL_STATES[pull.state]}</Badge>
212 <span>{pull.kind === "security" ? "Security update" : "Version update"}</span>
213 <span>
214 {count} {count === 1 ? "dependency" : "dependencies"}
215 </span>
216 {pull.mergeRequestedBy && (
217 <span className="inline-flex items-center gap-1">
218 <GitMerge size={11} />
219 Merges when its checks pass, as @{pull.mergeRequestedBy} asked
220 </span>
221 )}
222 </p>
223 {pull.error && <p className="mt-0.5 text-xs text-danger wrap-anywhere">{pull.error}</p>}
224 </div>
225 </li>
226 );
227}
228
229export function DependencyUpdates({
230 state,
231 action,
232 base,
233 canCheck,
234 titled = true,
235}: {
236 state: VersionUpdatesState;
237 action: string;
238 base: string;
239 canCheck: boolean;
240 /** Its own title and what it is for; off where the page says so above it. */
241 titled?: boolean;
242}) {
243 const pulls = livePulls(state.pulls);
244 const fileLink = state.path && state.commit ? `${base}/blob/${state.commit}/${state.path}` : null;
245 return (
246 <div className="rounded-xl border border-line bg-surface p-4">
247 {titled && (
248 <>
249 <div className="flex flex-wrap items-center justify-between gap-2">
250 <span className="text-sm font-medium">Dependency updates</span>
251 <a
252 href={DEPENDENCY_UPDATES_DOCS}
253 target="_blank"
254 rel="noreferrer"
255 className="inline-flex items-center gap-1 text-xs text-fg-soft hover:text-fg hover:underline"
256 >
257 How to write the file
258 <ExternalLink size={10} />
259 </a>
260 </div>
261 <p className="mt-1 text-sm text-muted">
262 Pull requests that keep dependencies current on a schedule, from a <code className="text-fg-soft">dependabot.yml</code>{" "}
263 file (version 2) in <code className="text-fg-soft">.g1t/</code> or <code className="text-fg-soft">.github/</code>. Security
264 updates follow the same file.
265 </p>
266 </>
267 )}
268
269 <div className={`${titled ? "mt-3 " : ""}flex items-start gap-1.5 text-xs text-muted`}>
270 {state.found && state.path ? (
271 <>
272 {state.problems.length > 0 ? (
273 <FileWarning size={13} className="shrink-0 text-danger" />
274 ) : (
275 <FileCode size={13} className="shrink-0 text-accent" />
276 )}
277 <span>
278 Reading{" "}
279 {fileLink ? (
280 <Link to={fileLink} className="font-mono text-fg-soft hover:underline">
281 {state.path}
282 </Link>
283 ) : (
284 <span className="font-mono text-fg-soft">{state.path}</span>
285 )}
286 {state.readAt && (
287 <>
288 , read <TimeAgo at={state.readAt} />
289 </>
290 )}
291 {state.problems.length === 0 && (
292 <>
293 : {state.updates.length} {state.updates.length === 1 ? "entry" : "entries"}
294 </>
295 )}
296 </span>
297 </>
298 ) : (
299 <>
300 <CircleDot size={13} className="shrink-0 text-faint" />
301 <span>
302 No <code>dependabot.yml</code> on the default branch.
303 </span>
304 </>
305 )}
306 </div>
307 {state.ignoredPaths.length > 0 && (
308 <p className="mt-1 flex items-start gap-1.5 text-xs text-warn">
309 <Info size={12} className="mt-0.5 shrink-0" />
310 <span>
311 Also found {state.ignoredPaths.map((path) => <code key={path}>{path} </code>)}
312 and ignored it: the file under <code>.g1t/</code> is read in its place.
313 </span>
314 </p>
315 )}
316
317 {state.problems.length > 0 && (
318 <div className="mt-3 rounded-lg border border-danger/40 bg-danger/5 p-3">
319 <p className="text-xs font-medium text-danger">
320 {state.problems.length === 1 ? "1 problem" : `${state.problems.length} problems`}. Nothing is updated from this file until
321 {state.problems.length === 1 ? " it is" : " they are"} fixed.
322 </p>
323 <ul className="mt-2 space-y-1 text-xs">
324 {state.problems.map((problem) => (
325 <li key={`${problem.line}:${problem.column}:${problem.key}:${problem.message}`} className="flex gap-2">
326 <span className="w-14 shrink-0 font-mono text-faint tabular-nums">
327 {problem.line > 0 ? `Line ${problem.line}` : "File"}
328 </span>
329 <span className="min-w-0 text-fg/90 wrap-anywhere">
330 {problem.key && <code className="mr-1 text-fg-soft">{problem.key}</code>}
331 <Said text={problem.message} />
332 </span>
333 </li>
334 ))}
335 </ul>
336 </div>
337 )}
338
339 {state.updates.length > 0 && (
340 <section className="mt-4">
341 <Heading>Entries</Heading>
342 <ul className="mt-2 space-y-2">
343 {state.updates.map((entry) => (
344 <Entry key={entry.id} entry={entry} action={action} canCheck={canCheck && state.problems.length === 0} />
345 ))}
346 </ul>
347 </section>
348 )}
349
350 {state.registries.length > 0 && (
351 <section className="mt-4">
352 <Heading>Private registries</Heading>
353 <ul className="mt-2 divide-y divide-line/60 text-xs">
354 {state.registries.map((registry) => (
355 <li key={registry.name} className="flex flex-wrap items-center gap-x-3 gap-y-0.5 py-1.5">
356 <span className="font-mono text-fg-soft">{registry.name}</span>
357 <span className="text-faint">{registry.kind}</span>
358 <span className="min-w-0 font-mono text-muted wrap-anywhere">{registry.url}</span>
359 {registry.secrets.length > 0 && (
360 <span className="inline-flex items-center gap-1 text-muted">
361 <KeyRound size={11} />
362 {registry.secrets.join(", ")}
363 </span>
364 )}
365 </li>
366 ))}
367 </ul>
368 </section>
369 )}
370
371 <section className="mt-4">
372 <Heading>Update pull requests</Heading>
373 {pulls.length > 0 ? (
374 <ul className="mt-1 divide-y divide-line/60">
375 {pulls.map((pull) => (
376 <PullRow key={`${pull.branch}:${pull.updatedAt}`} pull={pull} base={base} />
377 ))}
378 </ul>
379 ) : (
380 <p className="mt-2 text-xs text-muted">None open.</p>
381 )}
382 </section>
383
384 {state.ignores.length > 0 && (
385 <section className="mt-4">
386 <Heading>Ignored in comments</Heading>
387 <ul className="mt-2 space-y-1 text-xs">
388 {state.ignores.map((condition) => (
389 <li
390 key={`${condition.ecosystem}:${condition.dependency}:${conditionText(condition)}`}
391 className="flex flex-wrap items-center gap-x-2 gap-y-0.5"
392 >
393 <EyeOff size={11} className="shrink-0 text-faint" />
394 <span className="font-mono text-fg-soft">{condition.dependency}</span>
395 <span className="text-muted">{conditionText(condition)}</span>
396 <span className="text-faint">
397 {condition.ecosystem} · @{condition.by}
398 {condition.pull != null && (
399 <>
400 {" "}
401 in{" "}
402 <Link to={`${base}/pulls/${condition.pull}`} className="hover:underline">
403 #{condition.pull}
404 </Link>
405 </>
406 )}
407 </span>
408 </li>
409 ))}
410 </ul>
411 <p className="mt-2 text-xs text-faint">
412 Comment <code>@g1t unignore &lt;dependency&gt;</code> on an update pull request to undo one.
413 </p>
414 </section>
415 )}
416 </div>
417 );
418}