"use client"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { IconCheck, IconCircleCheck, IconCircleDot, IconCircleX, IconExternalLink, IconGitPullRequest, IconMessageCircle, IconPlus, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { useToast } from "@/components/toast-provider"; import { useAppStore } from "@/components/state-provider"; import { useCommentsStore } from "@/lib/state/slices/comments"; import type { PRFeedbackComment } from "@/lib/state/slices/comments"; import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { usePRCIPopover } from "@/hooks/domains/github/use-pr-ci-popover"; import { bucketCheck, bucketCheckCounts, groupChecksByWorkflow, type CheckBucket, type WorkflowGroup, } from "@/lib/github/check-buckets"; import type { CheckRun, TaskPR } from "@/lib/types/github"; import { PRCIAutomationControls } from "./pr-ci-automation-controls"; import { PRMergeButton } from "./pr-merge-button"; import { PRMergeabilityRow } from "./pr-mergeability-row"; type CountsView = { passed: number; inProgress: number; failed: number; }; const CHECK_GROUP_ORDER: CheckBucket[] = ["passed", "in_progress", "failed"]; export const PR_CI_DESKTOP_POPOVER_SCROLL_CLASS = "max-h-[min(28rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain"; function countForBucket(counts: CountsView, kind: CheckBucket): number { if (kind === "passed") return counts.passed; if (kind === "in_progress") return counts.inProgress; return counts.failed; } export function deriveAggregateCounts(pr: TaskPR): CountsView { // Pre-load coarse split from aggregate fields; lazy PRFeedback fetch replaces it. const total = Math.max(0, pr.checks_total); const passing = Math.min(Math.max(0, pr.checks_passing), total); const remaining = Math.max(0, total - passing); if (pr.checks_state === "failure") { const failed = remaining > 0 ? remaining : 1; const passed = total > 0 ? Math.max(0, total - failed) : 0; return { passed, failed, inProgress: 0 }; } if (pr.checks_state === "pending") { const inProgress = remaining > 0 ? remaining : 1; const passed = total > 0 ? Math.max(0, total - inProgress) : 0; return { passed, failed: 0, inProgress }; } if (pr.checks_state === "success") { return { passed: total, failed: 0, inProgress: 0 }; } return { passed: passing, failed: 0, inProgress: remaining }; } export function hasNoChecksAtAll( pr: TaskPR, feedback: { checks?: CheckRun[] } | null, isFetching: boolean, ): boolean { return ( !isFetching && pr.checks_state === "" && pr.checks_total === 0 && (!feedback || (feedback.checks?.length ?? 0) === 0) ); } function PRPopoverTitle({ title, onOpenDetailPanel, }: { title: string; onOpenDetailPanel?: () => void; }) { const className = "min-w-0 truncate text-sm font-medium"; if (!onOpenDetailPanel) { return ( {title} ); } return ( ); } function PRCIPopoverHeader({ pr, onOpenDetailPanel, }: { pr: TaskPR; onOpenDetailPanel?: () => void; }) { const title = `#${pr.pr_number} ${pr.pr_title || "Untitled PR"}`; return (
e.stopPropagation()} >
); } function CheckGroupIcon({ kind }: { kind: CheckBucket }) { if (kind === "passed") return ; if (kind === "in_progress") return ; return ; } function bucketLabel(kind: CheckBucket): string { if (kind === "passed") return "Passed"; if (kind === "in_progress") return "In progress"; return "Failed"; } function CheckGroupHeader({ kind, count }: { kind: CheckBucket; count: number }) { const label = bucketLabel(kind); return (
{label}
{count}
); } function PRWorkflowRow({ group, onAddAsContext, }: { group: WorkflowGroup; onAddAsContext: ((message: string) => void) | null; }) { // For an in-progress workflow, "0/1 ran" reads as "nothing finished" // and confuses people: did it start? It's clearer to just report how // many jobs are running. (Failed workflows go to a different bucket, // so an in_progress group never has failed jobs.) const badge = group.bucket === "in_progress" ? `${group.inProgress} running` : `${group.passed}/${group.total} passed`; return (
{ if (group.htmlUrl) window.open(group.htmlUrl, "_blank", "noopener,noreferrer"); }} > {group.workflow} {badge} {group.htmlUrl && ( )} {group.bucket === "failed" && onAddAsContext && ( )}
); } function buildWorkflowMessage(group: WorkflowGroup): string { const failed = group.jobs.filter((j) => bucketCheck(j) === "failed"); const lines: string[] = [ `### Workflow **${group.workflow}** has ${failed.length} failing job${failed.length !== 1 ? "s" : ""}.`, "", ]; for (const job of failed) { lines.push(`- **${job.name}** — ${job.conclusion || job.status}`); if (job.output) lines.push(` ${job.output}`); if (job.html_url) lines.push(` ${job.html_url}`); } lines.push("", "Please investigate and fix."); return lines.join("\n"); } function PRCheckGroup({ kind, count, workflows, isLoading, onAddAsContext, }: { kind: CheckBucket; count: number; workflows?: WorkflowGroup[]; isLoading: boolean; onAddAsContext: ((message: string) => void) | null; }) { if (count <= 0 && (!workflows || workflows.length === 0)) return null; const showRows = kind !== "passed"; const hasWorkflows = !!workflows && workflows.length > 0; return (
{showRows && (
{hasWorkflows && workflows!.map((g) => ( ))} {!hasWorkflows && isLoading ? Array.from({ length: Math.min(2, count || 1) }).map((_, i) => (
)) : null}
)}
); } /** * Stacked progress bar mirroring TodoIndicator's bar but split into three * segments — green (passed), yellow (in_progress), red (failed) — each * sized proportionally to its count. The leading "pass rate" label gives * the same at-a-glance number TodoIndicator shows ("6/10 (60%)"). * * Skipped/cancelled-but-not-failed jobs aren't counted toward the total * (they never enter any bucket), so the bar always reflects the actionable * picture: how close are we to "all green". */ function PRChecksProgressBar({ counts }: { counts: CountsView }) { const total = counts.passed + counts.inProgress + counts.failed; if (total === 0) return null; const pct = (n: number) => (n / total) * 100; const passedPct = pct(counts.passed); const inProgressPct = pct(counts.inProgress); const failedPct = pct(counts.failed); const completePct = Math.round(passedPct); return (
Pass rate {counts.passed}/{total} ({completePct}%)
{passedPct > 0 && (
)} {inProgressPct > 0 && (
)} {failedPct > 0 && (
)}
); } function PRChecksSection({ pr, feedback, isFetching, onAddAsContext, }: { pr: TaskPR; feedback: { checks?: CheckRun[] } | null; isFetching: boolean; onAddAsContext: ((message: string) => void) | null; }) { const aggregateCounts = useMemo(() => deriveAggregateCounts(pr), [pr]); const { precise, byBucket } = useMemo(() => { // Treat empty `feedback.checks` the same as "feedback not loaded yet" so // we keep showing the aggregate counts. Some mock paths return empty // arrays without errors, and we don't want the popover to flash 0/0/0. if (!feedback?.checks || feedback.checks.length === 0) { return { precise: null as CountsView | null, byBucket: null }; } const counts = bucketCheckCounts(feedback.checks); const precise: CountsView = { passed: counts.passed, inProgress: counts.inProgress, failed: counts.failed, }; const groups = groupChecksByWorkflow(feedback.checks); const byBucket: Record = { passed: groups.filter((g) => g.bucket === "passed"), in_progress: groups.filter((g) => g.bucket === "in_progress"), failed: groups.filter((g) => g.bucket === "failed"), }; return { precise, byBucket }; }, [feedback]); const counts = precise ?? aggregateCounts; // "No checks at all": the lazy fetch has settled (not isFetching) and there // are no checks anywhere — neither in PRFeedback nor in the aggregate. if (hasNoChecksAtAll(pr, feedback, isFetching)) { return (
No checks have started
); } return (
{CHECK_GROUP_ORDER.map((kind) => { const value = countForBucket(counts, kind); return ( ); })}
); } function PRReviewRow({ pr }: { pr: TaskPR }) { const required = pr.required_reviews ?? null; const approved = pr.review_count; const requested = pr.pending_review_count; let label: string; let icon: ReactNode; if (pr.review_state === "approved") { icon = ; label = "Approved"; } else if (pr.review_state === "changes_requested") { icon = ; label = "Changes requested"; } else { // Default branch covers "no reviews yet", "review pending", and any // unknown state. Always rendered (no early-return) so a fresh PR // still surfaces its review status. icon = ; label = "Awaiting review"; } // Counts live on the right edge, mirroring the check-group rows. // Use "K / M" when a required-minimum is known, otherwise just the // approved count. The "(K requested)" suffix slides in when reviewers // have been pinged but haven't submitted a review. let countText: string; if (required != null) countText = `${approved} / ${required}`; else countText = `${approved}`; if (requested > 0) countText += ` · ${requested} requested`; return (
{icon} {label}
{countText}
); } function PRCommentsRow({ pr }: { pr: TaskPR }) { if (!pr.unresolved_review_threads || pr.unresolved_review_threads <= 0) return null; return (
{pr.unresolved_review_threads} unresolved comment {pr.unresolved_review_threads === 1 ? "" : "s"}
); } function elapsedLabel(elapsed: number | null): string { if (elapsed == null) return ""; if (elapsed === 0) return "updated just now"; return `updated ${formatElapsedShort(elapsed)} ago`; } function PRPopoverFooter({ lastUpdatedAt }: { lastUpdatedAt: number | null }) { // Capture a "now" snapshot only inside the setInterval callback (an event // handler — no impurity in render, no setState during commit). When no // tick has fired yet, fall back to lastUpdatedAt so the rendered elapsed // is 0 ("just now"). const [now, setNow] = useState(null); useEffect(() => { if (lastUpdatedAt == null) return; const id = setInterval(() => setNow(Date.now()), 10_000); return () => clearInterval(id); }, [lastUpdatedAt]); const elapsed = lastUpdatedAt == null ? null : Math.max(0, Math.floor(((now ?? lastUpdatedAt) - lastUpdatedAt) / 1000)); if (lastUpdatedAt == null) return null; return (
{elapsedLabel(elapsed)}
); } function formatElapsedShort(seconds: number): string { if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); return `${hours}h`; } function ReconnectGitHubBlock() { return (
GitHub authentication lost. Reconnect GitHub
); } export function PRCIPopover({ pr, enabled, onOpenDetailPanel, }: { pr: TaskPR; enabled: boolean; onOpenDetailPanel?: () => void; }) { const ghStatus = useAppStore((s) => s.githubStatus.status); const authLost = ghStatus !== null && !ghStatus.authenticated; // Trigger an initial status load from the same hook the rest of the app uses. useGitHubStatus(); const { feedback, isFetching, lastUpdatedAt, refetch } = usePRCIPopover(pr, enabled && !authLost); const onAddAsContext = useAddCheckToContext(pr); return (
e.stopPropagation()} > {authLost ? ( ) : ( <>
)}
); } // --- Add-to-context wiring (mirrors pr-detail-panel.tsx for failed checks) --- function useAddCheckToContext(pr: TaskPR): ((message: string) => void) | null { const sessionId = useAppStore((s) => s.tasks.activeSessionId); const addComment = useCommentsStore((s) => s.addComment); const { toast } = useToast(); const prNumber = pr.pr_number; // Always call useCallback (rules-of-hooks) before bailing out, so the // returned callback identity is stable across renders unless its inputs // change. Without memoization the popover would create a new function // every parent render, defeating PRWorkflowRow's cheap reference equality. const handler = useCallback( (message: string) => { if (!sessionId) return; const comment: PRFeedbackComment = { id: `pr-feedback-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, sessionId, text: message, createdAt: new Date().toISOString(), status: "pending", source: "pr-feedback", prNumber, feedbackType: "check", content: message, }; addComment(comment); toast({ description: "Added to chat context" }); }, [sessionId, prNumber, addComment, toast], ); return sessionId ? handler : null; }