fix(github): harden auto-merge and surface task automation (#3128)
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
|
||||
|
||||
const hookMocks = vi.hoisted(() => ({
|
||||
error: null as string | null,
|
||||
options: null as TaskCIAutomationOptions | null,
|
||||
refresh: vi.fn(),
|
||||
retryMerge: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/github/use-task-ci-options", () => ({
|
||||
useTaskCIAutomationOptions: () => ({
|
||||
options: hookMocks.options,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: hookMocks.error,
|
||||
refresh: hookMocks.refresh,
|
||||
retryMerge: hookMocks.retryMerge,
|
||||
update: vi.fn(),
|
||||
resetPrompt: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/github/pr-ci-automation-prompt-dialog", () => ({
|
||||
CIAutomationPromptDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/github/pr-ci-automation-rows", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/components/github/pr-ci-automation-rows")>();
|
||||
return {
|
||||
...actual,
|
||||
CIAutomationHeader: () => null,
|
||||
CIAutomationOptionRows: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
import { PRCIAutomationControls } from "./pr-ci-automation-controls";
|
||||
|
||||
const pr = {
|
||||
task_id: "task-1",
|
||||
repository_id: "repo-1",
|
||||
pr_number: 42,
|
||||
} as TaskPR;
|
||||
|
||||
function makeOptions(errorKind: string): TaskCIAutomationOptions {
|
||||
return {
|
||||
task_id: "task-1",
|
||||
auto_fix_enabled: false,
|
||||
auto_merge_enabled: true,
|
||||
auto_fix_prompt_override: null,
|
||||
effective_auto_fix_prompt: "Default prompt",
|
||||
using_default_prompt: true,
|
||||
updated_at: "2026-08-28T10:00:00Z",
|
||||
pr_options: [],
|
||||
pr_states: [
|
||||
{
|
||||
task_id: "task-1",
|
||||
repository_id: "repo-1",
|
||||
pr_number: 42,
|
||||
last_error: "merge PR: provider unavailable",
|
||||
last_error_kind: errorKind,
|
||||
} as TaskCIAutomationOptions["pr_states"][number],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hookMocks.error = null;
|
||||
hookMocks.options = makeOptions("auto_merge");
|
||||
hookMocks.refresh.mockReset().mockResolvedValue(null);
|
||||
hookMocks.retryMerge.mockReset().mockResolvedValue({ accepted: true });
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("PRCIAutomationControls retry actions", () => {
|
||||
it("rearms only a typed auto-merge error for the exact linked PR", () => {
|
||||
render(<PRCIAutomationControls pr={pr} />);
|
||||
|
||||
const action = screen.getByRole("button", { name: "Retry" });
|
||||
expect(action.className).toContain("h-11");
|
||||
fireEvent.click(action);
|
||||
|
||||
expect(hookMocks.retryMerge).toHaveBeenCalledWith("repo-1", 42);
|
||||
expect(hookMocks.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes state for a stored error that is not an auto-merge failure", () => {
|
||||
hookMocks.options = makeOptions("auto_fix");
|
||||
render(<PRCIAutomationControls pr={pr} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
|
||||
expect(hookMocks.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(hookMocks.retryMerge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes state after a load error and never rearms merge", () => {
|
||||
hookMocks.options = null;
|
||||
hookMocks.error = "backend unavailable";
|
||||
render(<PRCIAutomationControls pr={pr} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
|
||||
expect(hookMocks.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(hookMocks.retryMerge).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useTaskCIAutomationOptions } from "@/hooks/domains/github/use-task-ci-options";
|
||||
import {
|
||||
@@ -17,9 +18,10 @@ import {
|
||||
} from "@/components/github/pr-ci-automation-rows";
|
||||
|
||||
export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
|
||||
const { options, loading, saving, error, refresh, update, resetPrompt } =
|
||||
const { options, loading, saving, error, refresh, update, resetPrompt, retryMerge } =
|
||||
useTaskCIAutomationOptions(pr.task_id);
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const [promptOpen, setPromptOpen] = useState(false);
|
||||
const [promptDraft, setPromptDraft] = useState("");
|
||||
const automationState = findCIAutomationStateForPR(options?.pr_states, pr);
|
||||
@@ -48,9 +50,11 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
|
||||
repository_id: pr.repository_id ?? "",
|
||||
pr_number: pr.pr_number,
|
||||
};
|
||||
Promise.resolve(update(scoped)).catch(() => reportError("Failed to update CI automation."));
|
||||
Promise.resolve(update(scoped)).catch(() =>
|
||||
reportError(t("github:failedToUpdateCiAutomation")),
|
||||
);
|
||||
},
|
||||
[pr, reportError, update],
|
||||
[pr, reportError, t, update],
|
||||
);
|
||||
|
||||
// The auto-fix prompt override stays task-level (applies to every linked
|
||||
@@ -60,18 +64,26 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
|
||||
if (!value) return;
|
||||
Promise.resolve(update({ auto_fix_prompt_override: value }))
|
||||
.then(() => setPromptOpen(false))
|
||||
.catch(() => reportError("Failed to save auto-fix prompt."));
|
||||
}, [promptDraft, reportError, update]);
|
||||
.catch(() => reportError(t("github:failedToSaveAutoFixPrompt")));
|
||||
}, [promptDraft, reportError, t, update]);
|
||||
|
||||
const useDefaultPrompt = useCallback(() => {
|
||||
Promise.resolve(resetPrompt())
|
||||
.then(() => setPromptOpen(false))
|
||||
.catch(() => reportError("Failed to reset auto-fix prompt."));
|
||||
}, [reportError, resetPrompt]);
|
||||
.catch(() => reportError(t("github:failedToResetAutoFixPrompt")));
|
||||
}, [reportError, resetPrompt, t]);
|
||||
|
||||
const retryLoad = useCallback(() => {
|
||||
Promise.resolve(refresh()).catch(() => reportError("Failed to load CI automation."));
|
||||
}, [refresh, reportError]);
|
||||
Promise.resolve(refresh()).catch(() =>
|
||||
reportError(t("github:failedToLoadCiAutomationOptions")),
|
||||
);
|
||||
}, [refresh, reportError, t]);
|
||||
|
||||
const retryAutoMerge = useCallback(() => {
|
||||
Promise.resolve(retryMerge(pr.repository_id ?? "", pr.pr_number))
|
||||
.then(() => toast({ description: t("github:autoMergeRetryAccepted") }))
|
||||
.catch(() => reportError(t("github:failedToRetryAutoMerge")));
|
||||
}, [pr.pr_number, pr.repository_id, reportError, retryMerge, t, toast]);
|
||||
|
||||
const disabled = loading || saving || !options;
|
||||
return (
|
||||
@@ -93,14 +105,17 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
|
||||
patchOption={patchOption}
|
||||
automationState={automationState}
|
||||
/>
|
||||
{automationState?.last_error && (
|
||||
<CIAutomationErrorRow
|
||||
error={automationState.last_error}
|
||||
loading={loading}
|
||||
onRetry={retryLoad}
|
||||
/>
|
||||
)}
|
||||
{error && <CIAutomationErrorRow error={error} loading={loading} onRetry={retryLoad} />}
|
||||
<PRCIAutomationErrors
|
||||
storedError={automationState?.last_error}
|
||||
storedErrorKind={automationState?.last_error_kind}
|
||||
loadError={error}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
retryLabel={t("github:retry")}
|
||||
refreshLabel={t("github:refresh")}
|
||||
onRetryMerge={retryAutoMerge}
|
||||
onRefresh={retryLoad}
|
||||
/>
|
||||
<CIAutomationPromptDialog
|
||||
open={promptOpen}
|
||||
prompt={promptDraft}
|
||||
@@ -113,3 +128,47 @@ export function PRCIAutomationControls({ pr }: { pr: TaskPR }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PRCIAutomationErrors({
|
||||
storedError,
|
||||
storedErrorKind,
|
||||
loadError,
|
||||
loading,
|
||||
saving,
|
||||
retryLabel,
|
||||
refreshLabel,
|
||||
onRetryMerge,
|
||||
onRefresh,
|
||||
}: {
|
||||
storedError?: string | null;
|
||||
storedErrorKind?: string;
|
||||
loadError?: string | null;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
retryLabel: string;
|
||||
refreshLabel: string;
|
||||
onRetryMerge: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const mergeError = storedErrorKind === "auto_merge";
|
||||
return (
|
||||
<>
|
||||
{storedError && (
|
||||
<CIAutomationErrorRow
|
||||
error={storedError}
|
||||
loading={loading || saving}
|
||||
actionLabel={mergeError ? retryLabel : refreshLabel}
|
||||
onAction={mergeError ? onRetryMerge : onRefresh}
|
||||
/>
|
||||
)}
|
||||
{loadError && (
|
||||
<CIAutomationErrorRow
|
||||
error={loadError}
|
||||
loading={loading}
|
||||
actionLabel={refreshLabel}
|
||||
onAction={onRefresh}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,13 +104,14 @@ export function CIAutomationRow({
|
||||
export function CIAutomationErrorRow({
|
||||
error,
|
||||
loading,
|
||||
onRetry,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
error: string;
|
||||
loading: boolean;
|
||||
onRetry: () => void;
|
||||
actionLabel: string;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -121,12 +122,12 @@ export function CIAutomationErrorRow({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 cursor-pointer gap-1 px-2 text-[11px]"
|
||||
className="h-11 min-w-11 cursor-pointer gap-1 px-2 text-[11px] sm:h-6 sm:min-w-0"
|
||||
disabled={loading}
|
||||
onClick={onRetry}
|
||||
onClick={onAction}
|
||||
>
|
||||
<IconRefresh className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
||||
{t("github:retry")}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -290,7 +290,9 @@ describe("PRCIPopover automation status", () => {
|
||||
auto_fix_exhausted_at: null,
|
||||
last_merge_signature: "",
|
||||
last_merge_attempt_at: null,
|
||||
last_merge_result: "",
|
||||
last_error: "No promptable task session is available.",
|
||||
last_error_kind: "",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
@@ -338,10 +340,12 @@ describe("PRCIPopover automation status", () => {
|
||||
auto_fix_exhausted_at: null,
|
||||
last_merge_signature: "",
|
||||
last_merge_attempt_at: null,
|
||||
last_merge_result: "",
|
||||
last_queue_attempt_head_sha: "head-a",
|
||||
last_queue_fix_event_id: "",
|
||||
last_queue_removal_cause: "provider_changed",
|
||||
last_error: null,
|
||||
last_error_kind: "",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
@@ -599,12 +603,12 @@ describe("PRCIPopover task prompts", () => {
|
||||
expect(hookMocks.updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers retry after CI automation options fail to load", () => {
|
||||
it("offers refresh after CI automation options fail to load", () => {
|
||||
hookMocks.error = BACKEND_UNAVAILABLE;
|
||||
renderPopover();
|
||||
|
||||
expect(screen.getByText(BACKEND_UNAVAILABLE)).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
|
||||
expect(hookMocks.refreshMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -143,7 +143,9 @@ function makeCIPrState(roundCount: number, exhausted = false) {
|
||||
auto_fix_exhausted_at: exhausted ? "2026-06-18T11:00:00Z" : null,
|
||||
last_merge_signature: "",
|
||||
last_merge_attempt_at: null,
|
||||
last_merge_result: "" as const,
|
||||
last_error: exhausted ? "CI auto-fix paused after 10 rounds for this PR" : null,
|
||||
last_error_kind: "",
|
||||
created_at: "2026-06-18T10:00:00Z",
|
||||
updated_at: "2026-06-18T10:00:00Z",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
|
||||
import { automationForPR } from "./pr-status-automation-badges";
|
||||
import type { TaskPRInfo } from "@/components/task/task-pr-info";
|
||||
|
||||
export type { TaskPRInfo } from "@/components/task/task-pr-info";
|
||||
|
||||
export type TaskPRAutomationDetail = {
|
||||
number: number;
|
||||
repository?: string;
|
||||
autoFixEnabled: boolean;
|
||||
autoMergeEnabled: boolean;
|
||||
};
|
||||
|
||||
export type TaskPRAutomationSummary = {
|
||||
autoFixEnabled: boolean;
|
||||
autoMergeEnabled: boolean;
|
||||
details: TaskPRAutomationDetail[];
|
||||
};
|
||||
|
||||
export function getTaskPRAutomationSummary(
|
||||
prs: TaskPR[],
|
||||
prInfo?: TaskPRInfo,
|
||||
options?: TaskCIAutomationOptions | null,
|
||||
): TaskPRAutomationSummary {
|
||||
const activePRs = prs.filter((pr) => pr.state.trim().toLowerCase() === "open");
|
||||
// The bounded task-row projection is the source of truth until the full PR
|
||||
// list is hydrated. Keep its flags visible even when a previously hydrated
|
||||
// options response is present but the scoped PR records are not.
|
||||
if (!options || prs.length === 0) {
|
||||
return {
|
||||
autoFixEnabled: prInfo?.autoFixEnabled === true,
|
||||
autoMergeEnabled: prInfo?.autoMergeEnabled === true,
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
const details = activePRs.map((pr) => {
|
||||
const automation = automationForPR(options, pr);
|
||||
const repository = [pr.owner, pr.repo].filter(Boolean).join("/") || undefined;
|
||||
return {
|
||||
number: pr.pr_number,
|
||||
...(repository ? { repository } : {}),
|
||||
autoFixEnabled: automation.autoFix,
|
||||
autoMergeEnabled: automation.autoMerge,
|
||||
};
|
||||
});
|
||||
return {
|
||||
autoFixEnabled: details.some((detail) => detail.autoFixEnabled),
|
||||
autoMergeEnabled: details.some((detail) => detail.autoMergeEnabled),
|
||||
details,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
|
||||
import { IconGitPullRequest } from "@tabler/icons-react";
|
||||
import {
|
||||
forwardRef,
|
||||
type FocusEventHandler,
|
||||
type MouseEventHandler,
|
||||
type PointerEventHandler,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@kandev/ui/drawer";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import type { TaskPRTooltipHydrationStatus } from "@/hooks/domains/github/use-task-pr-tooltip-hydration";
|
||||
import { useChangeRequestTaskTooltipState } from "@/components/integrations/use-change-request-task-tooltip-state";
|
||||
import type { TaskPRAutomationSummary, TaskPRInfo } from "./pr-task-automation";
|
||||
|
||||
export type PRTaskIconDisclosureProps = {
|
||||
taskId: string;
|
||||
prInfo?: TaskPRInfo;
|
||||
prs: TaskPR[];
|
||||
hasFullData: boolean;
|
||||
singlePR: TaskPR | null;
|
||||
readyToMerge: boolean;
|
||||
allReadyToMerge: boolean;
|
||||
displayState: string | undefined;
|
||||
displayCount: number;
|
||||
iconColor: string;
|
||||
ariaLabel: string;
|
||||
icon: ReactNode;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export function AutomationIndicatorDots({
|
||||
autoFixEnabled,
|
||||
autoMergeEnabled,
|
||||
}: Pick<TaskPRAutomationSummary, "autoFixEnabled" | "autoMergeEnabled">) {
|
||||
return (
|
||||
<>
|
||||
{autoFixEnabled && (
|
||||
<span
|
||||
data-testid="pr-task-automation-auto-fix"
|
||||
className="absolute left-0 top-0 h-1.5 w-1.5 rounded-full bg-yellow-400 ring-1 ring-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{autoMergeEnabled && (
|
||||
<span
|
||||
data-testid="pr-task-automation-auto-merge"
|
||||
className="absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-purple-500 ring-1 ring-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRTaskIconGlyph({ automation }: { automation: TaskPRAutomationSummary }) {
|
||||
return (
|
||||
<span className="relative inline-flex h-3.5 w-3.5 shrink-0">
|
||||
<IconGitPullRequest aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
<AutomationIndicatorDots
|
||||
autoFixEnabled={automation.autoFixEnabled}
|
||||
autoMergeEnabled={automation.autoMergeEnabled}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRTaskIconDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
t,
|
||||
...props
|
||||
}: PRTaskIconDisclosureProps & {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
t: ReturnType<typeof useTranslation>["t"];
|
||||
}) {
|
||||
const { prs, prInfo, singlePR, content } = props;
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerTrigger asChild>
|
||||
<PRTaskIconTrigger
|
||||
{...props}
|
||||
touch
|
||||
open={open}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
data-testid={`pr-task-automation-drawer-${props.taskId}`}
|
||||
className="max-h-[80dvh] flex flex-col"
|
||||
>
|
||||
<DrawerHeader className="shrink-0 border-b py-2">
|
||||
<DrawerTitle className="text-sm">
|
||||
{prs.length > 1
|
||||
? t("github:pullRequestCount", { count: prs.length })
|
||||
: t("github:pullRequestStatus", { number: singlePR?.pr_number ?? prInfo?.number })}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
{t("github:pullRequestCiStatusReviewsAnd")}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3" data-vaul-no-drag>
|
||||
{content}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRTaskIconTooltip({
|
||||
tooltip,
|
||||
...props
|
||||
}: PRTaskIconDisclosureProps & {
|
||||
tooltip: ReturnType<typeof useChangeRequestTaskTooltipState>;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip open={tooltip.open}>
|
||||
<TooltipTrigger asChild>
|
||||
<PRTaskIconTrigger
|
||||
{...props}
|
||||
onPointerEnter={tooltip.onPointerEnter}
|
||||
onPointerLeave={tooltip.onPointerLeave}
|
||||
onFocus={tooltip.onFocus}
|
||||
onBlur={tooltip.onBlur}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
sideOffset={6}
|
||||
onEscapeKeyDown={tooltip.onEscapeKeyDown}
|
||||
className="w-80 max-w-[calc(100vw-1rem)] p-3"
|
||||
>
|
||||
{props.content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type PRTaskIconTriggerProps = PRTaskIconDisclosureProps & {
|
||||
touch?: boolean;
|
||||
open?: boolean;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
onPointerEnter?: PointerEventHandler<HTMLSpanElement>;
|
||||
onPointerLeave?: PointerEventHandler<HTMLSpanElement>;
|
||||
onFocus?: FocusEventHandler<HTMLSpanElement>;
|
||||
onBlur?: FocusEventHandler<HTMLSpanElement>;
|
||||
};
|
||||
|
||||
export const PRTaskIconTrigger = forwardRef<HTMLElement, PRTaskIconTriggerProps>(
|
||||
function PRTaskIconTrigger(
|
||||
{
|
||||
touch = false,
|
||||
open = false,
|
||||
onClick,
|
||||
taskId,
|
||||
prInfo: _prInfo,
|
||||
prs,
|
||||
hasFullData,
|
||||
singlePR: _singlePR,
|
||||
readyToMerge,
|
||||
allReadyToMerge,
|
||||
displayState,
|
||||
displayCount,
|
||||
iconColor,
|
||||
ariaLabel,
|
||||
icon,
|
||||
content: _content,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...triggerAttributes
|
||||
},
|
||||
ref: Ref<HTMLElement>,
|
||||
) {
|
||||
const commonAttributes = {
|
||||
"data-testid": `pr-task-icon-${taskId}`,
|
||||
"data-pr-state": displayState,
|
||||
"data-pr-count": displayCount,
|
||||
"data-pr-ready-to-merge": hasFullData
|
||||
? String(prs.length === 1 ? readyToMerge : allReadyToMerge)
|
||||
: undefined,
|
||||
"aria-label": ariaLabel,
|
||||
};
|
||||
const contents = (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{icon}
|
||||
{prs.length > 1 ? (
|
||||
<span className="text-[9px] font-semibold leading-none tabular-nums">{prs.length}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
if (touch) {
|
||||
return (
|
||||
<button
|
||||
ref={ref as Ref<HTMLButtonElement>}
|
||||
type="button"
|
||||
{...triggerAttributes}
|
||||
{...commonAttributes}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"relative inline-flex h-3.5 w-3.5 shrink-0 cursor-pointer items-center justify-center [@media(pointer:coarse)]:after:absolute [@media(pointer:coarse)]:after:-inset-[15px] [@media(pointer:coarse)]:after:content-['']",
|
||||
iconColor,
|
||||
)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{contents}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
ref={ref as Ref<HTMLSpanElement>}
|
||||
{...triggerAttributes}
|
||||
{...commonAttributes}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
className={cn("inline-flex shrink-0 items-center", prs.length > 1 && "gap-0.5", iconColor)}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{contents}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export function TaskPRAutomationDetails({
|
||||
summary,
|
||||
status,
|
||||
}: {
|
||||
summary: TaskPRAutomationSummary;
|
||||
status?: TaskPRTooltipHydrationStatus;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hasAutomation = summary.autoFixEnabled || summary.autoMergeEnabled;
|
||||
if (!hasAutomation && summary.details.length === 0) return null;
|
||||
const detailContent =
|
||||
summary.details.length > 0 ? (
|
||||
<div className="mt-1 space-y-1">
|
||||
{summary.details.map((detail) => (
|
||||
<div
|
||||
key={`${detail.repository ?? ""}-${detail.number}`}
|
||||
className="flex flex-wrap items-center gap-x-2 gap-y-1"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{detail.repository
|
||||
? t("github:prTaskStatusRepositoryNumber", {
|
||||
repository: detail.repository,
|
||||
number: detail.number,
|
||||
})
|
||||
: t("github:prTaskStatusNumber", { number: detail.number })}
|
||||
</span>
|
||||
{detail.autoFixEnabled && (
|
||||
<span className="text-yellow-500">{t("github:autoFix")}</span>
|
||||
)}
|
||||
{detail.autoMergeEnabled && (
|
||||
<span className="text-purple-400">{t("github:autoMerge")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
const loadingContent =
|
||||
status === "loading" || status === "idle" ? (
|
||||
<p className="mt-1 text-muted-foreground">{t("github:taskPrDetailsLoading")}</p>
|
||||
) : null;
|
||||
return (
|
||||
<section
|
||||
data-testid="pr-task-automation-details"
|
||||
className="mt-2 border-t border-border/60 pt-2 text-xs"
|
||||
>
|
||||
<h4 className="font-medium text-foreground">{t("github:automation")}</h4>
|
||||
{detailContent ?? loadingContent}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompactPRTooltipContent({ status }: { status: TaskPRTooltipHydrationStatus }) {
|
||||
const { t } = useTranslation();
|
||||
if (status === "loading" || status === "idle") {
|
||||
return (
|
||||
<span data-testid="pr-task-tooltip-loading" className="text-sm text-muted-foreground">
|
||||
{t("github:taskPrDetailsLoading")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "unavailable") {
|
||||
return (
|
||||
<span data-testid="pr-task-tooltip-unavailable" className="text-sm text-muted-foreground">
|
||||
{t("github:taskPrDetailsUnavailable")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getTaskPRAutomationSummary } from "./pr-task-icon";
|
||||
import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
|
||||
|
||||
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
|
||||
return {
|
||||
id: "id",
|
||||
workspace_id: "workspace-1",
|
||||
task_id: "task",
|
||||
owner: "o",
|
||||
repo: "r",
|
||||
pr_number: 1,
|
||||
pr_url: "",
|
||||
pr_title: "Test PR",
|
||||
head_branch: "feat",
|
||||
base_branch: "main",
|
||||
author_login: "alice",
|
||||
state: "open",
|
||||
review_state: "",
|
||||
checks_state: "",
|
||||
mergeable_state: "",
|
||||
review_count: 0,
|
||||
pending_review_count: 0,
|
||||
comment_count: 0,
|
||||
unresolved_review_threads: 0,
|
||||
checks_total: 0,
|
||||
checks_passing: 0,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
created_at: "",
|
||||
merged_at: null,
|
||||
closed_at: null,
|
||||
last_synced_at: null,
|
||||
updated_at: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeOptions(): TaskCIAutomationOptions {
|
||||
return {
|
||||
task_id: "task",
|
||||
workspace_id: "workspace-1",
|
||||
auto_fix_enabled: false,
|
||||
auto_merge_enabled: false,
|
||||
auto_fix_prompt_override: null,
|
||||
effective_auto_fix_prompt: "",
|
||||
using_default_prompt: true,
|
||||
updated_at: "2026-08-01T00:00:00Z",
|
||||
pr_states: [],
|
||||
pr_options: [
|
||||
{
|
||||
task_id: "task",
|
||||
repository_id: "repo-a",
|
||||
pr_number: 1,
|
||||
auto_fix_enabled: true,
|
||||
auto_merge_enabled: false,
|
||||
prompt_on_review_requested: false,
|
||||
prompt_on_merged: false,
|
||||
prompt_on_closed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
{
|
||||
task_id: "task",
|
||||
repository_id: "repo-b",
|
||||
pr_number: 2,
|
||||
auto_fix_enabled: false,
|
||||
auto_merge_enabled: true,
|
||||
prompt_on_review_requested: false,
|
||||
prompt_on_merged: false,
|
||||
prompt_on_closed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
{
|
||||
task_id: "task",
|
||||
repository_id: "repo-c",
|
||||
pr_number: 3,
|
||||
auto_fix_enabled: true,
|
||||
auto_merge_enabled: true,
|
||||
prompt_on_review_requested: false,
|
||||
prompt_on_merged: false,
|
||||
prompt_on_closed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("getTaskPRAutomationSummary", () => {
|
||||
it("aggregates independent active PR settings and ignores terminal PRs", () => {
|
||||
const summary = getTaskPRAutomationSummary(
|
||||
[
|
||||
makePR({ repository_id: "repo-a", pr_number: 1 }),
|
||||
makePR({ repository_id: "repo-b", pr_number: 2 }),
|
||||
makePR({ repository_id: "repo-c", pr_number: 3, state: "merged" }),
|
||||
],
|
||||
undefined,
|
||||
makeOptions(),
|
||||
);
|
||||
|
||||
expect(summary).toEqual({
|
||||
autoFixEnabled: true,
|
||||
autoMergeEnabled: true,
|
||||
details: [
|
||||
{ number: 1, repository: "o/r", autoFixEnabled: true, autoMergeEnabled: false },
|
||||
{ number: 2, repository: "o/r", autoFixEnabled: false, autoMergeEnabled: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses bounded row flags before per-PR details hydrate", () => {
|
||||
expect(
|
||||
getTaskPRAutomationSummary([], {
|
||||
number: 7,
|
||||
state: "open",
|
||||
autoFixEnabled: true,
|
||||
autoMergeEnabled: true,
|
||||
}),
|
||||
).toEqual({
|
||||
autoFixEnabled: true,
|
||||
autoMergeEnabled: true,
|
||||
details: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps bounded row flags while full PR records are temporarily unavailable", () => {
|
||||
expect(
|
||||
getTaskPRAutomationSummary(
|
||||
[],
|
||||
{ number: 7, state: "open", autoFixEnabled: true, autoMergeEnabled: false },
|
||||
makeOptions(),
|
||||
),
|
||||
).toEqual({
|
||||
autoFixEnabled: true,
|
||||
autoMergeEnabled: false,
|
||||
details: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains repository identity when linked PR numbers overlap", () => {
|
||||
const summary = getTaskPRAutomationSummary(
|
||||
[
|
||||
makePR({ owner: "acme", repo: "frontend", repository_id: "repo-a", pr_number: 7 }),
|
||||
makePR({ owner: "acme", repo: "backend", repository_id: "repo-b", pr_number: 7 }),
|
||||
],
|
||||
undefined,
|
||||
{
|
||||
...makeOptions(),
|
||||
pr_options: [
|
||||
{ ...makeOptions().pr_options[0], repository_id: "repo-a", pr_number: 7 },
|
||||
{ ...makeOptions().pr_options[1], repository_id: "repo-b", pr_number: 7 },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(summary.details.map(({ repository, number }) => ({ repository, number }))).toEqual([
|
||||
{ repository: "acme/frontend", number: 7 },
|
||||
{ repository: "acme/backend", number: 7 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -7,13 +7,16 @@ import { TaskContributionIcons } from "@/components/task/task-contribution-icons
|
||||
import { PRTaskIcon } from "./pr-task-icon";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import type { TaskCIAutomationOptions } from "@/lib/types/github";
|
||||
|
||||
const listTaskPRsMock = vi.hoisted(() => vi.fn());
|
||||
const getTaskCIAutomationOptionsMock = vi.hoisted(() => vi.fn());
|
||||
const TASK_ID = "task-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
|
||||
vi.mock("@/lib/api/domains/github-api", () => ({
|
||||
listTaskPRs: listTaskPRsMock,
|
||||
getTaskCIAutomationOptions: getTaskCIAutomationOptionsMock,
|
||||
}));
|
||||
|
||||
function renderWithStore(initialState: Partial<AppState> | undefined, ui: ReactNode) {
|
||||
@@ -58,8 +61,40 @@ function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
|
||||
};
|
||||
}
|
||||
|
||||
function makeAutomationOptions(
|
||||
overrides: Partial<TaskCIAutomationOptions> = {},
|
||||
): TaskCIAutomationOptions {
|
||||
return {
|
||||
task_id: TASK_ID,
|
||||
workspace_id: WORKSPACE_ID,
|
||||
auto_fix_enabled: true,
|
||||
auto_merge_enabled: true,
|
||||
auto_fix_prompt_override: null,
|
||||
effective_auto_fix_prompt: "",
|
||||
using_default_prompt: true,
|
||||
updated_at: "2026-08-01T00:00:00Z",
|
||||
pr_states: [],
|
||||
pr_options: [
|
||||
{
|
||||
task_id: TASK_ID,
|
||||
repository_id: "repo-1",
|
||||
pr_number: 1,
|
||||
auto_fix_enabled: true,
|
||||
auto_merge_enabled: true,
|
||||
prompt_on_review_requested: false,
|
||||
prompt_on_merged: false,
|
||||
prompt_on_closed: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
listTaskPRsMock.mockReset().mockReturnValue(new Promise(() => {}));
|
||||
getTaskCIAutomationOptionsMock.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
@@ -177,3 +212,46 @@ describe("PRTaskIcon corrupted store entry", () => {
|
||||
expect(document.activeElement).toBe(screen.getByTestId(`pr-task-icon-${TASK_ID}`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("PRTaskIcon automation indicators", () => {
|
||||
// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-002.10
|
||||
it("renders independent automation dots from the bounded row projection", () => {
|
||||
renderWithStore(
|
||||
{ workspaces: { items: [], activeId: WORKSPACE_ID } },
|
||||
<TaskContributionIcons
|
||||
taskId={TASK_ID}
|
||||
prInfo={{ number: 7, state: "open", autoFixEnabled: true, autoMergeEnabled: true }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("pr-task-automation-auto-fix")).not.toBeNull();
|
||||
expect(screen.getByTestId("pr-task-automation-auto-merge")).not.toBeNull();
|
||||
});
|
||||
|
||||
// @covers AC-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-002.11
|
||||
it("shows per-PR automation details after the icon disclosure hydrates", async () => {
|
||||
const response = Promise.resolve({
|
||||
task_prs: { [TASK_ID]: [makePR({ repository_id: "repo-1" })] },
|
||||
});
|
||||
listTaskPRsMock.mockReturnValue(response);
|
||||
getTaskCIAutomationOptionsMock.mockResolvedValue(makeAutomationOptions());
|
||||
renderWithStore(
|
||||
{ workspaces: { items: [], activeId: WORKSPACE_ID } },
|
||||
<TaskContributionIcons taskId={TASK_ID} prInfo={{ number: 1, state: "open" }} />,
|
||||
);
|
||||
|
||||
fireEvent.pointerEnter(screen.getByTestId(`pr-task-icon-${TASK_ID}`), {
|
||||
pointerType: "mouse",
|
||||
});
|
||||
await act(async () => {
|
||||
await response;
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("pr-task-automation-details").length).toBeGreaterThan(0),
|
||||
);
|
||||
expect(screen.getAllByText("o/r PR #1").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Auto-fix").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Auto-merge").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IconGitPullRequest } from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useChangeRequestTaskTooltipState } from "@/components/integrations/use-change-request-task-tooltip-state";
|
||||
import {
|
||||
@@ -14,10 +12,24 @@ import {
|
||||
import {
|
||||
getTaskPRsForCurrentWorkspace,
|
||||
useTaskPRTooltipHydration,
|
||||
type TaskPRTooltipHydrationStatus,
|
||||
} from "@/hooks/domains/github/use-task-pr-tooltip-hydration";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import type { TaskCIAutomationOptions } from "@/lib/types/github";
|
||||
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
|
||||
import { derivePRTaskStatusSummary, PRTaskStatusSummary } from "./pr-task-status-summary";
|
||||
import {
|
||||
CompactPRTooltipContent,
|
||||
PRTaskIconDrawer,
|
||||
PRTaskIconGlyph,
|
||||
PRTaskIconTooltip,
|
||||
TaskPRAutomationDetails,
|
||||
type PRTaskIconDisclosureProps,
|
||||
} from "./pr-task-icon-disclosure";
|
||||
import { getTaskPRAutomationSummary, type TaskPRInfo } from "./pr-task-automation";
|
||||
|
||||
export type { TaskPRInfo } from "./pr-task-automation";
|
||||
export { getTaskPRAutomationSummary } from "./pr-task-automation";
|
||||
export { AutomationIndicatorDots } from "./pr-task-icon-disclosure";
|
||||
|
||||
const MUTED_FOREGROUND = CHANGE_REQUEST_STATUS_COLORS.muted;
|
||||
const PURPLE_500 = CHANGE_REQUEST_STATUS_COLORS.merged;
|
||||
@@ -238,15 +250,9 @@ export function pickDefaultPR(prs: TaskPR[]): TaskPR | null {
|
||||
return best;
|
||||
}
|
||||
|
||||
export type TaskPRInfo = {
|
||||
number: number;
|
||||
state: string;
|
||||
aggregateState?: string;
|
||||
};
|
||||
|
||||
export function PRTaskIcon({ taskId, prInfo }: { taskId: string; prInfo?: TaskPRInfo }) {
|
||||
const prs = useAppStore((state) => getTaskPRsForCurrentWorkspace(state, taskId));
|
||||
const hydration = useTaskPRTooltipHydration(taskId);
|
||||
const hydration = useTaskPRTooltipHydration(taskId, { includeAutomation: true });
|
||||
const fullPRs = Array.isArray(prs) && prs.length > 0 ? prs : [];
|
||||
|
||||
// Defensive: an upstream payload may briefly seed byTaskId[taskId] with a
|
||||
@@ -254,7 +260,15 @@ export function PRTaskIcon({ taskId, prInfo }: { taskId: string; prInfo?: TaskPR
|
||||
// instead of falling through into a full-data summary, where for-of throws.
|
||||
if (fullPRs.length === 0 && !prInfo) return null;
|
||||
|
||||
return <PRTaskIconView taskId={taskId} prInfo={prInfo} prs={fullPRs} hydration={hydration} />;
|
||||
return (
|
||||
<PRTaskIconView
|
||||
taskId={taskId}
|
||||
prInfo={prInfo}
|
||||
prs={fullPRs}
|
||||
hydration={hydration}
|
||||
automationOptions={hydration.automationOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TaskPRIconPresentation = {
|
||||
@@ -289,21 +303,27 @@ function PRTaskIconView({
|
||||
prInfo,
|
||||
prs,
|
||||
hydration,
|
||||
automationOptions,
|
||||
}: {
|
||||
taskId: string;
|
||||
prInfo?: TaskPRInfo;
|
||||
prs: TaskPR[];
|
||||
hydration: ReturnType<typeof useTaskPRTooltipHydration>;
|
||||
automationOptions: TaskCIAutomationOptions | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { hydrate } = hydration;
|
||||
const hasFullData = prs.length > 0;
|
||||
const tooltip = useChangeRequestTaskTooltipState(
|
||||
!hasFullData && prInfo
|
||||
? () => {
|
||||
void hydration.hydrate();
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const usesTouchDrawer = useTouchDrawer();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const automation = getTaskPRAutomationSummary(prs, prInfo, automationOptions);
|
||||
const needsHydration =
|
||||
!hasFullData ||
|
||||
((automation.autoFixEnabled || automation.autoMergeEnabled) && !automationOptions);
|
||||
const hydrateOnDisclosure = useCallback(() => {
|
||||
if (needsHydration) void hydrate();
|
||||
}, [hydrate, needsHydration]);
|
||||
const tooltip = useChangeRequestTaskTooltipState(hydrateOnDisclosure);
|
||||
const {
|
||||
singlePR,
|
||||
readyToMerge,
|
||||
@@ -314,52 +334,56 @@ function PRTaskIconView({
|
||||
displayCount,
|
||||
} = getTaskPRIconPresentation(prs, prInfo);
|
||||
|
||||
const ariaLabel =
|
||||
const statusAriaLabel =
|
||||
prs.length > 1
|
||||
? t("github:pullRequestStatuses", { count: prs.length })
|
||||
: t("github:pullRequestStatus", { number: singlePR?.pr_number ?? prInfo?.number });
|
||||
const automationAria = [
|
||||
automation.autoFixEnabled ? t("github:autoFixEnabledAria") : null,
|
||||
automation.autoMergeEnabled ? t("github:autoMergeEnabledAria") : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const ariaLabel = automationAria ? `${statusAriaLabel}, ${automationAria}` : statusAriaLabel;
|
||||
|
||||
return (
|
||||
<Tooltip open={tooltip.open}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid={`pr-task-icon-${taskId}`}
|
||||
data-pr-state={displayState}
|
||||
data-pr-count={displayCount}
|
||||
data-pr-ready-to-merge={
|
||||
hasFullData ? String(prs.length === 1 ? readyToMerge : allReadyToMerge) : undefined
|
||||
}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={ariaLabel}
|
||||
onPointerEnter={tooltip.onPointerEnter}
|
||||
onPointerLeave={tooltip.onPointerLeave}
|
||||
onFocus={tooltip.onFocus}
|
||||
onBlur={tooltip.onBlur}
|
||||
className={cn(
|
||||
"inline-flex items-center shrink-0",
|
||||
prs.length > 1 && "gap-0.5",
|
||||
iconColor,
|
||||
)}
|
||||
>
|
||||
<IconGitPullRequest aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
{prs.length > 1 ? (
|
||||
<span className="text-[9px] font-semibold leading-none tabular-nums">{prs.length}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
sideOffset={6}
|
||||
onEscapeKeyDown={tooltip.onEscapeKeyDown}
|
||||
className="w-80 max-w-[calc(100vw-1rem)] p-3"
|
||||
>
|
||||
{hasFullData ? (
|
||||
<PRTaskStatusSummary summaries={summaries} />
|
||||
) : (
|
||||
<CompactPRTooltipContent status={hydration.status} />
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
const disclosureProps: PRTaskIconDisclosureProps = {
|
||||
taskId,
|
||||
prInfo,
|
||||
prs,
|
||||
hasFullData,
|
||||
singlePR,
|
||||
readyToMerge,
|
||||
allReadyToMerge,
|
||||
displayState,
|
||||
displayCount,
|
||||
iconColor,
|
||||
ariaLabel,
|
||||
icon: <PRTaskIconGlyph automation={automation} />,
|
||||
content: hasFullData ? (
|
||||
<>
|
||||
<PRTaskStatusSummary summaries={summaries} />
|
||||
<TaskPRAutomationDetails summary={automation} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CompactPRTooltipContent status={hydration.status} />
|
||||
<TaskPRAutomationDetails summary={automation} status={hydration.status} />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
return usesTouchDrawer ? (
|
||||
<PRTaskIconDrawer
|
||||
{...disclosureProps}
|
||||
open={drawerOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setDrawerOpen(nextOpen);
|
||||
if (nextOpen) hydrateOnDisclosure();
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<PRTaskIconTooltip {...disclosureProps} tooltip={tooltip} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,22 +392,3 @@ function getTaskPRIconColor(prs: TaskPR[], prInfo?: TaskPRInfo): string {
|
||||
if (prs.length > 1) return aggregatePRStatusColor(prs);
|
||||
return getPRAggregateStatusColor(prInfo?.aggregateState ?? prInfo?.state);
|
||||
}
|
||||
|
||||
function CompactPRTooltipContent({ status }: { status: TaskPRTooltipHydrationStatus }) {
|
||||
const { t } = useTranslation();
|
||||
if (status === "loading" || status === "idle") {
|
||||
return (
|
||||
<span data-testid="pr-task-tooltip-loading" className="text-sm text-muted-foreground">
|
||||
{t("github:taskPrDetailsLoading")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "unavailable") {
|
||||
return (
|
||||
<span data-testid="pr-task-tooltip-unavailable" className="text-sm text-muted-foreground">
|
||||
{t("github:taskPrDetailsUnavailable")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user