| 1 | import { Spinner } from "~/components/ui/spinner"; |
| 2 | import type { |
| 3 | PullRequestActor, |
| 4 | PullRequestCheck, |
| 5 | PullRequestCheckStatus, |
| 6 | PullRequestChecksState, |
| 7 | PullRequestLabel, |
| 8 | PullRequestMergeability, |
| 9 | PullRequestReviewDecision, |
| 10 | PullRequestState, |
| 11 | } from "@t3tools/contracts"; |
| 12 | import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; |
| 13 | import { |
| 14 | CircleCheckIcon, |
| 15 | CircleDashedIcon, |
| 16 | CircleDotIcon, |
| 17 | CircleXIcon, |
| 18 | UserCheckIcon, |
| 19 | UserRoundIcon, |
| 20 | UserRoundXIcon, |
| 21 | } from "lucide-react"; |
| 22 | import { Children, type CSSProperties, isValidElement, type ReactNode, useState } from "react"; |
| 23 | |
| 24 | import { cn } from "~/lib/utils"; |
| 25 | |
| 26 | import { Badge } from "../ui/badge"; |
| 27 | import { InlineButton } from "../ui/button"; |
| 28 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 29 | import type { PullRequestReviewOutcome } from "./pullRequestDetail.logic"; |
| 30 | import { pullRequestLabelColor } from "./pullRequestList.logic"; |
| 31 | import { |
| 32 | PULL_REQUEST_STATE_PRESENTATION, |
| 33 | PullRequestGlyph, |
| 34 | type PullRequestStatePresentation, |
| 35 | type PullRequestGlyphIcon, |
| 36 | } from "./pullRequestIcons"; |
| 37 | |
| 38 | /** |
| 39 | * A host label as a flat tinted tag in the label's own color: a wash of it behind, the name |
| 40 | * in a mix of it and the theme foreground. The mix leans to the foreground because hosts hand |
| 41 | * out any color at all: at 30% of the label on light and 45% on dark, white, black and |
| 42 | * GitHub's pale yellows all clear 4.5:1 on their wash, selected row included, and |
| 43 | * saturated colors sit well above. |
| 44 | * A label with no usable color falls back to the muted tag. Children ride after the name, |
| 45 | * for an overflow count. The height is pinned so a labeled row is as tall as one without. |
| 46 | */ |
| 47 | export function PullRequestLabelChip({ |
| 48 | label, |
| 49 | size = "sm", |
| 50 | className, |
| 51 | children, |
| 52 | }: { |
| 53 | label: Pick<PullRequestLabel, "name" | "color">; |
| 54 | size?: "sm" | "default"; |
| 55 | className?: string; |
| 56 | children?: ReactNode; |
| 57 | }) { |
| 58 | const color = pullRequestLabelColor(label.color); |
| 59 | return ( |
| 60 | <Badge |
| 61 | size={size} |
| 62 | variant={color ? "label" : "secondary"} |
| 63 | className={cn("min-w-0 max-w-40 shrink justify-start", className)} |
| 64 | {...(color ? { style: { "--label": color } as CSSProperties } : {})} |
| 65 | > |
| 66 | <span className="truncate">{label.name}</span> |
| 67 | {children} |
| 68 | </Badge> |
| 69 | ); |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * The review verdict as one glyph beside the checks glyph, so a row answers both "does it |
| 74 | * build" and "did someone say yes" in the same spot. "Awaiting review" is only drawn when the |
| 75 | * host reports it, which on GitHub means the branch rules require a review nobody has given. |
| 76 | */ |
| 77 | function reviewDecisionPresentation(decision: PullRequestReviewDecision) { |
| 78 | switch (decision) { |
| 79 | case "approved": |
| 80 | return { |
| 81 | Icon: UserCheckIcon, |
| 82 | label: "Approved", |
| 83 | toneClassName: CHECK_STATUS_PRESENTATION.success.toneClassName, |
| 84 | }; |
| 85 | case "changes-requested": |
| 86 | return { |
| 87 | Icon: UserRoundXIcon, |
| 88 | label: "Changes requested", |
| 89 | toneClassName: "text-amber-600/90 dark:text-amber-400/80", |
| 90 | }; |
| 91 | case "review-required": |
| 92 | return { |
| 93 | Icon: UserRoundIcon, |
| 94 | label: "Awaiting review", |
| 95 | toneClassName: "text-muted-foreground/60", |
| 96 | }; |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | export function PullRequestReviewDecisionGlyph({ |
| 101 | decision, |
| 102 | }: { |
| 103 | decision: PullRequestReviewDecision; |
| 104 | }) { |
| 105 | const presentation = reviewDecisionPresentation(decision); |
| 106 | return ( |
| 107 | <Tooltip> |
| 108 | <TooltipTrigger render={<span className="inline-flex shrink-0" />}> |
| 109 | <presentation.Icon aria-hidden className={cn("size-3.5", presentation.toneClassName)} /> |
| 110 | <span className="sr-only">{presentation.label}</span> |
| 111 | </TooltipTrigger> |
| 112 | <TooltipPopup>{presentation.label}</TooltipPopup> |
| 113 | </Tooltip> |
| 114 | ); |
| 115 | } |
| 116 | |
| 117 | /** |
| 118 | * How a pull request's state reads anywhere it appears: the thread badge, the right-panel tab, |
| 119 | * the list, and the detail header all resolve through here so one pull request cannot look like |
| 120 | * two different things in two places. |
| 121 | * |
| 122 | * Closed and merged take precedence over a stale draft flag. |
| 123 | */ |
| 124 | export function resolvePullRequestState(input: { |
| 125 | readonly state: PullRequestState; |
| 126 | readonly isDraft: boolean; |
| 127 | }): PullRequestStatePresentation { |
| 128 | const key = input.state === "open" && input.isDraft ? "draft" : input.state; |
| 129 | return PULL_REQUEST_STATE_PRESENTATION[key]; |
| 130 | } |
| 131 | |
| 132 | export interface PullRequestConflictPresentation { |
| 133 | readonly label: string; |
| 134 | readonly toneClassName: string; |
| 135 | readonly Icon: PullRequestGlyphIcon; |
| 136 | } |
| 137 | |
| 138 | export function resolvePullRequestConflict(input: { |
| 139 | readonly state: PullRequestState; |
| 140 | readonly isDraft: boolean; |
| 141 | readonly mergeability?: PullRequestMergeability; |
| 142 | readonly baseBranch?: string; |
| 143 | }): PullRequestConflictPresentation | null { |
| 144 | if (input.state !== "open" || input.isDraft || input.mergeability !== "conflicting") { |
| 145 | return null; |
| 146 | } |
| 147 | return { |
| 148 | label: input.baseBranch ? `Conflicts with ${input.baseBranch}` : "Has conflicts", |
| 149 | toneClassName: "text-destructive", |
| 150 | Icon: PullRequestGlyph.conflicting, |
| 151 | }; |
| 152 | } |
| 153 | |
| 154 | export function PullRequestStateGlyph({ |
| 155 | state, |
| 156 | isDraft, |
| 157 | className, |
| 158 | }: { |
| 159 | state: PullRequestState; |
| 160 | isDraft: boolean; |
| 161 | className?: string; |
| 162 | }) { |
| 163 | const presentation = resolvePullRequestState({ state, isDraft }); |
| 164 | return ( |
| 165 | <Tooltip> |
| 166 | {/* The list row is itself a button, so the trigger stays a span: an interactive one would |
| 167 | nest a control inside that button and steal the row's click target. */} |
| 168 | <TooltipTrigger render={<span className="inline-flex shrink-0" />}> |
| 169 | <presentation.Icon |
| 170 | role="img" |
| 171 | aria-label={presentation.label} |
| 172 | className={cn("size-4 shrink-0", presentation.toneClassName, className)} |
| 173 | /> |
| 174 | </TooltipTrigger> |
| 175 | <TooltipPopup>{presentation.label}</TooltipPopup> |
| 176 | </Tooltip> |
| 177 | ); |
| 178 | } |
| 179 | |
| 180 | export function PullRequestConflictGlyph({ |
| 181 | state, |
| 182 | isDraft, |
| 183 | mergeability, |
| 184 | baseBranch, |
| 185 | className, |
| 186 | }: { |
| 187 | state: PullRequestState; |
| 188 | isDraft: boolean; |
| 189 | mergeability?: PullRequestMergeability; |
| 190 | baseBranch?: string; |
| 191 | className?: string; |
| 192 | }) { |
| 193 | const presentation = resolvePullRequestConflict({ |
| 194 | state, |
| 195 | isDraft, |
| 196 | ...(mergeability === undefined ? {} : { mergeability }), |
| 197 | ...(baseBranch === undefined ? {} : { baseBranch }), |
| 198 | }); |
| 199 | if (presentation === null) return null; |
| 200 | return ( |
| 201 | <Tooltip> |
| 202 | <TooltipTrigger render={<span className="inline-flex shrink-0" />}> |
| 203 | <presentation.Icon |
| 204 | role="img" |
| 205 | aria-label={presentation.label} |
| 206 | className={cn("size-4 shrink-0", presentation.toneClassName, className)} |
| 207 | /> |
| 208 | </TooltipTrigger> |
| 209 | <TooltipPopup>{presentation.label}</TooltipPopup> |
| 210 | </Tooltip> |
| 211 | ); |
| 212 | } |
| 213 | |
| 214 | const CHECK_STATUS_PRESENTATION = { |
| 215 | pending: { label: "Running", Icon: Spinner, toneClassName: "text-amber-500" }, |
| 216 | "action-required": { |
| 217 | label: "Awaiting action", |
| 218 | Icon: CircleDotIcon, |
| 219 | toneClassName: "text-amber-600 dark:text-amber-400/90", |
| 220 | }, |
| 221 | success: { |
| 222 | label: "Passed", |
| 223 | Icon: CircleCheckIcon, |
| 224 | toneClassName: "text-emerald-600 dark:text-emerald-300/90", |
| 225 | }, |
| 226 | failure: { label: "Failed", Icon: CircleXIcon, toneClassName: "text-destructive" }, |
| 227 | cancelled: { label: "Cancelled", Icon: CircleXIcon, toneClassName: "text-destructive" }, |
| 228 | skipped: { label: "Skipped", Icon: CircleDashedIcon, toneClassName: "text-muted-foreground/70" }, |
| 229 | neutral: { label: "Neutral", Icon: CircleDashedIcon, toneClassName: "text-muted-foreground/70" }, |
| 230 | } as const satisfies Record< |
| 231 | PullRequestCheckStatus, |
| 232 | { label: string; Icon: typeof CircleCheckIcon | typeof Spinner; toneClassName: string } |
| 233 | >; |
| 234 | |
| 235 | function isWorkflowApprovalCheck(check: Pick<PullRequestCheck, "status" | "url">): boolean { |
| 236 | return ( |
| 237 | check.status === "action-required" && |
| 238 | check.url !== null && |
| 239 | /\/actions\/runs\/\d+(?:\/|$)/u.test(check.url) |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | export function pullRequestCheckStatusLabel( |
| 244 | check: Pick<PullRequestCheck, "status" | "url">, |
| 245 | ): string { |
| 246 | return isWorkflowApprovalCheck(check) |
| 247 | ? "Awaiting approval" |
| 248 | : CHECK_STATUS_PRESENTATION[check.status].label; |
| 249 | } |
| 250 | |
| 251 | export function PullRequestCheckStatusIcon({ status }: { status: PullRequestCheckStatus }) { |
| 252 | const presentation = CHECK_STATUS_PRESENTATION[status]; |
| 253 | return ( |
| 254 | <presentation.Icon |
| 255 | aria-hidden |
| 256 | className={cn("size-3.5 shrink-0", presentation.toneClassName)} |
| 257 | /> |
| 258 | ); |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * The rollup a listing row carries, which is one word rather than the checks behind it. The |
| 263 | * headline is GitHub's own wording, so a reader who knows that page reads this one the same way. |
| 264 | */ |
| 265 | const CHECKS_STATE_PRESENTATION = { |
| 266 | passing: { |
| 267 | label: "All checks have passed", |
| 268 | Icon: CircleCheckIcon, |
| 269 | toneClassName: CHECK_STATUS_PRESENTATION.success.toneClassName, |
| 270 | }, |
| 271 | failing: { |
| 272 | label: "Some checks were not successful", |
| 273 | Icon: CircleXIcon, |
| 274 | toneClassName: "text-destructive", |
| 275 | }, |
| 276 | pending: { |
| 277 | label: "Some checks haven't completed yet", |
| 278 | Icon: CircleDotIcon, |
| 279 | toneClassName: "text-amber-600 dark:text-amber-400/90", |
| 280 | }, |
| 281 | } as const satisfies Record< |
| 282 | PullRequestChecksState, |
| 283 | { label: string; Icon: typeof CircleCheckIcon; toneClassName: string } |
| 284 | >; |
| 285 | |
| 286 | export function pullRequestChecksStatePresentation(state: PullRequestChecksState) { |
| 287 | return CHECKS_STATE_PRESENTATION[state]; |
| 288 | } |
| 289 | |
| 290 | /** |
| 291 | * The same rollup the server sends with a listing row, worked out here from the checks a detail |
| 292 | * already holds — so the header shows the icon without a second field travelling with it. |
| 293 | * |
| 294 | * Null for a change request with no checks: nothing to show beats a tick nobody earned. |
| 295 | */ |
| 296 | export function pullRequestChecksState( |
| 297 | checks: ReadonlyArray<PullRequestCheck>, |
| 298 | ): PullRequestChecksState | null { |
| 299 | if (checks.length === 0) return null; |
| 300 | const statuses = new Set(checks.map((check) => check.status)); |
| 301 | if (statuses.has("failure") || statuses.has("cancelled")) return "failing"; |
| 302 | if (statuses.has("pending") || statuses.has("action-required")) return "pending"; |
| 303 | return statuses.has("success") ? "passing" : null; |
| 304 | } |
| 305 | |
| 306 | /** |
| 307 | * How a verdict reads, in the one place every surface takes it from. The green is the green a |
| 308 | * passing check already wears in the same panel, so "approved" and "all checks passed" cannot |
| 309 | * look like two different kinds of good news. |
| 310 | * |
| 311 | * The ring runs a shade stronger than the text tones. At 16px across it is a thin arc, and the |
| 312 | * muted pairing that reads well as a word was barely there as an outline. |
| 313 | */ |
| 314 | const REVIEW_OUTCOME_PRESENTATION = { |
| 315 | approved: { |
| 316 | label: "Approved", |
| 317 | Icon: CircleCheckIcon, |
| 318 | toneClassName: "text-emerald-600 dark:text-emerald-300/90", |
| 319 | ringClassName: "ring-2 ring-emerald-500 dark:ring-emerald-400", |
| 320 | staleRingClassName: |
| 321 | "ring-2 ring-[color-mix(in_srgb,var(--color-emerald-500)_35%,var(--background))] dark:ring-[color-mix(in_srgb,var(--color-emerald-400)_35%,var(--background))]", |
| 322 | badgeVariant: "success", |
| 323 | }, |
| 324 | "changes-requested": { |
| 325 | label: "Changes requested", |
| 326 | Icon: CircleXIcon, |
| 327 | toneClassName: "text-destructive", |
| 328 | ringClassName: "ring-2 ring-destructive", |
| 329 | staleRingClassName: "ring-2 ring-[color-mix(in_srgb,var(--destructive)_35%,var(--background))]", |
| 330 | badgeVariant: "error", |
| 331 | }, |
| 332 | dismissed: { |
| 333 | label: "Review dismissed", |
| 334 | Icon: CircleDashedIcon, |
| 335 | toneClassName: "text-muted-foreground/70", |
| 336 | ringClassName: "ring-2 ring-muted-foreground/60", |
| 337 | staleRingClassName: |
| 338 | "ring-2 ring-[color-mix(in_srgb,var(--contrast-muted-foreground)_30%,var(--background))]", |
| 339 | badgeVariant: "outline", |
| 340 | }, |
| 341 | } as const satisfies Record< |
| 342 | PullRequestReviewOutcome, |
| 343 | { |
| 344 | label: string; |
| 345 | Icon: typeof CircleCheckIcon; |
| 346 | toneClassName: string; |
| 347 | ringClassName: string; |
| 348 | staleRingClassName: string; |
| 349 | badgeVariant: "success" | "error" | "outline"; |
| 350 | } |
| 351 | >; |
| 352 | |
| 353 | export function pullRequestReviewOutcomeToneClassName(outcome: PullRequestReviewOutcome): string { |
| 354 | return REVIEW_OUTCOME_PRESENTATION[outcome].toneClassName; |
| 355 | } |
| 356 | |
| 357 | /** Worn by whatever wraps a reviewer's avatar, so their verdict reads without a row of its own. */ |
| 358 | /** |
| 359 | * A faded verdict is mixed into the background rather than made translucent. The ring is the only |
| 360 | * separator an avatar carrying one has — the summary drops the opaque `ring-background` where a |
| 361 | * verdict is drawn — and the stack overlaps by 4px, so an alpha ring would let the neighbour show |
| 362 | * straight through it and the two faces would merge. |
| 363 | */ |
| 364 | export function pullRequestReviewOutcomeRingClassName( |
| 365 | outcome: PullRequestReviewOutcome, |
| 366 | stale = false, |
| 367 | ): string { |
| 368 | const presentation = REVIEW_OUTCOME_PRESENTATION[outcome]; |
| 369 | return stale ? presentation.staleRingClassName : presentation.ringClassName; |
| 370 | } |
| 371 | |
| 372 | /** |
| 373 | * What a superseded verdict says, which is the same word with when it applied added. Commits |
| 374 | * landed after it, so it stands for code the branch no longer has. |
| 375 | */ |
| 376 | export function pullRequestReviewOutcomeStaleLabel(outcome: PullRequestReviewOutcome): string { |
| 377 | return `${REVIEW_OUTCOME_PRESENTATION[outcome].label} earlier changes`; |
| 378 | } |
| 379 | |
| 380 | /** Decorative: every caller says which verdict this is in words beside it. */ |
| 381 | export function PullRequestReviewOutcomeIcon({ |
| 382 | outcome, |
| 383 | className, |
| 384 | }: { |
| 385 | outcome: PullRequestReviewOutcome; |
| 386 | className?: string; |
| 387 | }) { |
| 388 | const presentation = REVIEW_OUTCOME_PRESENTATION[outcome]; |
| 389 | return ( |
| 390 | <presentation.Icon |
| 391 | aria-hidden |
| 392 | className={cn("size-3.5 shrink-0", presentation.toneClassName, className)} |
| 393 | /> |
| 394 | ); |
| 395 | } |
| 396 | |
| 397 | export function pullRequestReviewOutcomeLabel(outcome: PullRequestReviewOutcome): string { |
| 398 | return REVIEW_OUTCOME_PRESENTATION[outcome].label; |
| 399 | } |
| 400 | |
| 401 | export function PullRequestReviewOutcomeBadge({ |
| 402 | outcome, |
| 403 | className, |
| 404 | }: { |
| 405 | outcome: PullRequestReviewOutcome; |
| 406 | className?: string; |
| 407 | }) { |
| 408 | const presentation = REVIEW_OUTCOME_PRESENTATION[outcome]; |
| 409 | return ( |
| 410 | <Badge size="sm" variant={presentation.badgeVariant} className={className}> |
| 411 | <presentation.Icon aria-hidden className="size-3" /> |
| 412 | {presentation.label} |
| 413 | </Badge> |
| 414 | ); |
| 415 | } |
| 416 | |
| 417 | export function PullRequestActorAvatar({ |
| 418 | actor, |
| 419 | className, |
| 420 | }: { |
| 421 | actor: PullRequestActor | null; |
| 422 | className?: string; |
| 423 | }) { |
| 424 | const login = actor?.login ?? "ghost"; |
| 425 | const avatarUrl = actor?.avatarUrl ?? null; |
| 426 | const [failedAvatarUrl, setFailedAvatarUrl] = useState<string | null>(null); |
| 427 | return avatarUrl === null || failedAvatarUrl === avatarUrl ? ( |
| 428 | // Not every host reports an avatar, and a private host may refuse the browser's request. |
| 429 | <span |
| 430 | aria-hidden |
| 431 | className={cn( |
| 432 | "flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-3xs font-medium text-muted-foreground", |
| 433 | className, |
| 434 | )} |
| 435 | > |
| 436 | {login.slice(0, 1).toUpperCase()} |
| 437 | </span> |
| 438 | ) : ( |
| 439 | <img |
| 440 | aria-hidden |
| 441 | alt="" |
| 442 | src={avatarUrl} |
| 443 | loading="lazy" |
| 444 | className={cn("size-4 shrink-0 rounded-full bg-muted object-cover", className)} |
| 445 | onError={() => setFailedAvatarUrl(avatarUrl)} |
| 446 | /> |
| 447 | ); |
| 448 | } |
| 449 | |
| 450 | /** |
| 451 | * GitHub attributes work from a deleted account to "ghost"; say the same word everywhere. |
| 452 | * |
| 453 | * An actor as a login beside its avatar, or as the avatar alone. With a profile URL the actor |
| 454 | * is an inline link; `className` places it and never restyles it. |
| 455 | */ |
| 456 | export function PullRequestActorLabel({ |
| 457 | actor, |
| 458 | className, |
| 459 | variant = "label", |
| 460 | tooltip = true, |
| 461 | profileUrl, |
| 462 | }: { |
| 463 | actor: PullRequestActor | null; |
| 464 | className?: string; |
| 465 | variant?: "label" | "avatar"; |
| 466 | tooltip?: boolean; |
| 467 | profileUrl?: string | null; |
| 468 | }) { |
| 469 | const login = actor?.login ?? "ghost"; |
| 470 | const label = ( |
| 471 | <span className={cn("flex min-w-0 items-center", variant === "label" && "gap-1.5")}> |
| 472 | <PullRequestActorAvatar actor={actor} /> |
| 473 | <span className={variant === "label" ? "truncate font-medium text-foreground" : "sr-only"}> |
| 474 | {login} |
| 475 | </span> |
| 476 | </span> |
| 477 | ); |
| 478 | const placement = cn("flex min-w-0 shrink", className); |
| 479 | if (!tooltip) return <span className={placement}>{label}</span>; |
| 480 | return ( |
| 481 | <Tooltip> |
| 482 | <TooltipTrigger |
| 483 | render={ |
| 484 | profileUrl ? ( |
| 485 | <InlineButton |
| 486 | className={placement} |
| 487 | render={ |
| 488 | <a |
| 489 | href={profileUrl} |
| 490 | target="_blank" |
| 491 | rel="noopener noreferrer" |
| 492 | aria-label={`Open ${login}'s profile`} |
| 493 | /> |
| 494 | } |
| 495 | /> |
| 496 | ) : ( |
| 497 | <span className={placement} /> |
| 498 | ) |
| 499 | } |
| 500 | > |
| 501 | {label} |
| 502 | </TooltipTrigger> |
| 503 | <TooltipPopup side="top"> |
| 504 | {actor?.name && actor.name !== login ? `${actor.name} (@${login})` : login} |
| 505 | {profileUrl ? " · Open profile" : ""} |
| 506 | </TooltipPopup> |
| 507 | </Tooltip> |
| 508 | ); |
| 509 | } |
| 510 | |
| 511 | /** Added and removed lines, coloured the way every host colours them. */ |
| 512 | export function PullRequestDiffStat({ |
| 513 | additions, |
| 514 | deletions, |
| 515 | className, |
| 516 | }: { |
| 517 | additions: number; |
| 518 | deletions: number; |
| 519 | className?: string; |
| 520 | }) { |
| 521 | // Not every host reports line counts. Showing "+0 -0" would read as an empty change set |
| 522 | // rather than as a missing one, so the stat is left out instead. |
| 523 | if (additions === 0 && deletions === 0) { |
| 524 | return null; |
| 525 | } |
| 526 | return ( |
| 527 | <span className={cn("inline-flex items-baseline gap-1 tabular-nums", className)}> |
| 528 | <span className="text-diff-addition-foreground">+{formatDiffCount(additions)}</span> |
| 529 | <span className="text-diff-deletion">-{formatDiffCount(deletions)}</span> |
| 530 | </span> |
| 531 | ); |
| 532 | } |
| 533 | |
| 534 | /** |
| 535 | * Dot-separated metadata. It owns the separator, and draws one only between the segments that |
| 536 | * survive, so a caller can render `{condition ? <span/> : null}` without leaving a stray dot. |
| 537 | * `Children.toArray` drops the nullish entries and keys what remains, which a plain array |
| 538 | * check would not do for a single child or a fragment. A separator borrows the key of the |
| 539 | * segment it precedes, so it stays stable without counting positions. |
| 540 | */ |
| 541 | function separatorKey(segment: ReactNode): string { |
| 542 | return `separator:${isValidElement(segment) ? String(segment.key) : String(segment)}`; |
| 543 | } |
| 544 | |
| 545 | export function PullRequestMetaLine({ |
| 546 | children, |
| 547 | className, |
| 548 | }: { |
| 549 | children: ReactNode; |
| 550 | className?: string; |
| 551 | }) { |
| 552 | const segments = Children.toArray(children); |
| 553 | return ( |
| 554 | <span className={cn("flex min-w-0 items-center gap-1.5", className)}> |
| 555 | {segments.flatMap((segment, index) => |
| 556 | index === 0 |
| 557 | ? segment |
| 558 | : [ |
| 559 | <span |
| 560 | aria-hidden |
| 561 | className="shrink-0 text-muted-foreground/50" |
| 562 | key={separatorKey(segment)} |
| 563 | > |
| 564 | · |
| 565 | </span>, |
| 566 | segment, |
| 567 | ], |
| 568 | )} |
| 569 | </span> |
| 570 | ); |
| 571 | } |
| 572 | |
| 573 | export function summarizePullRequestChecks(checks: ReadonlyArray<PullRequestCheck>): string { |
| 574 | if (checks.length === 0) return "No checks reported"; |
| 575 | const actionRequired = checks.filter((check) => check.status === "action-required"); |
| 576 | const workflowApprovalRequired = actionRequired.filter(isWorkflowApprovalCheck).length; |
| 577 | const otherActionRequired = actionRequired.length - workflowApprovalRequired; |
| 578 | const failed = checks.filter( |
| 579 | (check) => check.status === "failure" || check.status === "cancelled", |
| 580 | ).length; |
| 581 | const pending = checks.filter((check) => check.status === "pending").length; |
| 582 | const passed = checks.filter((check) => check.status === "success").length; |
| 583 | if (failed > 0) return `${failed} of ${checks.length} failing`; |
| 584 | if (workflowApprovalRequired > 0 && otherActionRequired > 0) { |
| 585 | return `${workflowApprovalRequired} ${workflowApprovalRequired === 1 ? "workflow" : "workflows"} and ${otherActionRequired} ${otherActionRequired === 1 ? "check" : "checks"} awaiting action`; |
| 586 | } |
| 587 | if (workflowApprovalRequired > 0) { |
| 588 | return `${workflowApprovalRequired} ${workflowApprovalRequired === 1 ? "workflow" : "workflows"} awaiting approval`; |
| 589 | } |
| 590 | if (otherActionRequired > 0) { |
| 591 | return `${otherActionRequired} ${otherActionRequired === 1 ? "check" : "checks"} awaiting action`; |
| 592 | } |
| 593 | if (pending > 0) return `${pending} of ${checks.length} running`; |
| 594 | return passed === checks.length ? "All checks passed" : `${passed} of ${checks.length} passing`; |
| 595 | } |