"use client"; import { IconGitPullRequest } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; import { useAppStore } from "@/components/state-provider"; import type { TaskPR } from "@/lib/types/github"; const STATUS_RANK: Record = { // Higher = more attention-worthy. Drives the aggregated icon color when a // task has multiple PRs (we surface the worst state). "text-red-500": 5, "text-yellow-500": 4, "text-sky-400": 3, "text-emerald-400": 2, "text-green-500": 1, "text-purple-500": 0, "text-muted-foreground": 0, }; // Requires checks_state === "success" (not just "") so repos with no CI configured // won't trigger ready-to-merge on mergeable_state=clean alone. export function isPRReadyToMerge(pr: TaskPR): boolean { if (pr.state !== "open") return false; if (pr.checks_state !== "success") return false; if (pr.mergeable_state !== "clean") return false; // Guard against stale mergeable_state: enforce required_reviews to match GitHub's gate. if (pr.required_reviews != null && pr.review_count < pr.required_reviews) { return false; } if (pr.review_state === "approved") return true; // No review process: no requested reviewers and no submitted reviews. GitHub // sets mergeable_state=clean when branch protection is satisfied, so this // covers repos without required reviewers. return pr.review_state === "" && pr.pending_review_count === 0; } // CI passed but the PR is still waiting on human review (reviewers requested // or pending review state). Distinct from yellow "CI running". An approved // PR with extra reviewers still pending also counts — GitHub's // review_state="approved" only means at least one reviewer approved, not // that branch protection's required count is met. export function isPRAwaitingReview(pr: TaskPR): boolean { if (pr.state !== "open") return false; if (pr.checks_state !== "success") return false; // Shortfall is "awaiting review" even when no reviewer is currently requested. if (pr.required_reviews != null && pr.review_count < pr.required_reviews) { return true; } if (pr.review_state === "approved") return pr.pending_review_count > 0; return pr.review_state === "pending" || pr.pending_review_count > 0; } export function getPRStatusColor(pr: TaskPR): string { if (pr.state === "merged") return "text-purple-500"; if (pr.state === "closed") return "text-red-500"; if (pr.review_state === "changes_requested" || pr.checks_state === "failure") { return "text-red-500"; } if (isPRReadyToMerge(pr)) { return "text-emerald-400"; } // Check awaiting-review before the plain-green fallback so an approved PR // with pending reviewers (1 of N required) doesn't read as fully approved. if (isPRAwaitingReview(pr)) { return "text-sky-400"; } if (pr.review_state === "approved" && pr.checks_state === "success") { return "text-green-500"; } if (pr.checks_state === "pending" || pr.review_state === "pending") { return "text-yellow-500"; } return "text-muted-foreground"; } export function getPRTooltip(pr: TaskPR): string { const parts = [`PR #${pr.pr_number}: ${pr.pr_title}`]; if (pr.state !== "open") parts.push(`State: ${pr.state}`); if (pr.review_state) parts.push(`Review: ${pr.review_state}`); if (pr.checks_state) parts.push(`CI: ${pr.checks_state}`); if (isPRReadyToMerge(pr)) { parts.push("Ready to merge"); } else if (pr.mergeable_state && pr.mergeable_state !== "unknown" && pr.state === "open") { parts.push(`Mergeable: ${pr.mergeable_state}`); } return parts.join(" | "); } /** * Picks the most attention-worthy color across N PRs. For multi-repo tasks one * red PR should dominate the visual even if the others are green. Terminal * (merged/closed) PRs are dropped when at least one PR is still open so a * task whose first PR landed and was followed by a new open PR surfaces the * live PR's status instead of the merged-purple from the closed one. */ export function aggregatePRStatusColor(prs: TaskPR[]): string { if (prs.length === 0) return "text-muted-foreground"; const open = prs.filter((p) => p.state === "open"); const target = open.length > 0 ? open : prs; let bestColor = "text-muted-foreground"; let bestRank = -1; for (const pr of target) { const color = getPRStatusColor(pr); const rank = STATUS_RANK[color] ?? 0; if (rank > bestRank) { bestRank = rank; bestColor = color; } } return bestColor; } /** * True when at least one PR is open AND every open PR is ready to merge. * Terminal (merged/closed) siblings are ignored so they can't drag the result * to false. Extracted so the rule is testable without mounting MultiPRIcon. */ export function areAllOpenPRsReadyToMerge(prs: TaskPR[]): boolean { const openPRs = prs.filter((p) => p.state === "open"); return openPRs.length > 0 && openPRs.every(isPRReadyToMerge); } /** * Attention rank for a single PR, reusing the same colour→rank table that * drives the aggregate icon. Terminal PRs (merged/closed) return -1 so they're * never the default focus when a task mixes open and finished PRs. */ export function prStatusRank(pr: TaskPR): number { if (pr.state !== "open") return -1; return STATUS_RANK[getPRStatusColor(pr)] ?? 0; } /** * Picks the most attention-worthy PR to focus first in a multi-PR popover — * the worst open status (failing > pending > awaiting-review > ready/passing). * Ties resolve to the first PR (creation order). Falls back to the first PR * when every PR is terminal so the popover always has something to show. */ export function pickDefaultPR(prs: TaskPR[]): TaskPR | null { if (prs.length === 0) return null; let best = prs[0]; let bestRank = prStatusRank(prs[0]); for (let i = 1; i < prs.length; i++) { const rank = prStatusRank(prs[i]); if (rank > bestRank) { best = prs[i]; bestRank = rank; } } return best; } export function PRTaskIcon({ taskId }: { taskId: string }) { const prs = useAppStore((state) => state.taskPRs.byTaskId[taskId] ?? null); // Defensive: an upstream payload may briefly seed byTaskId[taskId] with a // non-array value (e.g. an empty object from a partial hydration). Bail // instead of falling through into MultiPRIcon, where for-of throws. if (!Array.isArray(prs) || prs.length === 0) return null; if (prs.length === 1) return ; return ; } function SinglePRIcon({ taskId, pr }: { taskId: string; pr: TaskPR }) { return ( {getPRTooltip(pr)} ); } function MultiPRIcon({ taskId, prs }: { taskId: string; prs: TaskPR[] }) { const aggregateColor = aggregatePRStatusColor(prs); const allReady = areAllOpenPRsReadyToMerge(prs); return ( {prs.length}
{prs.map((pr) => (
{getPRTooltip(pr)}
))}
); }