Files
kandev/apps/web/components/github/pr-task-icon.tsx
T
José Almeida 42556971ef feat(web): tabbed multi-PR CI popover for topbar button and chat status chip (#1356)
* feat(web): tabbed multi-PR CI popover for topbar button and chat status chip

* test(e2e): cover multi-PR CI popover tabs, chip aggregate, and dropdown

* fix(web): address multi-PR popover review feedback

- suppress hover popover while the PR dropdown is open (no stacked overlays)
- repo-scope tab testids so same-number PRs on different repos stay unique
- guard non-array taskPRs store value in PRStatusChip (same as PRTaskIcon)
- add tablist/tab/aria-selected semantics to the popover tabs

* fix(web): warm default PR only, skip terminal PRs, add tabpanel ARIA

- chip background-sync warms pickDefaultPR (the tab the popover opens), not list head
- PRFeedbackWarmer skips merged/closed PRs (no wasted fetches in topbar path)
- tabpanel role + aria-controls/labelledby wire tabs to the panel

* fix(web): complete ARIA tabs keyboard model in multi-PR popover

- roving tabindex: only the active tab in the page tab order
- ArrowLeft/ArrowRight move selection + focus within the tablist (wrapping)
- stable useId-scoped tabpanel id so every tab's aria-controls resolves
- document topbar-only scoping of prCheckGroup e2e helpers

* refactor(web): skip duplicate rank call for first PR in pickDefaultPR
2026-06-12 19:34:27 +01:00

215 lines
8.1 KiB
TypeScript

"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<string, number> = {
// 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 <SinglePRIcon taskId={taskId} pr={prs[0]} />;
return <MultiPRIcon taskId={taskId} prs={prs} />;
}
function SinglePRIcon({ taskId, pr }: { taskId: string; pr: TaskPR }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
data-testid={`pr-task-icon-${taskId}`}
data-pr-state={pr.state}
data-pr-count="1"
data-pr-ready-to-merge={isPRReadyToMerge(pr) ? "true" : "false"}
className={cn("inline-flex items-center shrink-0", getPRStatusColor(pr))}
>
<IconGitPullRequest className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent>{getPRTooltip(pr)}</TooltipContent>
</Tooltip>
);
}
function MultiPRIcon({ taskId, prs }: { taskId: string; prs: TaskPR[] }) {
const aggregateColor = aggregatePRStatusColor(prs);
const allReady = areAllOpenPRsReadyToMerge(prs);
return (
<Tooltip>
<TooltipTrigger asChild>
<span
data-testid={`pr-task-icon-${taskId}`}
data-pr-count={prs.length}
data-pr-ready-to-merge={allReady ? "true" : "false"}
className={cn("inline-flex items-center gap-0.5 shrink-0", aggregateColor)}
>
<IconGitPullRequest className="h-3.5 w-3.5" />
<span className="text-[9px] font-semibold leading-none">{prs.length}</span>
</span>
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-col gap-1 text-xs">
{prs.map((pr) => (
<div key={pr.id} className="flex items-center gap-2">
<span className={cn("inline-flex shrink-0", getPRStatusColor(pr))}>
<IconGitPullRequest className="h-3 w-3" />
</span>
<span>{getPRTooltip(pr)}</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
);
}