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 bar | 1 | /** |
| 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 | */ | |
| 8 | import { Bot, CircleCheck, CircleDot, CircleSlash, FileCode2, Lock, ShieldCheck, Sparkles } from "lucide-react"; | |
| 9 | import { type ReactNode, useEffect, useState } from "react"; | |
| 10 | import { Link, useFetcher } from "react-router"; | |
| 11 | ||
| 12 | import { | |
| 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 | ||
| 24 | import { total, trendMax } from "../lib/security-suite"; | |
| 25 | import { SeverityBadge } from "./security"; | |
| 26 | import { TimeAgo } from "./ui"; | |
| 27 | import { Badge } from "./ui/badge"; | |
| Merge branch 'worktree-agent-ae1299e92e4462012' | 28 | import { Hint } from "./ui/hint"; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 29 | import { RadioGroup, RadioOption } from "./ui/radio-group"; |
| 30 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 31 | ||
| 32 | type Done = { ok: boolean; error?: string } | undefined; | |
| 33 | ||
| 34 | export const CARD = "rounded-xl border border-line bg-surface"; | |
| 35 | export const LIST = "divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"; | |
| 36 | const 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"; | |
| 38 | const 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. | 40 | const 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 bar | 41 | |
| 42 | /** A section's title, what it is for, and what can be done there. */ | |
| 43 | export 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 bar | 59 | */ |
| 60 | export 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 bar | 69 | 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 bar | 78 | <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 bar | 84 | </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 bar | 89 | </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 bar | 92 | )} |
| 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. */ | |
| 103 | export 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 | ||
| 133 | const 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. */ | |
| 140 | export 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. */ | |
| 182 | export 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". */ | |
| 188 | export 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). | |
| 195 | const 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. */ | |
| 202 | export 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 | ||
| 290 | function 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 bar | 293 | <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. */ | |
| 302 | export 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 bar | 341 | <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 bar | 360 | <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. */ | |
| 382 | export 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 bar | 405 | {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. */ | |
| 411 | export 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 bar | 417 | {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 | ||
| 450 | export 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. */ | |
| 461 | function 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 bar | 474 | <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 | */ | |
| 488 | export 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 bar | 590 | {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 | ||
| 596 | function 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. */ | |
| 629 | export 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.