147 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|