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