Files
Alassane ecda06748e fix(gitlab): scope MR automation switches per linked MR (#2676)
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-08-22 14:44:56 +00:00

281 lines
8.1 KiB
TypeScript

"use client";
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IconInfoCircle } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Label } from "@kandev/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Switch } from "@kandev/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { autoFixRoundForState } from "@/lib/gitlab/mr-automation";
import type {
TaskMRAutomationOptionsForMR,
TaskMRAutomationPatch,
TaskMRLifecycleState,
} from "@/lib/types/gitlab";
/** Shared compact-row height: taller touch target on mobile/coarse pointers. */
export function compactRowMinHeight(isMobile: boolean, isFinePointer: boolean): string {
return isMobile || !isFinePointer ? "min-h-11" : "min-h-7";
}
/**
* Dual-mode help affordance: a tap popover on coarse pointers, a hover
* tooltip on fine pointers. Mirrors CIAutomationHelpButton (GitHub).
*/
export function MRAutomationHelpButton({
ariaLabel,
testId,
children,
}: {
ariaLabel: string;
testId: string;
children: ReactNode;
}) {
const { isFinePointer } = useResponsiveBreakpoint();
const [open, setOpen] = useState(false);
const trigger = (
<Button
type="button"
variant="ghost"
size="icon"
data-testid={testId}
className="h-5 w-5 cursor-help text-muted-foreground hover:text-foreground"
aria-label={ariaLabel}
>
<IconInfoCircle className="h-3.5 w-3.5" />
</Button>
);
if (!isFinePointer) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
side="top"
align="start"
portal={false}
className="max-w-[280px] text-xs leading-relaxed"
>
{children}
</PopoverContent>
</Popover>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-[280px] text-xs leading-relaxed">
{children}
</TooltipContent>
</Tooltip>
);
}
function MRAutomationRow({
id,
label,
checked,
disabled,
onCheckedChange,
help,
describedBy,
}: {
id: string;
label: string;
checked: boolean;
disabled: boolean;
onCheckedChange: (checked: boolean) => void;
help?: ReactNode;
describedBy?: string;
}) {
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const minHeight = compactRowMinHeight(isMobile, isFinePointer);
return (
<div className={`flex items-center justify-between gap-3 px-1 ${minHeight}`}>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<Label htmlFor={id} className="min-w-0 cursor-pointer text-xs leading-5">
{label}
</Label>
{help}
</div>
<Switch
id={id}
aria-label={label}
aria-describedby={describedBy}
checked={checked}
disabled={disabled}
onCheckedChange={onCheckedChange}
/>
</div>
);
}
function MRAutoFixRoundHelpButton({
state,
maxRounds,
}: {
state: TaskMRLifecycleState | undefined;
maxRounds: number | null | undefined;
}) {
const { t } = useTranslation();
const round = autoFixRoundForState(state, maxRounds);
return (
<MRAutomationHelpButton
testId="mr-auto-fix-round-help"
ariaLabel={t("gitlab:mrAutomationExplainAutoFixRounds")}
>
<span data-testid="mr-auto-fix-round-explanation">
{t("gitlab:mrAutoFixRoundExplanation", { current: round.current, max: round.max })}
</span>
</MRAutomationHelpButton>
);
}
function MRAutoMergeHelpButton() {
const { t } = useTranslation();
return (
<MRAutomationHelpButton
testId="mr-auto-merge-help"
ariaLabel={t("gitlab:mrAutomationExplainAutoMerge")}
>
<span data-testid="mr-auto-merge-explanation">{t("gitlab:mrAutoMergeReadyExplanation")}</span>
</MRAutomationHelpButton>
);
}
/**
* Props shared by every switch-row group. `elementIDSuffix` scopes the DOM ids
* to one merge request: the multi-MR dropdown renders one group per linked MR,
* so a task-only suffix would emit duplicate ids and point every `<Label>` at
* the first MR's switch.
*/
type MRAutomationRowsProps = {
elementIDSuffix: string;
switches: TaskMRAutomationOptionsForMR;
disabled: boolean;
patchOption: (patch: TaskMRAutomationPatch) => void;
};
export function MRAutomationOptionRows({
elementIDSuffix,
switches,
disabled,
patchOption,
automationState,
maxRounds,
}: MRAutomationRowsProps & {
automationState: TaskMRLifecycleState | undefined;
maxRounds: number | null | undefined;
}) {
const { t } = useTranslation();
return (
<>
<MRAutomationRow
id={`task-mr-auto-fix-${elementIDSuffix}`}
label={t("gitlab:mrAutoFixCiAndAddressComments")}
checked={switches.auto_fix_enabled}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ auto_fix_enabled: checked })}
help={
switches.auto_fix_enabled ? (
<MRAutoFixRoundHelpButton state={automationState} maxRounds={maxRounds} />
) : null
}
/>
<MRAutomationRow
id={`task-mr-auto-merge-${elementIDSuffix}`}
label={t("gitlab:mrAutoMergeWhenReady")}
checked={switches.auto_merge_enabled}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ auto_merge_enabled: checked })}
help={switches.auto_merge_enabled ? <MRAutoMergeHelpButton /> : null}
/>
</>
);
}
function ReviewRequestedPromptRow({
elementIDSuffix,
switches,
disabled,
patchOption,
}: MRAutomationRowsProps) {
const { t } = useTranslation();
const helpID = `task-mr-review-requested-prompt-${elementIDSuffix}-description`;
const help = t("gitlab:mrAutomationReviewRequestedHelp");
return (
<>
<span id={helpID} className="sr-only">
{help}
</span>
<MRAutomationRow
id={`task-mr-review-requested-prompt-${elementIDSuffix}`}
label={t("gitlab:mrAutomationReviewRequestedLabel")}
describedBy={helpID}
checked={switches.prompt_on_review_requested}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ prompt_on_review_requested: checked })}
help={
<MRAutomationHelpButton
testId="mr-review-requested-help"
ariaLabel={t("gitlab:mrAutomationReviewRequestedHelpAriaLabel")}
>
{help}
</MRAutomationHelpButton>
}
/>
</>
);
}
export function MRAgentPromptRows({
elementIDSuffix,
switches,
disabled,
patchOption,
}: MRAutomationRowsProps) {
const { t } = useTranslation();
const terminalHelpID = `task-mr-terminal-help-${elementIDSuffix}`;
const terminalHelp = t("gitlab:mrAutomationTerminalHelp");
return (
<>
<ReviewRequestedPromptRow
elementIDSuffix={elementIDSuffix}
switches={switches}
disabled={disabled}
patchOption={patchOption}
/>
<span id={terminalHelpID} className="sr-only">
{terminalHelp}
</span>
<MRAutomationRow
id={`task-mr-merged-prompt-${elementIDSuffix}`}
label={t("gitlab:mrAutomationMergedLabel")}
describedBy={terminalHelpID}
checked={switches.prompt_on_merged}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ prompt_on_merged: checked })}
help={
<MRAutomationHelpButton
testId="mr-terminal-help"
ariaLabel={t("gitlab:mrAutomationTerminalHelpAriaLabel")}
>
{terminalHelp}
</MRAutomationHelpButton>
}
/>
<MRAutomationRow
id={`task-mr-closed-prompt-${elementIDSuffix}`}
label={t("gitlab:mrAutomationClosedLabel")}
describedBy={terminalHelpID}
checked={switches.prompt_on_closed}
disabled={disabled}
onCheckedChange={(checked) => patchOption({ prompt_on_closed: checked })}
/>
</>
);
}