Files
kandev/apps/web/components/github/pr-checks-section.tsx
T

147 lines
5.5 KiB
TypeScript

import { useMemo, useState, useEffect } from "react";
import { IconCheck, IconX, IconClock } from "@tabler/icons-react";
import type { CheckRun } from "@/lib/types/github";
import { CollapsibleSection, AddToContextButton, formatDuration, formatElapsed } from "./pr-shared";
function CheckStatusIcon({ check }: { check: CheckRun }) {
const value = check.conclusion || check.status;
if (value === "success") return <IconCheck className="h-3.5 w-3.5 text-green-500 shrink-0" />;
if (value === "failure" || value === "timed_out")
return <IconX className="h-3.5 w-3.5 text-red-500 shrink-0" />;
if (value === "in_progress" || value === "queued")
return <IconClock className="h-3.5 w-3.5 text-yellow-500 shrink-0 animate-pulse" />;
return <IconClock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />;
}
function conclusionLabel(check: CheckRun): string | null {
const c = check.conclusion;
if (!c || c === "success" || c === "failure") return c || null;
const labels: Record<string, string> = {
timed_out: "timed out",
cancelled: "cancelled",
action_required: "action required",
skipped: "skipped",
neutral: "neutral",
stale: "stale",
};
return labels[c] ?? c;
}
function checkDurationText(check: CheckRun): string | null {
if (check.started_at && check.completed_at) {
const ms = new Date(check.completed_at).getTime() - new Date(check.started_at).getTime();
if (ms < 1000) return null;
return formatDuration(check.started_at, check.completed_at);
}
if (check.started_at && !check.completed_at) return `${formatElapsed(check.started_at)} running`;
return null;
}
function isFailedCheck(check: CheckRun): boolean {
return check.conclusion === "failure" || check.conclusion === "timed_out";
}
function buildCheckMessage(check: CheckRun): string {
const parts = [`CI check **${check.name}** failed (${check.conclusion}).`];
if (check.output) parts.push(check.output);
if (check.html_url) parts.push(`Check URL: ${check.html_url}`);
parts.push("Please investigate and fix this failing check.");
return parts.join("\n\n");
}
function buildAllFailedMessage(checks: CheckRun[]): string {
const failed = checks.filter(isFailedCheck);
const parts = [`### ${failed.length} CI Check${failed.length !== 1 ? "s" : ""} Failed`, ""];
for (const check of failed) {
parts.push(`**${check.name}** — ${check.conclusion}`);
if (check.output) parts.push(check.output);
if (check.html_url) parts.push(`URL: ${check.html_url}`);
parts.push("");
}
parts.push("Please investigate and fix these failing checks.");
return parts.join("\n");
}
function formatSectionSummary(checks: CheckRun[]): string {
const failed = checks.filter(isFailedCheck).length;
const passed = checks.filter((c) => c.conclusion === "success").length;
const pending = checks.length - failed - passed;
const parts: string[] = [];
if (failed > 0) parts.push(`${failed} failed`);
if (passed > 0) parts.push(`${passed} passed`);
if (pending > 0) parts.push(`${pending} pending`);
return parts.join(", ");
}
export function ChecksSection({
checks,
onAddAsContext,
}: {
checks: CheckRun[];
onAddAsContext: (message: string) => void;
}) {
const hasRunning = useMemo(() => checks.some((c) => c.started_at && !c.completed_at), [checks]);
const [, setTick] = useState(0);
useEffect(() => {
if (!hasRunning) return;
const id = setInterval(() => setTick((t) => t + 1), 1_000);
return () => clearInterval(id);
}, [hasRunning]);
const summary = checks.length > 0 ? ` \u2014 ${formatSectionSummary(checks)}` : "";
const hasFailed = checks.some(isFailedCheck);
return (
<CollapsibleSection
title={`CI Checks${summary}`}
count={checks.length}
defaultOpen
onAddAll={hasFailed ? () => onAddAsContext(buildAllFailedMessage(checks)) : undefined}
addAllLabel="Add all failed checks to chat context"
>
{checks.length === 0 && <p className="text-xs text-muted-foreground px-2 py-2">No checks</p>}
{checks.map((check) => {
const label = conclusionLabel(check);
const duration = checkDurationText(check);
const checkKey = `${check.name}-${check.html_url}-${check.source}-${check.started_at ?? ""}`;
return (
<div
key={checkKey}
data-testid={`check-run-${check.name}`}
className="px-2.5 py-1.5 rounded-md border border-border bg-muted/30 flex items-center gap-2 text-xs"
>
<CheckStatusIcon check={check} />
{check.html_url ? (
<a
href={check.html_url}
target="_blank"
rel="noopener noreferrer"
className="font-medium truncate hover:underline cursor-pointer min-w-0"
>
{check.name}
</a>
) : (
<span className="font-medium truncate min-w-0">{check.name}</span>
)}
{label && <span className="text-[10px] text-muted-foreground shrink-0">{label}</span>}
{duration && (
<span
data-testid={`check-duration-${check.name}`}
className="text-[10px] text-muted-foreground shrink-0"
>
{duration}
</span>
)}
{isFailedCheck(check) && (
<div className="ml-auto shrink-0">
<AddToContextButton onClick={() => onAddAsContext(buildCheckMessage(check))} />
</div>
)}
</div>
);
})}
</CollapsibleSection>
);
}