| 1 | import type { MergebotPullRequestLiveStatus } from "@t3tools/contracts"; |
| 2 | import type { |
| 3 | MergebotStatusPill as Pill, |
| 4 | MergebotStatusPillTone, |
| 5 | } from "@t3tools/client-runtime/mergebot-status-pill"; |
| 6 | import { |
| 7 | CircleCheckIcon, |
| 8 | CircleDashedIcon, |
| 9 | CircleXIcon, |
| 10 | FlaskConicalIcon, |
| 11 | GlobeIcon, |
| 12 | HourglassIcon, |
| 13 | RocketIcon, |
| 14 | TriangleAlertIcon, |
| 15 | type LucideIcon, |
| 16 | } from "lucide-react"; |
| 17 | |
| 18 | import { useNowMinute } from "~/hooks/useNowMinute"; |
| 19 | import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; |
| 20 | import { cn } from "~/lib/utils"; |
| 21 | |
| 22 | const TONE: Record<MergebotStatusPillTone, string> = { |
| 23 | yellow: "bg-yellow-500/15 text-yellow-700 dark:text-yellow-400", |
| 24 | red: "bg-error/12 text-error-foreground", |
| 25 | orange: "bg-orange-500/12 text-orange-700 dark:text-orange-400", |
| 26 | blue: "bg-info/12 text-info-foreground", |
| 27 | green: "bg-success/12 text-success-foreground", |
| 28 | muted: "bg-muted text-muted-foreground", |
| 29 | }; |
| 30 | |
| 31 | const ICON: Record<Exclude<Pill["kind"], "running-ci">, LucideIcon> = { |
| 32 | rebasing: PullRequestGlyph.pullRequest, |
| 33 | "ci-failed": CircleXIcon, |
| 34 | "merge-conflicts": TriangleAlertIcon, |
| 35 | "draft-passes-ci": CircleDashedIcon, |
| 36 | ready: CircleCheckIcon, |
| 37 | queued: HourglassIcon, |
| 38 | merging: PullRequestGlyph.merged, |
| 39 | merged: PullRequestGlyph.merged, |
| 40 | deploying: RocketIcon, |
| 41 | "deploy-failed": RocketIcon, |
| 42 | "on-staging": FlaskConicalIcon, |
| 43 | shipped: GlobeIcon, |
| 44 | }; |
| 45 | |
| 46 | /** A still arc of finished checks; nothing on the pill repaints on its own. */ |
| 47 | function ChecksProgress({ completed, total }: { completed: number; total: number }) { |
| 48 | const radius = 8; |
| 49 | const circumference = 2 * Math.PI * radius; |
| 50 | const filled = total > 0 ? (completed / total) * circumference : 0; |
| 51 | return ( |
| 52 | <svg viewBox="0 0 24 24" className="size-3 shrink-0" aria-hidden> |
| 53 | <circle |
| 54 | cx="12" |
| 55 | cy="12" |
| 56 | r={radius} |
| 57 | fill="none" |
| 58 | stroke="currentColor" |
| 59 | strokeOpacity={0.3} |
| 60 | strokeWidth={3} |
| 61 | /> |
| 62 | {filled > 0 ? ( |
| 63 | <circle |
| 64 | cx="12" |
| 65 | cy="12" |
| 66 | r={radius} |
| 67 | fill="none" |
| 68 | stroke="currentColor" |
| 69 | strokeWidth={3} |
| 70 | strokeLinecap="round" |
| 71 | strokeDasharray={`${filled} ${circumference}`} |
| 72 | transform="rotate(-90 12 12)" |
| 73 | /> |
| 74 | ) : null} |
| 75 | </svg> |
| 76 | ); |
| 77 | } |
| 78 | |
| 79 | function minutesUntil(etaAt: string, now: string) { |
| 80 | const minutes = Math.round((Date.parse(etaAt) - Date.parse(now)) / 60_000); |
| 81 | return minutes < 1 ? "<1m" : `~${minutes}m`; |
| 82 | } |
| 83 | |
| 84 | export function MergebotStatusPill({ |
| 85 | pill, |
| 86 | status, |
| 87 | className, |
| 88 | }: { |
| 89 | pill: Pill; |
| 90 | status: MergebotPullRequestLiveStatus; |
| 91 | className?: string; |
| 92 | }) { |
| 93 | const now = useNowMinute(); |
| 94 | const Icon = pill.kind === "running-ci" ? null : ICON[pill.kind]; |
| 95 | return ( |
| 96 | <span |
| 97 | className={cn( |
| 98 | "inline-flex min-w-0 shrink-0 items-center gap-1 rounded-full py-px ps-1.5 pe-2 text-xs leading-4 font-medium whitespace-nowrap", |
| 99 | TONE[pill.tone], |
| 100 | className, |
| 101 | )} |
| 102 | > |
| 103 | {Icon ? ( |
| 104 | <Icon |
| 105 | className={cn("size-3 shrink-0", pill.kind === "shipped" && "text-success-foreground")} |
| 106 | /> |
| 107 | ) : ( |
| 108 | <ChecksProgress |
| 109 | completed={status.checks?.completed ?? 0} |
| 110 | total={status.checks?.total ?? 0} |
| 111 | /> |
| 112 | )} |
| 113 | <span className="truncate">{pill.label}</span> |
| 114 | {pill.etaAt ? ( |
| 115 | <span className="font-normal tabular-nums opacity-60">{minutesUntil(pill.etaAt, now)}</span> |
| 116 | ) : null} |
| 117 | </span> |
| 118 | ); |
| 119 | } |