g1t/apps/web/app/components/required-checks.tsx
| 1 | /** |
| 2 | * Choosing the checks a branch requires: every name reported on the |
| 3 | * repository's commits lately, with the events it was reported for, ticked |
| 4 | * when it is required, and a field for a name nothing has reported yet. |
| 5 | * Posts each ticked name as `requiredChecks`. |
| 6 | */ |
| 7 | import { Plus, X } from "lucide-react"; |
| 8 | import { useState } from "react"; |
| 9 | |
| 10 | import type { SeenCheck } from "@g1t/contracts"; |
| 11 | |
| 12 | import { CheckboxOption } from "./ui/checkbox"; |
| 13 | import { TimeAgo } from "./ui"; |
| 14 | |
| 15 | /** The events a workflow reports for that matter to a merge, first. */ |
| 16 | const EVENT_ORDER = ["pull_request", "merge_group", "pull_request_target", "push"]; |
| 17 | |
| 18 | function events(list: string[]): string { |
| 19 | const sorted = [...list].sort((a, b) => { |
| 20 | const ia = EVENT_ORDER.indexOf(a); |
| 21 | const ib = EVENT_ORDER.indexOf(b); |
| 22 | return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || a.localeCompare(b); |
| 23 | }); |
| 24 | return sorted.join(", "); |
| 25 | } |
| 26 | |
| 27 | /** What to say under a name: where it was seen, and whether that is enough to require it. */ |
| 28 | function about(seen: SeenCheck | undefined, mergeQueue: boolean) { |
| 29 | if (!seen) return <>Not reported in the last 30 days. Until something reports it, nothing can merge.</>; |
| 30 | const onPulls = seen.events.length === 0 || seen.events.some((event) => event.startsWith("pull_request")); |
| 31 | const onQueue = seen.events.length === 0 || seen.events.includes("merge_group"); |
| 32 | return ( |
| 33 | <> |
| 34 | {seen.events.length > 0 ? `Reported for ${events(seen.events)}` : "Reported by a tool other than a workflow"} ·{" "} |
| 35 | <TimeAgo at={seen.lastSeen} /> |
| 36 | {!onPulls && <span className="text-warn"> · not on pull requests, so it would hold every merge</span>} |
| 37 | {onPulls && mergeQueue && !onQueue && <span className="text-warn"> · not on merge_group, so the queue cannot pass</span>} |
| 38 | </> |
| 39 | ); |
| 40 | } |
| 41 | |
| 42 | export function RequiredChecksPicker({ |
| 43 | required, |
| 44 | seen, |
| 45 | mergeQueue, |
| 46 | disabled, |
| 47 | }: { |
| 48 | required: string[]; |
| 49 | seen: SeenCheck[]; |
| 50 | mergeQueue: boolean; |
| 51 | disabled?: boolean; |
| 52 | }) { |
| 53 | const [added, setAdded] = useState<string[]>([]); |
| 54 | const [typed, setTyped] = useState(""); |
| 55 | const known = (name: string, list: string[]) => list.some((other) => other.toLowerCase() === name.toLowerCase()); |
| 56 | // Required ones first, as they are; then the rest seen lately; then what was typed. |
| 57 | const names = [ |
| 58 | ...required, |
| 59 | ...seen.map((check) => check.name).filter((name) => !known(name, required)), |
| 60 | ...added.filter((name) => !known(name, required) && !seen.some((check) => check.name.toLowerCase() === name.toLowerCase())), |
| 61 | ]; |
| 62 | const add = () => { |
| 63 | const name = typed.trim(); |
| 64 | if (name && !known(name, names)) setAdded([...added, name]); |
| 65 | setTyped(""); |
| 66 | }; |
| 67 | return ( |
| 68 | <div className="rounded-xl border border-line bg-surface p-4"> |
| 69 | <p className="text-sm font-medium">Require status checks to pass before merging</p> |
| 70 | <p className="mt-1 text-sm text-muted"> |
| 71 | A pull request merges only once each check ticked here has passed on its latest commit: for people and agents |
| 72 | alike, by hand, by auto-merge and through the queue. A workflow reports a check named after it. |
| 73 | </p> |
| 74 | {names.length === 0 ? ( |
| 75 | <p className="mt-3 rounded-lg border border-dashed border-line px-3 py-3 text-sm text-faint"> |
| 76 | No checks have been reported on this repository in the last 30 days. |
| 77 | </p> |
| 78 | ) : ( |
| 79 | <ul className="mt-3 divide-y divide-line rounded-lg border border-line bg-bg"> |
| 80 | {names.map((name) => { |
| 81 | const seenCheck = seen.find((check) => check.name.toLowerCase() === name.toLowerCase()); |
| 82 | const isAdded = known(name, added) && !known(name, required); |
| 83 | return ( |
| 84 | <li key={name} className="flex items-start gap-2 px-3 py-2.5"> |
| 85 | <CheckboxOption |
| 86 | className="min-w-0 grow" |
| 87 | name="requiredChecks" |
| 88 | value={name} |
| 89 | defaultChecked={known(name, required) || isAdded} |
| 90 | disabled={disabled} |
| 91 | label={<span className="font-mono text-[0.8125rem]">{name}</span>} |
| 92 | description={about(seenCheck, mergeQueue)} |
| 93 | /> |
| 94 | {isAdded && ( |
| 95 | <button |
| 96 | type="button" |
| 97 | aria-label={`Remove ${name}`} |
| 98 | onClick={() => setAdded(added.filter((other) => other !== name))} |
| 99 | className="rounded-md p-1 text-faint hover:bg-raised hover:text-fg" |
| 100 | > |
| 101 | <X size={13} /> |
| 102 | </button> |
| 103 | )} |
| 104 | </li> |
| 105 | ); |
| 106 | })} |
| 107 | </ul> |
| 108 | )} |
| 109 | <div className="mt-3 flex flex-wrap gap-2"> |
| 110 | <input |
| 111 | value={typed} |
| 112 | onChange={(event) => setTyped(event.target.value)} |
| 113 | onKeyDown={(event) => { |
| 114 | if (event.key === "Enter") { |
| 115 | event.preventDefault(); |
| 116 | add(); |
| 117 | } |
| 118 | }} |
| 119 | disabled={disabled} |
| 120 | maxLength={100} |
| 121 | placeholder="Another check, by name" |
| 122 | aria-label="Require another check, by name" |
| 123 | className="min-w-0 grow rounded-md border border-line bg-bg px-3 py-1.5 font-mono text-sm placeholder:font-sans placeholder:text-faint focus:border-line-strong focus:outline-none sm:max-w-xs" |
| 124 | /> |
| 125 | <button |
| 126 | type="button" |
| 127 | onClick={add} |
| 128 | disabled={disabled || typed.trim() === ""} |
| 129 | className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 hover:border-line-strong hover:bg-raised disabled:opacity-50" |
| 130 | > |
| 131 | <Plus size={13} /> |
| 132 | Add |
| 133 | </button> |
| 134 | </div> |
| 135 | </div> |
| 136 | ); |
| 137 | } |