Skip to content
643 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1/**
2 * The security suite's pieces, shared by a project's Security sections and
3 * the workspace's: the activation prompt, filters, code scanning alerts,
4 * the trend chart and coverage table, the custom pattern editor, and the
5 * bypass and "Fix with g1t" forms. Each form posts an `intent` to the page
6 * it is on.
7 */
8import { Bot, CircleCheck, CircleDot, CircleSlash, FileCode2, Lock, ShieldCheck, Sparkles } from "lucide-react";
9import { type ReactNode, useEffect, useState } from "react";
10import { Link, useFetcher } from "react-router";
11
12import {
13 type AlertState,
14 BYPASS_REASONS,
15 type CodeAlert,
16 type DryRun,
17 type RepoCoverage,
18 type SavedPattern,
19 type Severity,
20 type SeverityCounts,
21 type TrendPoint,
22} from "@g1t/contracts";
23
24import { total, trendMax } from "../lib/security-suite";
25import { SeverityBadge } from "./security";
26import { TimeAgo } from "./ui";
27import { Badge } from "./ui/badge";
Merge branch 'worktree-agent-ae1299e92e4462012'28import { Hint } from "./ui/hint";
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar29import { RadioGroup, RadioOption } from "./ui/radio-group";
30import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
31
32type Done = { ok: boolean; error?: string } | undefined;
33
34export const CARD = "rounded-xl border border-line bg-surface";
35export const LIST = "divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface";
36const INPUT =
37 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
38const SMALL_BUTTON =
39 "rounded-md border border-line px-2.5 py-1 text-xs font-medium text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-50";
You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how.40const PRIMARY = "rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg transition-colors hover:bg-fg-hover disabled:opacity-50";
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar41
42/** A section's title, what it is for, and what can be done there. */
43export function SectionHeader({ title, about, actions }: { title: string; about: ReactNode; actions?: ReactNode }) {
44 return (
45 <div className="flex flex-wrap items-start justify-between gap-4">
46 <div className="min-w-0 max-w-2xl">
47 <h2 className="text-xl font-semibold tracking-tight">{title}</h2>
48 <p className="mt-1.5 text-sm text-muted">{about}</p>
49 </div>
50 {actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
51 </div>
52 );
53}
54
55/**
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.56 * What a private repository's paid feature needs: the g1t plan, which
57 * Security and quality comes with at no price of its own, and who can
58 * start it. No pressure: what stays free is said too.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar59 */
60export function ActivationPrompt({
61 workspace,
62 feature,
63 monthlyCents,
64 isOwner,
65}: {
66 workspace: string;
67 feature: string;
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.68 /** The g1t plan's monthly price, from billing; null when it could not be read. */
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar69 monthlyCents: number | null;
70 isOwner: boolean;
71}) {
72 const price = monthlyCents == null ? null : `$${(monthlyCents / 100).toFixed(monthlyCents % 100 ? 2 : 0)} a month`;
73 return (
74 <div className={`${CARD} flex flex-col gap-4 p-5 sm:flex-row sm:items-start`}>
75 <Lock size={18} className="mt-0.5 shrink-0 text-accent" />
76 <div className="min-w-0 grow">
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.77 <p className="font-medium">{feature} on private repositories comes with the g1t plan</p>
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar78 <p className="mt-1.5 text-sm text-muted">
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.79 Security and quality has no price of its own. With the plan{price ? ` (${price} for the workspace)` : ""}, custom patterns,
80 validity checks, delegated bypass, code scanning, dependency review and the security overview are on for every private
81 repository in {workspace}, and their scans are charged at cost plus 20%, like everything g1t runs. Fixes by g1t's agent
82 are charged as agent usage. Public repositories have all of it free, and secret scanning, push protection, vulnerability
83 alerts and security updates stay free everywhere.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar84 </p>
85 <div className="mt-3 flex flex-wrap items-center gap-3 text-sm">
86 {isOwner ? (
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.87 <Link to={`/${workspace}/-/billing`} className={PRIMARY}>
88 Start the plan
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar89 </Link>
90 ) : (
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.91 <span className="text-muted">An owner of {workspace} can start the plan in Billing.</span>
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar92 )}
93 <a href="https://docs.g1t.sh/guides/security/pricing/" className="text-muted underline underline-offset-2 hover:text-fg">
94 What's free and what's paid
95 </a>
96 </div>
97 </div>
98 </div>
99 );
100}
101
102/** A labelled select that changes a filter in the address. */
103export function FilterSelect({
104 label,
105 value,
106 options,
107 onChange,
108}: {
109 label: string;
110 value: string;
111 options: [string, string][];
112 onChange: (value: string) => void;
113}) {
114 return (
115 <label className="flex min-w-0 flex-col gap-1 text-xs text-muted">
116 {label}
117 <Select value={value} onValueChange={onChange}>
118 <SelectTrigger size="sm" className="min-w-36" aria-label={label}>
119 <SelectValue />
120 </SelectTrigger>
121 <SelectContent>
122 {options.map(([option, text]) => (
123 <SelectItem key={option} value={option}>
124 {text}
125 </SelectItem>
126 ))}
127 </SelectContent>
128 </Select>
129 </label>
130 );
131}
132
133const STATE_ICON: Record<AlertState, ReactNode> = {
134 open: <CircleDot size={14} />,
135 dismissed: <CircleSlash size={14} />,
136 fixed: <CircleCheck size={14} />,
137};
138
139/** A code scanning alert in a list. */
140export function CodeAlertItem({ alert, base }: { alert: CodeAlert; base: string }) {
141 const where = alert.path ? `${alert.path}${alert.startLine ? `:${alert.startLine}` : ""}` : null;
142 return (
143 <li className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-start">
144 <span className={`mt-0.5 hidden shrink-0 sm:block ${alert.state === "open" ? "text-warn" : "text-muted"}`}>{STATE_ICON[alert.state]}</span>
145 <div className="min-w-0 grow">
146 <div className="flex flex-wrap items-center gap-2">
147 <Link to={`${base}/security/code-scanning/${alert.number}`} className="min-w-0 text-sm font-medium break-words hover:underline">
148 {alert.ruleName && alert.ruleName !== alert.ruleId ? alert.ruleName : shortRule(alert.ruleId)}
149 </Link>
150 <SeverityBadge severity={alert.severity} />
151 {alert.issue != null && (
152 <Link to={`${base}/issues/${alert.issue}`}>
153 <Badge tone="accent">
154 <Bot size={11} /> g1t on #{alert.issue}
155 </Badge>
156 </Link>
157 )}
158 </div>
159 <p className="mt-1 line-clamp-2 text-xs text-muted">{alert.message}</p>
160 <p className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-faint">
161 <span>#{alert.number}</span>
162 <span>{alert.tool}</span>
163 {where && <span className="truncate font-mono">{where}</span>}
164 <span>
165 {alert.state === "fixed" && alert.fixedAt ? (
166 <>
167 fixed <TimeAgo at={alert.fixedAt} />
168 </>
169 ) : (
170 <>
171 found <TimeAgo at={alert.createdAt} />
172 </>
173 )}
174 </span>
175 </p>
176 </div>
177 </li>
178 );
179}
180
181/** The last part of a dotted rule id: Semgrep's are long. */
182export function shortRule(ruleId: string): string {
183 const parts = ruleId.split(".");
184 return parts.length > 2 ? parts[parts.length - 1] : ruleId;
185}
186
187/** Open counts as compact words: "3 critical · 1 high". */
188export function countsLine(counts: SeverityCounts): string {
189 const parts = (["critical", "high", "medium", "low"] as Severity[]).filter((s) => counts[s] > 0).map((s) => `${counts[s]} ${s}`);
190 return parts.length ? parts.join(" · ") : "none open";
191}
192
193// The trend's three series, each its own hue, validated for the dark
194// surface (dataviz: categorical slots 1–3, dark steps).
195const SERIES = [
196 { key: "secretScanning", label: "Secrets", color: "#3987e5" },
197 { key: "codeScanning", label: "Code scanning", color: "#d95926" },
198 { key: "vulnerability", label: "Vulnerabilities", color: "#199e70" },
199] as const;
200
201/** Open alerts by type each day, as stacked bars with a legend and a table. */
202export function TrendChart({ points }: { points: TrendPoint[] }) {
203 const [hover, setHover] = useState<number | null>(null);
204 const max = trendMax(points);
205 const width = 640;
206 const height = 140;
207 const gap = 2;
208 const bar = Math.max(2, width / Math.max(points.length, 1) - gap);
209 const shown = hover != null ? points[hover] : points[points.length - 1];
210 return (
211 <figure className={`${CARD} p-4`}>
212 <figcaption className="flex flex-wrap items-baseline justify-between gap-2">
213 <span className="text-sm font-medium">Open alerts, by day</span>
214 <span className="text-xs text-muted" aria-live="polite">
215 {shown
216 ? `${shown.day}: ${SERIES.map((s) => `${shown[s.key]} ${s.label.toLowerCase()}`).join(", ")}`
217 : "No days yet"}
218 </span>
219 </figcaption>
220 <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted" aria-label="Legend">
221 {SERIES.map((series) => (
222 <li key={series.key} className="flex items-center gap-1.5">
223 <span aria-hidden="true" className="size-2.5 rounded-sm" style={{ background: series.color }} />
224 {series.label}
225 </li>
226 ))}
227 </ul>
228 <svg
229 viewBox={`0 0 ${width} ${height}`}
230 preserveAspectRatio="none"
231 className="mt-3 h-36 w-full"
232 role="img"
233 aria-label="Open alerts by type each day; the table below has the numbers"
234 onMouseLeave={() => setHover(null)}
235 >
236 <line x1={0} x2={width} y1={height - 0.5} y2={height - 0.5} stroke="currentColor" className="text-line" />
237 {points.map((point, at) => {
238 const x = at * (bar + gap);
239 let y = height;
240 return (
241 <g key={point.day} onMouseEnter={() => setHover(at)}>
242 {/* A hit target the full height of the day. */}
243 <rect x={x} y={0} width={bar + gap} height={height} fill="transparent" />
244 {SERIES.map((series) => {
245 const value = point[series.key];
246 if (!value) return null;
247 const h = Math.max(1, (value / max) * (height - 8));
248 y -= h;
249 const rect = <rect key={series.key} x={x} y={y} width={bar} height={Math.max(0, h - gap)} rx={Math.min(2, bar / 2)} fill={series.color} opacity={hover == null || hover === at ? 1 : 0.55} />;
250 return rect;
251 })}
252 <title>{`${point.day}: ${SERIES.map((s) => `${point[s.key]} ${s.label.toLowerCase()}`).join(", ")}`}</title>
253 </g>
254 );
255 })}
256 </svg>
257 <details className="mt-2">
258 <summary className="cursor-pointer text-xs text-muted hover:text-fg">Show as a table</summary>
259 <div className="mt-2 max-h-56 overflow-auto">
260 <table className="w-full text-xs tabular-nums">
261 <thead className="text-left text-muted">
262 <tr>
263 <th className="py-1 font-medium">Day</th>
264 {SERIES.map((series) => (
265 <th key={series.key} className="py-1 text-right font-medium">
266 {series.label}
267 </th>
268 ))}
269 </tr>
270 </thead>
271 <tbody>
272 {[...points].reverse().map((point) => (
273 <tr key={point.day} className="border-t border-line">
274 <td className="py-1">{point.day}</td>
275 {SERIES.map((series) => (
276 <td key={series.key} className="py-1 text-right">
277 {point[series.key]}
278 </td>
279 ))}
280 </tr>
281 ))}
282 </tbody>
283 </table>
284 </div>
285 </details>
286 </figure>
287 );
288}
289
290function On({ on, children }: { on: boolean; children?: ReactNode }) {
291 return on ? (
Merge branch 'worktree-agent-ad7c6d88d93adc817'292 <span className="inline-flex items-center gap-1 text-success">
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar293 <CircleCheck size={13} aria-hidden="true" />
294 {children ?? "On"}
295 </span>
296 ) : (
297 <span className="text-faint">{children ? children : "Off"}</span>
298 );
299}
300
301/** Which repository has which feature on, and what is open in each. */
302export function CoverageTable({ repos, owner }: { repos: RepoCoverage[]; owner: string }) {
303 if (repos.length === 0) {
304 return (
305 <p className="rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">
306 No repository has been scanned yet. Each one is scanned on its next push to its default branch, or when its Security page
307 is first opened.
308 </p>
309 );
310 }
311 return (
312 <div className={`${CARD} overflow-x-auto`}>
313 <table className="w-full min-w-[46rem] text-sm">
314 <thead className="text-left text-xs text-muted">
315 <tr className="border-b border-line">
316 <th className="px-4 py-2.5 font-medium">Repository</th>
317 <th className="px-3 py-2.5 font-medium">Open</th>
318 <th className="px-3 py-2.5 font-medium">Push protection</th>
319 <th className="px-3 py-2.5 font-medium">Custom patterns</th>
320 <th className="px-3 py-2.5 font-medium">Code scanning</th>
321 <th className="px-3 py-2.5 font-medium">Dependency review</th>
322 <th className="px-3 py-2.5 font-medium">Security updates</th>
323 </tr>
324 </thead>
325 <tbody>
326 {repos.map((repo) => {
327 const open = total(repo.secrets) + total(repo.code) + total(repo.vulnerabilities);
328 const critical = repo.secrets.critical + repo.code.critical + repo.vulnerabilities.critical;
329 const high = repo.secrets.high + repo.code.high + repo.vulnerabilities.high;
330 return (
331 <tr key={repo.repoId} className="border-b border-line last:border-0">
332 <td className="px-4 py-2.5">
333 <Link to={`/${owner}/${repo.name}/security`} className="font-mono text-sm font-medium hover:underline">
334 {repo.name}
335 </Link>
336 {repo.private && <span className="ml-2 text-xs text-faint">private</span>}
337 </td>
338 <td className="px-3 py-2.5 text-xs whitespace-nowrap">
339 {open === 0 ? (
Merge branch 'worktree-agent-ad7c6d88d93adc817'340 <span className="inline-flex items-center gap-1 text-success">
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar341 <ShieldCheck size={13} /> None
342 </span>
343 ) : (
344 <span className="flex flex-wrap gap-1">
345 {critical > 0 && <Badge tone="danger">{critical} critical</Badge>}
346 {high > 0 && <Badge tone="warn">{high} high</Badge>}
347 {open - critical - high > 0 && <Badge>{open - critical - high} other</Badge>}
348 </span>
349 )}
350 </td>
351 <td className="px-3 py-2.5 text-xs">
352 <On on>On</On>
353 </td>
354 <td className="px-3 py-2.5 text-xs tabular-nums">
355 <On on={repo.customPatterns > 0}>{repo.customPatterns > 0 ? `${repo.customPatterns}` : "None"}</On>
356 </td>
357 <td className="px-3 py-2.5 text-xs">
358 {repo.codeScanningAt ? (
Merge branch 'worktree-agent-ad7c6d88d93adc817'359 <span className="text-success">
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar360 <TimeAgo at={repo.codeScanningAt} />
361 </span>
362 ) : (
363 <span className="text-faint">Not set up</span>
364 )}
365 </td>
366 <td className="px-3 py-2.5 text-xs">
367 <On on={repo.dependencyReview && repo.lockfiles > 0}>{repo.lockfiles === 0 ? "No lockfiles" : undefined}</On>
368 </td>
369 <td className="px-3 py-2.5 text-xs">
370 <On on={repo.securityUpdates} />
371 </td>
372 </tr>
373 );
374 })}
375 </tbody>
376 </table>
377 </div>
378 );
379}
380
381/** "Fix with g1t": an issue for g1t, as the person asking. */
382export function FixWithG1t({ id, action, issue, base }: { id: string; action: string; issue: number | null; base: string }) {
383 const fetcher = useFetcher<{ ok: boolean; error?: string; issue?: number; message?: string | null }>();
384 const opened = fetcher.data?.issue ?? issue;
385 if (opened != null) {
386 return (
387 <Link to={`${base}/issues/${opened}`} className={`${SMALL_BUTTON} inline-flex items-center gap-1.5`}>
388 <Bot size={13} /> g1t is on #{opened}
389 </Link>
390 );
391 }
392 return (
393 <span className="flex flex-col items-end gap-1">
Merge branch 'worktree-agent-ae1299e92e4462012'394 <Hint label="Opens an issue assigned to g1t; its run is charged as agent usage">
395 <button
396 type="button"
397 disabled={fetcher.state !== "idle"}
398 onClick={() => fetcher.submit({ intent: "fix", id }, { method: "post", action })}
399 className={`${SMALL_BUTTON} inline-flex items-center gap-1.5`}
400 >
401 <Sparkles size={13} />
402 {fetcher.state !== "idle" ? "Opening…" : "Fix with g1t"}
403 </button>
404 </Hint>
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar405 {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
406 </span>
407 );
408}
409
410/** Pushing past push protection with a reason, or asking to. */
411export function BypassForm({ id, action, request }: { id: string; action: string; request: boolean }) {
412 const fetcher = useFetcher<Done & { requested?: boolean }>();
413 const [reason, setReason] = useState("");
414 if (fetcher.data?.ok) {
415 return (
Merge branch 'worktree-agent-ad7c6d88d93adc817'416 <p className="rounded-md border border-success/30 bg-success/5 px-3 py-2 text-sm text-success">
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar417 {fetcher.data.requested
418 ? "Asked. The workspace's owners and the repository's admins were told; push again once one approves."
419 : "Bypassed. Push again and it goes through."}
420 </p>
421 );
422 }
423 return (
424 <fetcher.Form method="post" action={action} className="space-y-3">
425 <input type="hidden" name="intent" value="bypass" />
426 <input type="hidden" name="id" value={id} />
427 <fieldset>
428 <legend className="text-xs font-medium text-muted">Why does it need to go through?</legend>
429 <RadioGroup name="reason" value={reason} onValueChange={setReason} required className="mt-2 gap-3">
430 {BYPASS_REASONS.map((option) => (
431 <RadioOption key={option.reason} value={option.reason} label={option.label} description={option.about} />
432 ))}
433 </RadioGroup>
434 </fieldset>
435 <label className="block">
436 <span className="mb-1.5 block text-xs font-medium text-muted">Comment (optional)</span>
437 <textarea name="comment" rows={2} maxLength={500} className={INPUT} placeholder="What a reviewer should know." />
438 </label>
439 <div className="flex flex-wrap items-center gap-3">
440 <button type="submit" disabled={fetcher.state !== "idle" || !reason} className={PRIMARY}>
441 {fetcher.state !== "idle" ? "Sending…" : request ? "Ask to bypass" : "Bypass push protection"}
442 </button>
443 <span className="text-xs text-muted">Recorded with your name and reason, on the alert and in the audit log.</span>
444 </div>
445 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
446 </fetcher.Form>
447 );
448}
449
450export type PatternDraft = {
451 id?: string;
452 name: string;
453 pattern: string;
454 before: string;
455 after: string;
456 testStrings: string;
457 published: boolean;
458};
459
460/** Where a test string matched, the match marked. */
461function TestResult({ text, found }: { text: string; found: [number, number] | null }) {
462 if (!found) {
463 return (
464 <li className="flex items-start gap-2 font-mono text-xs break-all">
465 <CircleSlash size={13} className="mt-0.5 shrink-0 text-faint" />
466 <span className="text-muted">{text}</span>
467 </li>
468 );
469 }
470 const chars = [...text];
471 return (
472 <li className="flex items-start gap-2 font-mono text-xs break-all">
Merge branch 'worktree-agent-ad7c6d88d93adc817'473 <CircleCheck size={13} className="mt-0.5 shrink-0 text-success" />
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar474 <span>
475 {chars.slice(0, found[0]).join("")}
476 <mark className="rounded-sm bg-accent/25 text-fg">{chars.slice(found[0], found[1]).join("")}</mark>
477 {chars.slice(found[1]).join("")}
478 </span>
479 </li>
480 );
481}
482
483/**
484 * Making or changing a custom pattern: its regular expression, what comes
485 * before and after it, test strings, a dry run over the default branch,
486 * and saving it as a draft or published.
487 */
488export function PatternEditor({ draft, action, onDone }: { draft: PatternDraft; action: string; onDone?: () => void }) {
489 const save = useFetcher<{ ok: boolean; error?: string; saved?: SavedPattern }>();
490 const dry = useFetcher<{ ok: boolean; error?: string; dryRun?: DryRun }>();
491 const [form, setForm] = useState(draft);
492 useEffect(() => {
493 if (save.state === "idle" && save.data?.ok && onDone) onDone();
494 }, [save.state, save.data, onDone]);
495 const set = (key: keyof PatternDraft) => (event: { target: { value: string } }) => setForm({ ...form, [key]: event.target.value });
496 const fields = (publish: boolean) => ({
497 intent: "save_pattern",
498 id: form.id ?? "",
499 name: form.name,
500 pattern: form.pattern,
501 before: form.before,
502 after: form.after,
503 testStrings: form.testStrings,
504 publish: String(publish),
505 });
506 const tests = form.testStrings.split("\n").filter((line) => line.trim());
507 const saved = save.data?.saved;
508 return (
509 <div className="space-y-4">
510 <div className="grid gap-4 sm:grid-cols-2">
511 <label className="block">
512 <span className="mb-1.5 block text-xs font-medium text-muted">Name</span>
513 <input value={form.name} onChange={set("name")} maxLength={100} placeholder="Acme API key" className={INPUT} />
514 </label>
515 <label className="block sm:col-span-2">
516 <span className="mb-1.5 block text-xs font-medium text-muted">Secret format (regular expression)</span>
517 <input
518 value={form.pattern}
519 onChange={set("pattern")}
520 maxLength={1000}
521 spellCheck={false}
522 placeholder="acme_[a-z0-9]{32}"
523 className={`${INPUT} font-mono`}
524 />
525 </label>
526 <label className="block">
527 <span className="mb-1.5 block text-xs font-medium text-muted">Before the secret (optional)</span>
528 <input value={form.before} onChange={set("before")} spellCheck={false} placeholder="\A|[^0-9A-Za-z]" className={`${INPUT} font-mono`} />
529 </label>
530 <label className="block">
531 <span className="mb-1.5 block text-xs font-medium text-muted">After the secret (optional)</span>
532 <input value={form.after} onChange={set("after")} spellCheck={false} placeholder="\z|[^0-9A-Za-z]" className={`${INPUT} font-mono`} />
533 </label>
534 <label className="block sm:col-span-2">
535 <span className="mb-1.5 block text-xs font-medium text-muted">Test strings, one a line</span>
536 <textarea value={form.testStrings} onChange={set("testStrings")} rows={3} spellCheck={false} className={`${INPUT} font-mono`} />
537 </label>
538 </div>
539 <p className="text-xs text-muted">
540 Patterns use the Rust regex syntax, which runs in time linear in the text: no look-around or back-references. Before and
541 after default to a line edge or a character that is not a letter or digit.
542 </p>
543 {saved && tests.length > 0 && (
544 <ul className="space-y-1">
545 {tests.map((text, at) => (
546 <TestResult key={at} text={text} found={saved.tests[at] ?? null} />
547 ))}
548 </ul>
549 )}
550 <div className="flex flex-wrap items-center gap-2">
551 <button
552 type="button"
553 disabled={save.state !== "idle" || !form.name.trim() || !form.pattern.trim()}
554 onClick={() => save.submit(fields(true), { method: "post", action })}
555 className={PRIMARY}
556 >
557 {save.state !== "idle" ? "Saving…" : form.published ? "Save" : "Publish"}
558 </button>
559 <button
560 type="button"
561 disabled={save.state !== "idle" || !form.name.trim() || !form.pattern.trim()}
562 onClick={() => save.submit(fields(false), { method: "post", action })}
563 className={SMALL_BUTTON}
564 >
565 {form.published ? "Unpublish, keep as draft" : "Save as draft"}
566 </button>
567 <button
568 type="button"
569 disabled={dry.state !== "idle" || !form.pattern.trim()}
570 onClick={() => dry.submit({ intent: "dry_run", pattern: form.pattern, before: form.before, after: form.after }, { method: "post", action })}
571 className={SMALL_BUTTON}
572 >
573 {dry.state !== "idle" ? "Running…" : "Dry run"}
574 </button>
575 {form.id && (
576 <button
577 type="button"
578 disabled={save.state !== "idle"}
579 onClick={() => {
580 if (confirm(`Delete "${form.name}"? The alerts it found stay.`)) save.submit({ intent: "delete_pattern", id: form.id ?? "" }, { method: "post", action });
581 }}
582 className="ml-auto rounded-md border border-danger/40 px-2.5 py-1 text-xs font-medium text-danger hover:bg-danger/10 disabled:opacity-50"
583 >
584 Delete
585 </button>
586 )}
587 </div>
588 {save.data?.error && <p className="text-sm text-danger">{save.data.error}</p>}
Merge branch 'worktree-agent-ad7c6d88d93adc817'589 {save.data?.ok && (saved ? <p className="text-sm text-success">Saved{saved.pattern.state === "published" ? " and published: the history is scanned again for it" : " as a draft"}.</p> : <p className="text-sm text-success">Deleted. The alerts it found stay.</p>)}
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar590 {dry.data?.error && <p className="text-sm text-danger">{dry.data.error}</p>}
591 {dry.data?.dryRun && <DryRunResults dryRun={dry.data.dryRun} />}
592 </div>
593 );
594}
595
596function DryRunResults({ dryRun }: { dryRun: DryRun }) {
597 return (
598 <div className="space-y-3">
599 {dryRun.repos.map((repo) => (
600 <section key={repo.name} className={`${CARD} p-3`}>
601 <p className="text-sm">
602 <span className="font-mono font-medium">{repo.name}</span>
603 <span className="text-muted">
604 {" "}
605 · {repo.matches.length} {repo.matches.length === 1 ? "match" : "matches"} in {repo.filesScanned} files
606 {repo.truncated ? " (stopped early)" : ""}
607 </span>
608 </p>
609 {repo.matches.length > 0 && (
610 <ul className="mt-2 space-y-1">
611 {repo.matches.map((match, at) => (
612 <li key={at} className="font-mono text-xs break-all">
613 <span className="inline-flex items-center gap-1 text-fg-soft">
614 <FileCode2 size={12} />
615 {match.path}:{match.line}
616 </span>
617 <span className="text-faint"> {match.preview}</span>
618 </li>
619 ))}
620 </ul>
621 )}
622 </section>
623 ))}
624 </div>
625 );
626}
627
628/** Reads a pattern form's fields, as the pages' actions take them. */
629export function patternFields(form: FormData) {
630 const text = (key: string) => String(form.get(key) ?? "");
631 return {
632 id: text("id") || undefined,
633 name: text("name").trim(),
634 pattern: text("pattern"),
635 before: text("before").trim() || null,
636 after: text("after").trim() || null,
637 testStrings: text("testStrings")
638 .split("\n")
639 .map((line) => line.replace(/\r$/, ""))
640 .filter((line) => line.trim()),
641 publish: text("publish") === "true",
642 };
643}

This file's history is long; its oldest lines are credited to the oldest commit read.