Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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