Files
kandev/apps/web/components/github/pr-mergeability-notice.tsx
2026-08-05 15:14:26 +02:00

186 lines
5.3 KiB
TypeScript

"use client";
import { IconAlertTriangle } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { cn } from "@/lib/utils";
import type { MergeableState } from "@/lib/types/github";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
// --- Pure descriptor (unit-tested) ---
export type MergeabilityNotice =
| { kind: "none" }
| { kind: "banner" } // dirty / merge conflicts
| { kind: "chip"; label: string } // blocked / behind base
| { kind: "text" }; // generic non-mergeable fallback
/** Maps a PR's merge state to how prominently the panel surfaces it. */
export function describeMergeability({
state,
mergeable,
isDraft,
prState,
}: {
state: MergeableState | undefined;
mergeable: boolean;
isDraft: boolean;
prState: string;
}): MergeabilityNotice {
if (prState !== "open" || isDraft) return { kind: "none" };
switch (state) {
case "dirty":
return { kind: "banner" };
case "blocked":
return { kind: "chip", label: t("github:blocked") };
case "behind":
return { kind: "chip", label: t("github:behindBase") };
case "clean":
case "unstable":
case "has_hooks":
case "draft":
// "draft" is gated above via isDraft, but handle the enum value too.
return { kind: "none" };
default:
// unknown / "" / future states: defer to the legacy boolean signal.
return mergeable === false ? { kind: "text" } : { kind: "none" };
}
}
/** Chat-context message sent to the agent when the user clicks "Resolve conflicts". */
export function buildConflictResolutionMessage({
prNumber,
headBranch,
baseBranch,
}: {
prNumber: number;
headBranch: string;
baseBranch: string;
}): string {
return (
`PR #${prNumber} (\`${headBranch}\` → \`${baseBranch}\`) has merge conflicts and ` +
`can't be merged automatically. Please merge \`${baseBranch}\` into \`${headBranch}\` ` +
`(or rebase onto it) and resolve the conflicts.`
);
}
// --- Presentation ---
function ConflictBanner({
baseBranch,
onResolveConflicts,
resolveDisabled,
popover,
}: {
baseBranch: string;
onResolveConflicts?: () => void;
resolveDisabled?: boolean;
popover?: boolean;
}) {
const { t } = useTranslation();
return (
<div
data-testid="pr-conflict-banner"
className={cn(
"flex items-start gap-2.5 rounded-md border border-red-500/35 bg-red-500/10",
popover ? "px-2.5 py-2" : "px-3 py-2.5",
)}
>
<IconAlertTriangle className="h-4 w-4 shrink-0 text-red-600 dark:text-red-400 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-red-600 dark:text-red-400">
{t("github:mergeConflictsWith")} <code className="font-mono">{baseBranch}</code>
</div>
<p className="mt-0.5 text-[11px] leading-snug text-muted-foreground">
{t("github:thisBranchCanTBeMerged")}
</p>
{onResolveConflicts && (
<Button
size="sm"
variant="outline"
data-testid="pr-resolve-conflicts-button"
className="mt-2 h-6 cursor-pointer px-2 text-[11px]"
onClick={onResolveConflicts}
disabled={resolveDisabled}
>
{resolveDisabled ? t("github:addedToChatContext") : t("github:resolveConflicts")}
</Button>
)}
</div>
</div>
);
}
function MergeabilityChip({ label, popover }: { label: string; popover?: boolean }) {
return (
<span
className={cn(
"flex items-center text-amber-600 dark:text-amber-400",
popover ? "gap-1.5 text-xs" : "gap-1 text-[10px]",
)}
>
<IconAlertTriangle className={popover ? "h-3.5 w-3.5" : "h-3 w-3"} />
{label}
</span>
);
}
function NotMergeableText({ popover }: { popover?: boolean }) {
const { t } = useTranslation();
return (
<span
className={cn(
"flex items-center text-yellow-600 dark:text-yellow-400",
popover ? "gap-1.5 text-xs" : "gap-1 text-[10px]",
)}
>
<IconAlertTriangle className={popover ? "h-3.5 w-3.5" : "h-3 w-3"} />
{t("github:notMergeable")}
</span>
);
}
export function PRMergeabilityNotice({
state,
mergeable,
isDraft,
prState,
baseBranch,
onResolveConflicts,
resolveDisabled,
popover,
}: {
state: MergeableState | undefined;
mergeable: boolean;
isDraft: boolean;
prState: string;
baseBranch: string;
onResolveConflicts?: () => void;
resolveDisabled?: boolean;
/** Render the CI-hover-popover variant: the banner uses tighter card padding,
* and the chip/text match the popover's `text-xs` rows (so it is slightly
* larger than the detail-panel default, which sits in a denser chip row). */
popover?: boolean;
}) {
const notice = describeMergeability({ state, mergeable, isDraft, prState });
if (notice.kind === "none") return null;
if (notice.kind === "banner")
return (
<ConflictBanner
baseBranch={baseBranch}
onResolveConflicts={onResolveConflicts}
resolveDisabled={resolveDisabled}
popover={popover}
/>
);
return (
<div className={cn("flex items-center gap-1.5 flex-wrap", popover && "px-1 py-1")}>
{notice.kind === "chip" ? (
<MergeabilityChip label={notice.label} popover={popover} />
) : (
<NotMergeableText popover={popover} />
)}
</div>
);
}