Files
2026-09-10 18:14:05 +01:00

277 lines
9.5 KiB
TypeScript

import { IconCheck, IconX, IconClock } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import type { PRReview, RequestedReviewer } from "@/lib/types/github";
import { CollapsibleSection, FeedbackItemRow } from "./pr-shared";
import { useTranslation } from "react-i18next";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
function ReviewStateIcon({ state }: { state: string }) {
if (state === "APPROVED") return <IconCheck className="h-3.5 w-3.5 text-green-500 shrink-0" />;
if (state === "CHANGES_REQUESTED") return <IconX className="h-3.5 w-3.5 text-red-500 shrink-0" />;
return <IconClock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />;
}
function reviewStateLabel(state: string): string {
const labels: Record<string, string> = {
APPROVED: "Approved",
CHANGES_REQUESTED: "Changes Requested",
COMMENTED: "Commented",
PENDING: "Pending",
DISMISSED: "Dismissed",
};
return labels[state] ?? state;
}
export function ReviewStateBadge({ state }: { state: string }) {
if (!state) return null;
const styles: Record<string, string> = {
approved: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
changes_requested: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
pending: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
};
const labelMap: Record<string, string> = {
approved: "Approved",
changes_requested: "Changes requested",
pending: "Review pending",
};
return (
<Badge variant="secondary" className={`text-[10px] px-1.5 py-0 ${styles[state] ?? ""}`}>
{labelMap[state] ?? state}
</Badge>
);
}
function buildReviewMessage(review: PRReview, prUrl: string): string {
const parts = [`Review from **${review.author}** (${review.state}):`];
if (review.body) parts.push(review.body);
parts.push(`PR: ${prUrl}`);
parts.push("Please address this review feedback.");
return parts.join("\n\n");
}
function buildAllReviewsMessage(reviews: PRReview[], prUrl: string): string {
const parts = ["### All PR Reviews", ""];
for (const r of reviews) {
parts.push(`**${r.author}** - ${reviewStateLabel(r.state)}`);
if (r.body) parts.push(r.body);
parts.push("");
}
parts.push(`PR: ${prUrl}`);
parts.push("Please address the review feedback above.");
return parts.join("\n");
}
function normalizeLogin(login: string): string {
return login.trim().toLowerCase();
}
export function reconcileReviews(reviews: PRReview[], requestedReviewers: RequestedReviewer[]) {
const latestByAuthor = new Map<string, PRReview>();
for (const review of reviews) {
const author = normalizeLogin(review.author);
const current = latestByAuthor.get(author);
if (!current || new Date(review.created_at) > new Date(current.created_at)) {
latestByAuthor.set(author, review);
}
}
const requestedByLogin = new Map<string, RequestedReviewer>();
for (const reviewer of requestedReviewers) {
const login = normalizeLogin(reviewer.login);
if (!requestedByLogin.has(login)) requestedByLogin.set(login, reviewer);
}
return {
reviews: Array.from(latestByAuthor.entries())
.filter(([author]) => !requestedByLogin.has(author))
.map(([, review]) => review),
pendingReviewers: Array.from(requestedByLogin.values()),
};
}
function formatPendingReviewer(reviewer: RequestedReviewer): string {
if (reviewer.type === "team") return `${reviewer.login} (team)`;
return reviewer.login;
}
function formatReviewSummary(reviews: PRReview[]): string {
const approved = reviews.filter((r) => r.state === "APPROVED").length;
const changes = reviews.filter((r) => r.state === "CHANGES_REQUESTED").length;
const parts: string[] = [];
if (approved > 0) parts.push(`${approved} approved`);
if (changes > 0) parts.push(`${changes} changes requested`);
const other = reviews.length - approved - changes;
if (other > 0) parts.push(`${other} other`);
return parts.join(", ");
}
function computeSectionSummary(
reviews: PRReview[],
requestedReviewers: RequestedReviewer[],
pendingReviewCount: number,
) {
const submittedSummary = reviews.length > 0 ? formatReviewSummary(reviews) : "";
const pendingCount =
requestedReviewers.length > 0 ? requestedReviewers.length : pendingReviewCount;
const summaryParts: string[] = [];
if (submittedSummary) summaryParts.push(submittedSummary);
if (pendingCount > 0) summaryParts.push(`${pendingCount} pending`);
const summary = summaryParts.length > 0 ? ` - ${summaryParts.join(", ")}` : "";
return { pendingCount, summary, totalCount: reviews.length + pendingCount };
}
function ReviewMetaBadge({ state }: { state: string }) {
return (
<>
<ReviewStateIcon state={state} />
<span className="text-[10px] text-muted-foreground truncate">{reviewStateLabel(state)}</span>
</>
);
}
function SubmittedReviewRow({
review,
prUrl,
onAddAsContext,
canReRequest,
isRequesting,
onReRequest,
}: {
review: PRReview;
prUrl: string;
onAddAsContext: (message: string) => void;
canReRequest: boolean;
isRequesting: boolean;
onReRequest?: (reviewer: string) => void;
}) {
const { t } = useTranslation();
const hasActionable = review.state === "CHANGES_REQUESTED" || !!review.body;
return (
<div data-testid={`pr-submitted-review-${normalizeLogin(review.author)}`}>
<FeedbackItemRow
author={review.author}
authorAvatar={review.author_avatar}
body={review.body || undefined}
createdAt={review.created_at}
metaBadge={<ReviewMetaBadge state={review.state} />}
trailingAction={
canReRequest && review.state === "DISMISSED" && onReRequest ? (
<div className="basis-full w-full sm:basis-auto sm:w-auto">
<Button
type="button"
variant="outline"
className={controlSizingClassName(
"standard",
"w-full max-w-full shrink-0 cursor-pointer px-2 text-[10px] sm:w-auto",
)}
data-testid={`pr-rerequest-review-${normalizeLogin(review.author)}`}
aria-label={t("github:reRequestReviewFrom", { author: review.author })}
onClick={() => onReRequest(review.author)}
disabled={isRequesting}
>
{isRequesting ? t("github:requesting") : t("github:reRequestReview")}
</Button>
</div>
) : undefined
}
onAddAsContext={
hasActionable ? () => onAddAsContext(buildReviewMessage(review, prUrl)) : undefined
}
/>
</div>
);
}
function PendingReviewRow({ reviewer }: { reviewer: RequestedReviewer }) {
const { t } = useTranslation();
return (
<div
className="px-2.5 py-1.5 rounded-md border border-border bg-muted/30"
data-testid={`pr-pending-reviewer-${normalizeLogin(reviewer.login)}`}
>
<div className="flex items-center gap-2">
<IconClock className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
<span className="text-xs font-medium">{formatPendingReviewer(reviewer)}</span>
<span className="text-[10px] text-muted-foreground truncate">
{t("github:pendingReview")}
</span>
</div>
</div>
);
}
export function ReviewsSection({
reviews,
requestedReviewers,
prUrl,
reviewState,
pendingReviewCount,
onAddAsContext,
canReRequest = false,
requestingReviewers = [],
onReRequest,
}: {
reviews: PRReview[];
requestedReviewers: RequestedReviewer[];
prUrl: string;
reviewState: string;
pendingReviewCount: number;
onAddAsContext: (message: string) => void;
canReRequest?: boolean;
requestingReviewers?: string[];
onReRequest?: (reviewer: string) => void;
}) {
const { t } = useTranslation();
const { reviews: reconciledReviews, pendingReviewers } = reconcileReviews(
reviews,
requestedReviewers,
);
const requestingReviewerLogins = new Set(requestingReviewers.map(normalizeLogin));
const { pendingCount, summary, totalCount } = computeSectionSummary(
reconciledReviews,
pendingReviewers,
pendingReviewCount,
);
const pendingText = pendingCount > 0 ? ` (${pendingCount} pending)` : "";
const subtitle = reviewState ? (
<div className="text-[10px] text-muted-foreground px-2 pb-1">
{t("github:overallLabel")} <ReviewStateBadge state={reviewState} />
{pendingText && <span className="text-yellow-600 dark:text-yellow-400">{pendingText}</span>}
</div>
) : null;
return (
<CollapsibleSection
title={t("github:reviews", { summary })}
count={totalCount}
defaultOpen
subtitle={subtitle}
onAddAll={
reconciledReviews.length > 0
? () => onAddAsContext(buildAllReviewsMessage(reconciledReviews, prUrl))
: undefined
}
addAllLabel={t("github:addAllReviewsToChatContext")}
>
{reconciledReviews.length === 0 && pendingCount === 0 && (
<p className="text-xs text-muted-foreground px-2 py-2">{t("github:noReviewsYet")}</p>
)}
{reconciledReviews.map((review) => (
<SubmittedReviewRow
key={review.id}
review={review}
prUrl={prUrl}
onAddAsContext={onAddAsContext}
canReRequest={canReRequest}
isRequesting={requestingReviewerLogins.has(normalizeLogin(review.author))}
onReRequest={onReRequest}
/>
))}
{pendingReviewers.map((reviewer) => (
<PendingReviewRow key={`pending-${reviewer.type}-${reviewer.login}`} reviewer={reviewer} />
))}
</CollapsibleSection>
);
}