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 | import { LoaderCircle, Plus, Search, Tag, Trash2 } from "lucide-react"; |
| 2 | import { useEffect, useRef, useState } from "react"; | |
| 3 | import { Form, Link, useActionData } from "react-router"; | |
| 4 | ||
| 5 | import type { Label } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import type { Route } from "./+types/labels"; | |
| 8 | import { LabelChip } from "../../components/labels"; | |
| 9 | import { Button, EmptyState, ErrorText, SubmitButton, usePending } from "../../components/ui"; | |
| 10 | import { | |
| 11 | AlertDialog, | |
| 12 | AlertDialogAction, | |
| 13 | AlertDialogCancel, | |
| 14 | AlertDialogContent, | |
| 15 | AlertDialogDescription, | |
| 16 | AlertDialogFooter, | |
| 17 | AlertDialogHeader, | |
| 18 | AlertDialogTitle, | |
| 19 | AlertDialogTrigger, | |
| 20 | } from "../../components/ui/alert-dialog"; | |
| 21 | import { refusal, requireRepo } from "../../lib/access.server"; | |
| 22 | import { matchLabels, tidyColor } from "../../lib/labels"; | |
| 23 | import { page } from "../../lib/meta"; | |
| 24 | import { work } from "../../lib/services.server"; | |
| 25 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| 26 | ||
| 27 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 28 | return page(args, { title: `Labels · ${params.owner}/${params.repo} · g1t` }); | |
| 29 | } | |
| 30 | ||
| 31 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 32 | const { viewer, access } = await requireRepo(context, params, "read"); | |
| 33 | const labels = await work.listLabels({ namespace: params.owner, name: params.repo }, viewer); | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 34 | return { labels: unwrap(labels), canEdit: access.can.manage_labels }; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 35 | } |
| 36 | ||
| 37 | type ActionData = { intent: string; name?: string; error?: string; done?: string }; | |
| 38 | ||
| 39 | export async function action({ request, params, context }: Route.ActionArgs): Promise<ActionData> { | |
| 40 | assertSameOrigin(request); | |
| 41 | const user = requireUser(context, request); | |
| 42 | const form = await request.formData(); | |
| 43 | const intent = String(form.get("intent") ?? ""); | |
| 44 | const name = String(form.get("name") ?? ""); | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 45 | // Labels are made, changed and deleted with the Write role; Triage applies them. |
| 46 | const refused = await refusal(context, params, "manage_labels"); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 47 | if (refused) return { intent, name, error: refused }; |
| 48 | const path = { namespace: params.owner, name: params.repo }; | |
| 49 | const text = (key: string) => String(form.get(key) ?? ""); | |
| 50 | switch (intent) { | |
| 51 | case "defaults": { | |
| 52 | const added = await work.addDefaultLabels(user, path); | |
| 53 | return added.ok ? { intent, done: "The default labels are here." } : { intent, error: added.error.message }; | |
| 54 | } | |
| 55 | case "delete": { | |
| 56 | const deleted = await work.deleteLabel(user, path, name); | |
| 57 | return deleted.ok ? { intent, name } : { intent, name, error: deleted.error.message }; | |
| 58 | } | |
| 59 | case "create": | |
| 60 | case "edit": { | |
| 61 | const saved = await work.saveLabel(user, path, { | |
| 62 | name: intent === "edit" ? name : undefined, | |
| 63 | newName: text("newName"), | |
| 64 | color: text("color"), | |
| 65 | description: text("description"), | |
| 66 | }); | |
| 67 | return saved.ok ? { intent, name: saved.value.name } : { intent, name, error: saved.error.message }; | |
| 68 | } | |
| 69 | default: | |
| 70 | return { intent, error: "Nothing to do." }; | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | /** A color's input: a swatch picker and its hex digits, kept together. */ | |
| 75 | function ColorField({ defaultValue }: { defaultValue: string }) { | |
| 76 | const [color, setColor] = useState(tidyColor(defaultValue) ?? "bfdadc"); | |
| 77 | return ( | |
| 78 | <label className="flex items-center gap-2"> | |
| 79 | <span className="sr-only">Color</span> | |
| 80 | <input | |
| 81 | type="color" | |
| 82 | value={`#${color}`} | |
| 83 | onChange={(event) => setColor(event.target.value.slice(1))} | |
| 84 | className="size-9 shrink-0 cursor-pointer rounded-md border border-line bg-bg p-1" | |
| 85 | aria-label="Pick a color" | |
| 86 | /> | |
| 87 | <input | |
| 88 | name="color" | |
| 89 | value={color} | |
| 90 | onChange={(event) => setColor(event.target.value.replace(/^#/, "").slice(0, 6))} | |
| 91 | pattern="[0-9a-fA-F]{6}" | |
| 92 | required | |
| 93 | aria-label="Color, six hex digits" | |
| 94 | autoComplete="off" | |
| 95 | className="h-9 w-24 rounded-md border border-line bg-bg px-2 font-mono text-sm outline-none hover:border-line-strong focus:border-accent-dim" | |
| 96 | /> | |
| 97 | </label> | |
| 98 | ); | |
| 99 | } | |
| 100 | ||
| 101 | /** Creating a label, or editing one: name, description and color. */ | |
| 102 | function LabelForm({ label, onDone }: { label?: Label; onDone: () => void }) { | |
| 103 | const intent = label ? "edit" : "create"; | |
| 104 | const result = useActionData<ActionData>(); | |
| 105 | const pending = usePending({ intent, name: label?.name ?? "" }); | |
| 106 | const mine = result?.intent === intent && (label ? result.name === label.name : true); | |
| 107 | // Saved: close the form, once this form's own save has come back. | |
| 108 | const sent = useRef(false); | |
| 109 | useEffect(() => { | |
| 110 | if (sent.current && !pending && mine && !result?.error) onDone(); | |
| 111 | sent.current = pending; | |
| 112 | }, [mine, result, pending, onDone]); | |
| 113 | return ( | |
| 114 | <Form method="post" className="grid gap-3 rounded-xl border border-line bg-surface p-4 sm:grid-cols-[1fr_1.4fr_auto] sm:items-end"> | |
| 115 | <input type="hidden" name="intent" value={intent} /> | |
| 116 | <input type="hidden" name="name" value={label?.name ?? ""} /> | |
| 117 | <label className="block"> | |
| 118 | <span className="mb-1.5 block text-xs font-medium text-muted">Name</span> | |
| 119 | <input | |
| 120 | name="newName" | |
| 121 | defaultValue={label?.name ?? ""} | |
| 122 | required | |
| 123 | maxLength={50} | |
| 124 | autoFocus | |
| 125 | autoComplete="off" | |
| 126 | data-1p-ignore | |
| 127 | placeholder="area: cli" | |
| 128 | className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none hover:border-line-strong focus:border-accent-dim" | |
| 129 | /> | |
| 130 | </label> | |
| 131 | <label className="block"> | |
| 132 | <span className="mb-1.5 block text-xs font-medium text-muted">Description</span> | |
| 133 | <input | |
| 134 | name="description" | |
| 135 | defaultValue={label?.description ?? ""} | |
| 136 | maxLength={100} | |
| 137 | autoComplete="off" | |
| 138 | data-1p-ignore | |
| 139 | placeholder="What it means (optional)" | |
| 140 | className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none hover:border-line-strong focus:border-accent-dim" | |
| 141 | /> | |
| 142 | </label> | |
| 143 | <div className="flex flex-wrap items-center gap-2"> | |
| 144 | <ColorField defaultValue={label?.color ?? ""} /> | |
| 145 | <Button type="button" variant="quiet" onClick={onDone}> | |
| 146 | Cancel | |
| 147 | </Button> | |
| 148 | <SubmitButton match={{ intent, name: label?.name ?? "" }} pending="Saving…"> | |
| 149 | {label ? "Save" : "Create label"} | |
| 150 | </SubmitButton> | |
| 151 | </div> | |
| 152 | {mine && result?.error && ( | |
| 153 | <div className="sm:col-span-3"> | |
| 154 | <ErrorText>{result.error}</ErrorText> | |
| 155 | </div> | |
| 156 | )} | |
| 157 | </Form> | |
| 158 | ); | |
| 159 | } | |
| 160 | ||
| 161 | function DeleteLabel({ label }: { label: Label }) { | |
| 162 | const deleting = usePending({ intent: "delete", name: label.name }); | |
| 163 | const carried = label.issues + label.pulls; | |
| 164 | return ( | |
| 165 | <AlertDialog> | |
| 166 | <AlertDialogTrigger asChild> | |
| 167 | <Button type="button" variant="quiet" disabled={deleting} aria-label={`Delete ${label.name}`}> | |
| 168 | {deleting ? <LoaderCircle size={14} className="animate-spin" aria-hidden="true" /> : <Trash2 size={14} />} | |
| 169 | <span className="hidden sm:inline">{deleting ? "Deleting…" : "Delete"}</span> | |
| 170 | </Button> | |
| 171 | </AlertDialogTrigger> | |
| 172 | <AlertDialogContent> | |
| 173 | <Form method="post" className="grid gap-4"> | |
| 174 | <input type="hidden" name="intent" value="delete" /> | |
| 175 | <input type="hidden" name="name" value={label.name} /> | |
| 176 | <AlertDialogHeader> | |
| 177 | <AlertDialogTitle>Delete the {label.name} label?</AlertDialogTitle> | |
| 178 | <AlertDialogDescription> | |
| 179 | {carried === 0 | |
| 180 | ? "Nothing carries it. " | |
| 181 | : `It comes off the ${label.issues} ${label.issues === 1 ? "issue" : "issues"} and ${label.pulls} pull ${label.pulls === 1 ? "request" : "requests"} that carry it. `} | |
| 182 | This cannot be undone. | |
| 183 | </AlertDialogDescription> | |
| 184 | </AlertDialogHeader> | |
| 185 | <AlertDialogFooter> | |
| 186 | <AlertDialogCancel>Cancel</AlertDialogCancel> | |
| 187 | <AlertDialogAction asChild> | |
| 188 | <button type="submit"> | |
| 189 | <Trash2 size={14} /> | |
| 190 | Delete label | |
| 191 | </button> | |
| 192 | </AlertDialogAction> | |
| 193 | </AlertDialogFooter> | |
| 194 | </Form> | |
| 195 | </AlertDialogContent> | |
| 196 | </AlertDialog> | |
| 197 | ); | |
| 198 | } | |
| 199 | ||
| 200 | export default function Labels({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 201 | const { labels, canEdit } = loaderData; | |
| 202 | const base = `/${params.owner}/${params.repo}`; | |
| 203 | const [query, setQuery] = useState(""); | |
| 204 | const [creating, setCreating] = useState(false); | |
| 205 | const [editing, setEditing] = useState<string | null>(null); | |
| 206 | const shown = matchLabels(labels, query); | |
| 207 | const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; | |
| 208 | return ( | |
| 209 | <div className="space-y-4"> | |
| 210 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 211 | <div> | |
| 212 | <h2 className="text-xl font-semibold tracking-tight">Labels</h2> | |
| 213 | <p className="mt-1 text-sm text-muted"> | |
| 214 | Issues and pull requests carry labels to say what they are. Filter either list by one. | |
| 215 | </p> | |
| 216 | </div> | |
| 217 | {canEdit && !creating && ( | |
| 218 | <div className="flex flex-wrap items-center gap-2"> | |
| 219 | <Form method="post"> | |
| 220 | <input type="hidden" name="intent" value="defaults" /> | |
| 221 | <SubmitButton variant="quiet" match={{ intent: "defaults" }} pending="Adding…"> | |
| 222 | Add the default labels | |
| 223 | </SubmitButton> | |
| 224 | </Form> | |
| 225 | <Button type="button" onClick={() => setCreating(true)}> | |
| 226 | <Plus size={15} /> | |
| 227 | New label | |
| 228 | </Button> | |
| 229 | </div> | |
| 230 | )} | |
| 231 | </div> | |
| 232 | {actionData?.intent === "defaults" && | |
| 233 | (actionData.error ? <ErrorText>{actionData.error}</ErrorText> : <p className="text-sm text-muted">{actionData.done}</p>)} | |
| 234 | {actionData?.intent === "delete" && actionData.error && <ErrorText>{actionData.error}</ErrorText>} | |
| 235 | {creating && <LabelForm onDone={() => setCreating(false)} />} | |
| 236 | ||
| 237 | <div className="overflow-hidden rounded-xl border border-line"> | |
| 238 | <div className="flex flex-wrap items-center gap-3 border-b border-line bg-surface px-4 py-2.5"> | |
| 239 | <span className="text-sm font-medium">{count(labels.length, "label", "labels")}</span> | |
| 240 | <label className="ml-auto flex h-8 w-full items-center gap-2 rounded-md border border-line bg-bg px-2.5 sm:w-64"> | |
| 241 | <Search size={14} className="shrink-0 text-faint" /> | |
| 242 | <input | |
| 243 | type="search" | |
| 244 | value={query} | |
| 245 | onChange={(event) => setQuery(event.target.value)} | |
| 246 | placeholder="Search labels" | |
| 247 | aria-label="Search labels" | |
| 248 | autoComplete="off" | |
| 249 | data-1p-ignore | |
| 250 | className="w-full bg-transparent text-sm outline-none placeholder:text-faint" | |
| 251 | /> | |
| 252 | </label> | |
| 253 | </div> | |
| 254 | {labels.length === 0 ? ( | |
| 255 | <div className="p-4"> | |
| 256 | <EmptyState title="No labels yet"> | |
| 257 | {canEdit ? "Add the default labels, or make your own." : "Nobody has made a label here yet."} | |
| 258 | </EmptyState> | |
| 259 | </div> | |
| 260 | ) : shown.length === 0 ? ( | |
| 261 | <p className="px-4 py-8 text-center text-sm text-faint">No label matches “{query}”.</p> | |
| 262 | ) : ( | |
| 263 | <ul className="divide-y divide-line"> | |
| 264 | {shown.map((label) => | |
| 265 | editing === label.name ? ( | |
| 266 | <li key={label.name} className="p-3"> | |
| 267 | <LabelForm label={label} onDone={() => setEditing(null)} /> | |
| 268 | </li> | |
| 269 | ) : ( | |
| 270 | <li key={label.name} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3"> | |
| 271 | <span className="w-full min-w-0 sm:w-56"> | |
| 272 | <LabelChip name={label.name} color={label.color} /> | |
| 273 | </span> | |
| 274 | <span className="min-w-0 grow text-sm text-muted">{label.description}</span> | |
| 275 | <span className="flex shrink-0 items-center gap-3 text-xs text-faint"> | |
| 276 | {label.issues > 0 ? ( | |
| 277 | <Link to={`${base}/issues?label=${encodeURIComponent(label.name)}&state=open`} className="hover:text-fg"> | |
| 278 | {count(label.issues, "issue", "issues")} | |
| 279 | </Link> | |
| 280 | ) : ( | |
| 281 | <span>no issues</span> | |
| 282 | )} | |
| 283 | {label.pulls > 0 ? ( | |
| 284 | <Link to={`${base}/pulls?label=${encodeURIComponent(label.name)}`} className="hover:text-fg"> | |
| 285 | {count(label.pulls, "pull request", "pull requests")} | |
| 286 | </Link> | |
| 287 | ) : ( | |
| 288 | <span>no pull requests</span> | |
| 289 | )} | |
| 290 | </span> | |
| 291 | {canEdit && ( | |
| 292 | <span className="flex shrink-0 items-center gap-2"> | |
| 293 | <Button type="button" variant="quiet" onClick={() => setEditing(label.name)} aria-label={`Edit ${label.name}`}> | |
| 294 | <Tag size={14} /> | |
| 295 | <span className="hidden sm:inline">Edit</span> | |
| 296 | </Button> | |
| 297 | <DeleteLabel label={label} /> | |
| 298 | </span> | |
| 299 | )} | |
| 300 | </li> | |
| 301 | ), | |
| 302 | )} | |
| 303 | </ul> | |
| 304 | )} | |
| 305 | </div> | |
| 306 | </div> | |
| 307 | ); | |
| 308 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.