feat(gitlab): add MR status chip to chat and passthrough toolbars (#2537)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@kandev/ui/drawer";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { MRCIPopover } from "./mr-ci-popover";
|
||||
import { MRStatusChipTriggerContent, useMRChipTriggerAttrs } from "./mr-status-chip-trigger";
|
||||
import { useFrozenChipSelection, type ChipAutomation } from "./mr-status-chip-selection";
|
||||
import type { MRChipStatus } from "./mr-task-icon";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
/** Coarse-pointer disclosure variant: the same MRCIPopover body inside a bottom-sheet drawer. */
|
||||
export function MRStatusChipDrawer({
|
||||
mrs,
|
||||
openCount,
|
||||
liveSelected,
|
||||
liveStatus,
|
||||
automation,
|
||||
taskId,
|
||||
canLink,
|
||||
onUnlink,
|
||||
onLink,
|
||||
}: {
|
||||
mrs: TaskMR[];
|
||||
openCount: number;
|
||||
liveSelected: TaskMR;
|
||||
liveStatus: MRChipStatus;
|
||||
automation: ChipAutomation;
|
||||
taskId: string;
|
||||
canLink: boolean;
|
||||
onUnlink: (associationId: string) => void;
|
||||
onLink: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { actedOnMR, frozen, shouldForceClose } = useFrozenChipSelection(mrs, liveSelected, open);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldForceClose) setOpen(false);
|
||||
}, [shouldForceClose]);
|
||||
|
||||
const attrs = useMRChipTriggerAttrs({ openCount, liveStatus, actedOnMR, frozen });
|
||||
// The chip closes its own disclosure before the link dialog opens: the
|
||||
// drawer is itself a focus-trapping dialog, so opening a second one
|
||||
// inside it would strand focus (spec: Link and unlink from the chip).
|
||||
const handleLink = () => {
|
||||
setOpen(false);
|
||||
onLink();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
{/* asChild registers this button as Radix's Dialog trigger ref, which its
|
||||
default onCloseAutoFocus reads to restore focus on close — an
|
||||
untracked plain button leaves nothing to focus and it falls back to
|
||||
document.body (spec: Accessibility, "closing the drawer returns
|
||||
focus to the chip trigger"). */}
|
||||
<DrawerTrigger asChild>
|
||||
<button type="button" aria-haspopup="dialog" aria-expanded={open} {...attrs}>
|
||||
<MRStatusChipTriggerContent
|
||||
openCount={openCount}
|
||||
liveStatus={liveStatus}
|
||||
automation={automation}
|
||||
/>
|
||||
</button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent data-testid="mr-status-chip-drawer" className="max-h-[80vh] flex flex-col">
|
||||
<DrawerHeader className="flex flex-row items-center justify-between border-b py-2">
|
||||
<DrawerTitle className="text-sm">
|
||||
{t("gitlab:mrChipDrawerTitle", { mriid: actedOnMR.mr_iid })}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
{t("gitlab:mrChipDrawerDescription")}
|
||||
</DrawerDescription>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
data-testid="mr-status-chip-drawer-close"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("gitlab:mrChipDrawerClose")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3" data-vaul-no-drag>
|
||||
{/* See mr-status-chip-popover.tsx for why shouldForceClose gates
|
||||
this render: actedOnMR falls back to the new live selection in
|
||||
the same commit the frozen MR vanishes, before the closing
|
||||
effect below fires. */}
|
||||
{!shouldForceClose && (
|
||||
<MRCIPopover
|
||||
mr={actedOnMR}
|
||||
taskId={taskId}
|
||||
enabled={open}
|
||||
canLink={canLink}
|
||||
onLink={handleLink}
|
||||
onUnlink={() => onUnlink(actedOnMR.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@kandev/ui/popover";
|
||||
import { useMRPopoverInteractions } from "./mr-topbar-button";
|
||||
import { MRCIPopover } from "./mr-ci-popover";
|
||||
import { MRStatusChipTriggerContent, useMRChipTriggerAttrs } from "./mr-status-chip-trigger";
|
||||
import {
|
||||
useFrozenChipSelection,
|
||||
useTriggerOutsideGuard,
|
||||
type ChipAutomation,
|
||||
} from "./mr-status-chip-selection";
|
||||
import type { MRChipStatus } from "./mr-task-icon";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
/**
|
||||
* Fine-pointer disclosure variant: reuses the topbar button's own hover
|
||||
* lifecycle (useMRPopoverInteractions) so the 13-handler wiring can't drift
|
||||
* from `MRTopbarButton`'s (spec: Accessibility, "prefer exporting and
|
||||
* reusing" over hand-copying).
|
||||
*/
|
||||
export function MRStatusChipPopover({
|
||||
mrs,
|
||||
openCount,
|
||||
liveSelected,
|
||||
liveStatus,
|
||||
automation,
|
||||
taskId,
|
||||
canLink,
|
||||
onUnlink,
|
||||
onLink,
|
||||
}: {
|
||||
mrs: TaskMR[];
|
||||
openCount: number;
|
||||
liveSelected: TaskMR;
|
||||
liveStatus: MRChipStatus;
|
||||
automation: ChipAutomation;
|
||||
taskId: string;
|
||||
canLink: boolean;
|
||||
onUnlink: (associationId: string) => void;
|
||||
onLink: () => void;
|
||||
}) {
|
||||
const { open, onOpenChange, onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave } =
|
||||
useMRPopoverInteractions();
|
||||
const { actedOnMR, frozen, shouldForceClose } = useFrozenChipSelection(mrs, liveSelected, open);
|
||||
const { ref, onPointerDownOutside } = useTriggerOutsideGuard();
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldForceClose) onOpenChange(false);
|
||||
}, [shouldForceClose, onOpenChange]);
|
||||
|
||||
const attrs = useMRChipTriggerAttrs({ openCount, liveStatus, actedOnMR, frozen });
|
||||
// The chip closes its own disclosure before the link dialog opens
|
||||
// (spec: Link and unlink from the chip, "closing first is required").
|
||||
const handleLink = () => {
|
||||
onOpenChange(false);
|
||||
onLink();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverAnchor asChild>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...attrs}
|
||||
onMouseOver={onTriggerEnter}
|
||||
onMouseEnter={onTriggerEnter}
|
||||
onMouseMove={onTriggerEnter}
|
||||
onPointerOver={onTriggerEnter}
|
||||
onPointerEnter={onTriggerEnter}
|
||||
onPointerMove={onTriggerEnter}
|
||||
onFocus={onTriggerEnter}
|
||||
onMouseLeave={onTriggerLeave}
|
||||
onPointerLeave={onTriggerLeave}
|
||||
onBlur={onTriggerLeave}
|
||||
>
|
||||
<MRStatusChipTriggerContent
|
||||
openCount={openCount}
|
||||
liveStatus={liveStatus}
|
||||
automation={automation}
|
||||
/>
|
||||
</button>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
data-testid="mr-status-chip-popover"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
className="w-80"
|
||||
onMouseEnter={onContentEnter}
|
||||
onMouseMove={onContentEnter}
|
||||
onMouseLeave={onContentLeave}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* shouldForceClose fires the same render `actedOnMR` falls back
|
||||
from the vanished frozen MR to the new live selection — before
|
||||
the effect below has a chance to close the disclosure. Skipping
|
||||
the render here for that one commit means the popover is never
|
||||
shown with merge/unlink controls wired to a swapped-in MR the
|
||||
user did not open it on (spec: "an action target that swaps
|
||||
under the pointer mid-interaction is a hazard"). */}
|
||||
{!shouldForceClose && (
|
||||
<MRCIPopover
|
||||
mr={actedOnMR}
|
||||
taskId={taskId}
|
||||
enabled={open}
|
||||
canLink={canLink}
|
||||
onLink={handleLink}
|
||||
onUnlink={() => onUnlink(actedOnMR.id)}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chipAutomation, selectBadgeMR } from "./mr-status-chip-selection";
|
||||
import type { TaskMR, TaskMRAutomationOptions, TaskMRLifecycleState } from "@/lib/types/gitlab";
|
||||
|
||||
function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
|
||||
return {
|
||||
id: "association-1",
|
||||
task_id: "task-1",
|
||||
host: "https://gitlab.example",
|
||||
project_path: "group/project",
|
||||
mr_iid: 81,
|
||||
mr_url: "https://gitlab.example/group/project/-/merge_requests/81",
|
||||
mr_title: "Test MR",
|
||||
head_branch: "feature",
|
||||
base_branch: "main",
|
||||
author_username: "alice",
|
||||
state: "open",
|
||||
approval_state: "",
|
||||
pipeline_state: "",
|
||||
merge_status: "",
|
||||
draft: false,
|
||||
approval_count: 0,
|
||||
required_approvals: 0,
|
||||
pipeline_jobs_total: 0,
|
||||
pipeline_jobs_pass: 0,
|
||||
reviewer_count: 0,
|
||||
unapproved_reviewers: 0,
|
||||
unresolved_discussions: 0,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(overrides: Partial<TaskMRLifecycleState> = {}): TaskMRLifecycleState {
|
||||
return {
|
||||
task_id: "task-1",
|
||||
repository_id: "",
|
||||
project_path: "group/project",
|
||||
mr_iid: 81,
|
||||
review_request_initialized: false,
|
||||
last_review_requested: false,
|
||||
last_observed_state: "",
|
||||
last_lifecycle_event: "",
|
||||
last_fix_signature: "",
|
||||
last_fix_checkpoint_json: "",
|
||||
last_merge_signature: "",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
auto_fix_round_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAutomation(overrides: Partial<TaskMRAutomationOptions> = {}): TaskMRAutomationOptions {
|
||||
return {
|
||||
task_id: "task-1",
|
||||
auto_fix_enabled: true,
|
||||
auto_merge_enabled: false,
|
||||
auto_fix_max_rounds: 5,
|
||||
effective_auto_fix_prompt: "",
|
||||
using_default_prompt: true,
|
||||
prompt_on_review_requested: false,
|
||||
prompt_on_merged: false,
|
||||
prompt_on_closed: false,
|
||||
review_reviewer_username: "",
|
||||
updated_at: "",
|
||||
mr_states: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// spec: Selection and ordering, "The auto-fix round shown on a multi-MR chip
|
||||
// is the most attention-worthy round across the open MRs" — an exhausted
|
||||
// round beats a non-exhausted one, then a higher `current` wins, then the
|
||||
// same mr_iid/project_path/id order selectChipMR uses. Previously had zero
|
||||
// coverage of any kind (Review round 1 finding #1, mutation-proven: with
|
||||
// isBadgeMRBetter neutered to `return false`, the full suite stayed green).
|
||||
describe("selectBadgeMR tiebreak", () => {
|
||||
it("prefers an exhausted round over a non-exhausted higher round (spec.md:1063-1065)", () => {
|
||||
const exhausted = makeMR({ id: "a", mr_iid: 3 });
|
||||
const notExhausted = makeMR({ id: "b", mr_iid: 9 });
|
||||
const options = makeAutomation({
|
||||
mr_states: [
|
||||
makeState({
|
||||
mr_iid: 3,
|
||||
auto_fix_round_count: 3,
|
||||
auto_fix_exhausted_at: "2026-08-11T00:00:00Z",
|
||||
}),
|
||||
makeState({ mr_iid: 9, auto_fix_round_count: 4 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Both input orders, so a first-in-array bug can't hide behind a
|
||||
// coincidentally-correct fixture order.
|
||||
expect(selectBadgeMR([exhausted, notExhausted], options)?.id).toBe("a");
|
||||
expect(selectBadgeMR([notExhausted, exhausted], options)?.id).toBe("a");
|
||||
});
|
||||
|
||||
it("prefers a higher current round when neither is exhausted", () => {
|
||||
const lowerRound = makeMR({ id: "a", mr_iid: 1 });
|
||||
const higherRound = makeMR({ id: "b", mr_iid: 2 });
|
||||
const options = makeAutomation({
|
||||
mr_states: [
|
||||
makeState({ mr_iid: 1, auto_fix_round_count: 1 }),
|
||||
makeState({ mr_iid: 2, auto_fix_round_count: 4 }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(selectBadgeMR([lowerRound, higherRound], options)?.id).toBe("b");
|
||||
expect(selectBadgeMR([higherRound, lowerRound], options)?.id).toBe("b");
|
||||
});
|
||||
|
||||
it("resolves a fully-tied round by mr_iid ascending, iid 7 beating iid 12 (spec.md:1066-1071)", () => {
|
||||
const higherIID = makeMR({ id: "a", mr_iid: 12 });
|
||||
const lowerIID = makeMR({ id: "b", mr_iid: 7 });
|
||||
const options = makeAutomation({
|
||||
mr_states: [
|
||||
makeState({ mr_iid: 12, auto_fix_round_count: 2 }),
|
||||
makeState({ mr_iid: 7, auto_fix_round_count: 2 }),
|
||||
],
|
||||
});
|
||||
|
||||
// For either input order, the winner is the lower mr_iid (7), not
|
||||
// whichever MR happens to come first in the array.
|
||||
expect(selectBadgeMR([higherIID, lowerIID], options)?.mr_iid).toBe(7);
|
||||
expect(selectBadgeMR([lowerIID, higherIID], options)?.mr_iid).toBe(7);
|
||||
});
|
||||
|
||||
it("returns null when auto-fix is disabled", () => {
|
||||
const mr = makeMR();
|
||||
expect(selectBadgeMR([mr], makeAutomation({ auto_fix_enabled: false }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Integration-level confirmation that chipAutomation's returned round info
|
||||
// actually comes from the badge-selected MR identified above.
|
||||
describe("chipAutomation", () => {
|
||||
it("returns the badge-selected MR's own round info", () => {
|
||||
const exhausted = makeMR({ id: "a", mr_iid: 3 });
|
||||
const notExhausted = makeMR({ id: "b", mr_iid: 9 });
|
||||
const options = makeAutomation({
|
||||
mr_states: [
|
||||
makeState({
|
||||
mr_iid: 3,
|
||||
auto_fix_round_count: 3,
|
||||
auto_fix_exhausted_at: "2026-08-11T00:00:00Z",
|
||||
}),
|
||||
makeState({ mr_iid: 9, auto_fix_round_count: 4 }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = chipAutomation(options, [exhausted, notExhausted]);
|
||||
|
||||
expect(result.autoFixRound).toEqual({ current: 3, max: 5, exhausted: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { compareChipMR } from "./mr-task-icon";
|
||||
import {
|
||||
autoFixRoundForState,
|
||||
findMRAutomationStateForMR,
|
||||
type AutoFixRoundInfo,
|
||||
} from "@/lib/gitlab/mr-automation";
|
||||
import type { TaskMR, TaskMRAutomationOptions } from "@/lib/types/gitlab";
|
||||
|
||||
export type ChipAutomation = {
|
||||
autoFixEnabled: boolean;
|
||||
autoMergeEnabled: boolean;
|
||||
autoFixRound: AutoFixRoundInfo | null;
|
||||
};
|
||||
|
||||
function autoFixRoundFor(options: TaskMRAutomationOptions | null, mr: TaskMR): AutoFixRoundInfo {
|
||||
return autoFixRoundForState(
|
||||
findMRAutomationStateForMR(options?.mr_states, mr),
|
||||
options?.auto_fix_max_rounds,
|
||||
);
|
||||
}
|
||||
|
||||
function isBadgeMRBetter(
|
||||
candidateRound: AutoFixRoundInfo,
|
||||
candidate: TaskMR,
|
||||
bestRound: AutoFixRoundInfo,
|
||||
best: TaskMR,
|
||||
): boolean {
|
||||
if (candidateRound.exhausted !== bestRound.exhausted) return candidateRound.exhausted;
|
||||
if (candidateRound.current !== bestRound.current)
|
||||
return candidateRound.current > bestRound.current;
|
||||
return compareChipMR(candidate, best) < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The badge-selected MR: the most attention-worthy auto-fix round across the
|
||||
* open MRs — an exhausted round beats a non-exhausted one, then a higher
|
||||
* `current` wins, then the same `mr_iid` / `project_path` / `id` order
|
||||
* `selectChipMR` uses (spec: Selection and ordering). Distinct from the live
|
||||
* and frozen selections, and deliberately never frozen (spec: "The
|
||||
* automation badges do NOT freeze").
|
||||
*/
|
||||
export function selectBadgeMR(
|
||||
openMRs: TaskMR[],
|
||||
options: TaskMRAutomationOptions | null,
|
||||
): TaskMR | null {
|
||||
if (!options?.auto_fix_enabled) return null;
|
||||
let best: TaskMR | null = null;
|
||||
let bestRound: AutoFixRoundInfo | null = null;
|
||||
for (const mr of openMRs) {
|
||||
const round = autoFixRoundFor(options, mr);
|
||||
if (!best || !bestRound || isBadgeMRBetter(round, mr, bestRound, best)) {
|
||||
best = mr;
|
||||
bestRound = round;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Automation flags + round info for the chip's badges, live (never frozen). */
|
||||
export function chipAutomation(
|
||||
options: TaskMRAutomationOptions | null,
|
||||
openMRs: TaskMR[],
|
||||
): ChipAutomation {
|
||||
const badgeMR = selectBadgeMR(openMRs, options);
|
||||
return {
|
||||
autoFixEnabled: Boolean(options?.auto_fix_enabled),
|
||||
autoMergeEnabled: Boolean(options?.auto_merge_enabled),
|
||||
autoFixRound: badgeMR ? autoFixRoundFor(options, badgeMR) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Freezes which MR the disclosure acts on by association `id` while it is
|
||||
* open (spec: "Freezing while the disclosure is open"). Captures `id` only,
|
||||
* never a copy of the TaskMR object, so the returned `actedOnMR` always
|
||||
* re-reads the live store row for that id. Deliberately local to whichever
|
||||
* disclosure variant (popover vs drawer) calls it: a pointer-precision
|
||||
* change unmounts one variant and mounts the other, so this state resets to
|
||||
* unfrozen on that transition with no extra code (spec round-4 finding
|
||||
* F31) — the freeze can never survive a variant swap because there is
|
||||
* nothing above the swap point holding it.
|
||||
*/
|
||||
export function useFrozenChipSelection(mrs: TaskMR[], liveSelected: TaskMR, open: boolean) {
|
||||
const [frozenId, setFrozenId] = useState<string | null>(null);
|
||||
const wasOpen = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !wasOpen.current) setFrozenId(liveSelected.id);
|
||||
if (!open) setFrozenId(null);
|
||||
wasOpen.current = open;
|
||||
}, [open, liveSelected.id]);
|
||||
|
||||
const frozenMR = frozenId
|
||||
? (mrs.find((mr) => mr.id === frozenId && mr.state === "open") ?? null)
|
||||
: null;
|
||||
// If the held id leaves the store, or its row stops being open, the
|
||||
// disclosure closes (spec). The caller effects on `shouldForceClose`.
|
||||
const shouldForceClose = frozenId !== null && frozenMR === null;
|
||||
const actedOnMR = frozenMR ?? liveSelected;
|
||||
return { actedOnMR, frozen: frozenId !== null && !shouldForceClose, shouldForceClose };
|
||||
}
|
||||
|
||||
/**
|
||||
* Radix treats the trigger as outside the popover content, so a click on
|
||||
* the chip itself would otherwise close it via onPointerDownOutside. This
|
||||
* guard filters out trigger clicks so clicking the chip is a no-op while
|
||||
* the popover stays open (spec: "Clicking the fine-pointer trigger is a
|
||||
* no-op in both states").
|
||||
*/
|
||||
export function useTriggerOutsideGuard() {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const onPointerDownOutside = useCallback(
|
||||
(e: { target: EventTarget | null; preventDefault: () => void }) => {
|
||||
if (ref.current && ref.current.contains(e.target as Node)) e.preventDefault();
|
||||
},
|
||||
[],
|
||||
);
|
||||
return { ref, onPointerDownOutside };
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconCircleCheckFilled,
|
||||
IconCircleXFilled,
|
||||
IconClock,
|
||||
IconGitMerge,
|
||||
IconLoader2,
|
||||
IconPointFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMRReadyToMerge, mrChipStatus } from "./mr-task-icon";
|
||||
import type { MRChipStatus } from "./mr-task-icon";
|
||||
import type { ChipAutomation } from "./mr-status-chip-selection";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
export const MR_CHIP_TRIGGER_CLASS =
|
||||
"cursor-pointer inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-xs";
|
||||
|
||||
const STATUS_LABEL_KEY: Record<MRChipStatus, string> = {
|
||||
merged: "gitlab:mrChipStatusMerged",
|
||||
closed: "gitlab:mrChipStatusClosed",
|
||||
failed: "gitlab:mrChipStatusFailed",
|
||||
draft: "gitlab:mrChipStatusDraft",
|
||||
ready: "gitlab:mrChipStatusReady",
|
||||
awaiting_approval: "gitlab:mrChipStatusAwaitingApproval",
|
||||
running: "gitlab:mrChipStatusRunning",
|
||||
neutral: "gitlab:mrChipStatusNeutral",
|
||||
};
|
||||
|
||||
export function MRStatusChipGlyph({ status }: { status: MRChipStatus }) {
|
||||
const cls = "h-3.5 w-3.5";
|
||||
const testProps = { "data-testid": "mr-status-glyph", "data-status": status } as const;
|
||||
switch (status) {
|
||||
case "merged":
|
||||
return (
|
||||
<IconGitMerge {...testProps} className={cn(cls, "text-purple-500")} aria-hidden="true" />
|
||||
);
|
||||
case "failed":
|
||||
return (
|
||||
<IconCircleXFilled {...testProps} className={cn(cls, "text-red-500")} aria-hidden="true" />
|
||||
);
|
||||
case "ready":
|
||||
return (
|
||||
<IconCircleCheckFilled
|
||||
{...testProps}
|
||||
className={cn(cls, "text-emerald-400")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
case "awaiting_approval":
|
||||
return <IconClock {...testProps} className={cn(cls, "text-sky-400")} aria-hidden="true" />;
|
||||
case "running":
|
||||
return (
|
||||
<IconLoader2
|
||||
{...testProps}
|
||||
className={cn(cls, "text-yellow-500 animate-spin [animation-duration:3s]")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
// closed, draft, neutral all render the same muted dot.
|
||||
return (
|
||||
<IconPointFilled
|
||||
{...testProps}
|
||||
className={cn(cls, "text-muted-foreground")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The aria-label describes the acted-on MR, so its status word is the
|
||||
// acted-on MR's OWN status (mrChipStatus(actedOnMR)) — it deliberately does
|
||||
// NOT read the live-tracking `liveStatus` that drives `data-status` and the
|
||||
// glyph. Diverging on that point would rename the label mid-interaction to a
|
||||
// status that belongs to a different MR than the one the disclosure is
|
||||
// actually showing/acting on (spec: Accessibility, "The aria-label describes
|
||||
// the acted-on MR").
|
||||
function useMRChipAriaLabel(openCount: number, actedOnMR: TaskMR) {
|
||||
const { t } = useTranslation();
|
||||
const status = t(STATUS_LABEL_KEY[mrChipStatus(actedOnMR)]);
|
||||
if (openCount > 1) return t("gitlab:mrChipAriaLabelMulti", { count: openCount, status });
|
||||
return t("gitlab:mrChipAriaLabelSingle", { mriid: actedOnMR.mr_iid, status });
|
||||
}
|
||||
|
||||
export type MRChipTriggerAttrs = {
|
||||
"data-testid": "mr-status-chip";
|
||||
"data-status": MRChipStatus;
|
||||
"data-mr-count": number;
|
||||
"data-mr-iid": number;
|
||||
"data-mr-state": string;
|
||||
"data-mr-ready-to-merge": "true" | "false";
|
||||
"data-selection-frozen": "true" | "false";
|
||||
"aria-label": string;
|
||||
className: string;
|
||||
};
|
||||
|
||||
export function useMRChipTriggerAttrs({
|
||||
openCount,
|
||||
liveStatus,
|
||||
actedOnMR,
|
||||
frozen,
|
||||
}: {
|
||||
openCount: number;
|
||||
liveStatus: MRChipStatus;
|
||||
actedOnMR: TaskMR;
|
||||
frozen: boolean;
|
||||
}): MRChipTriggerAttrs {
|
||||
return {
|
||||
"data-testid": "mr-status-chip",
|
||||
"data-status": liveStatus,
|
||||
"data-mr-count": openCount,
|
||||
"data-mr-iid": actedOnMR.mr_iid,
|
||||
"data-mr-state": actedOnMR.state,
|
||||
"data-mr-ready-to-merge": isMRReadyToMerge(actedOnMR) ? "true" : "false",
|
||||
"data-selection-frozen": frozen ? "true" : "false",
|
||||
"aria-label": useMRChipAriaLabel(openCount, actedOnMR),
|
||||
className: MR_CHIP_TRIGGER_CLASS,
|
||||
};
|
||||
}
|
||||
|
||||
function AutomationFlagBadges({ automation }: { automation: ChipAutomation }) {
|
||||
const { t } = useTranslation();
|
||||
if (!automation.autoFixEnabled && !automation.autoMergeEnabled) return null;
|
||||
const round = automation.autoFixRound;
|
||||
return (
|
||||
<>
|
||||
{automation.autoFixEnabled && round && (
|
||||
<span
|
||||
data-testid="mr-status-auto-fix-chip"
|
||||
data-auto-fix-exhausted={round.exhausted ? "true" : "false"}
|
||||
className={cn(
|
||||
"rounded-sm px-1 py-0.5 text-[9px] font-medium leading-none",
|
||||
round.exhausted
|
||||
? "bg-yellow-500/15 text-yellow-500"
|
||||
: "bg-emerald-500/15 text-emerald-500",
|
||||
)}
|
||||
>
|
||||
{t("gitlab:mrChipAutoFix")} {round.current}/{round.max}
|
||||
</span>
|
||||
)}
|
||||
{automation.autoMergeEnabled && (
|
||||
<span
|
||||
data-testid="mr-status-auto-merge-chip"
|
||||
className="rounded-sm bg-sky-500/15 px-1 py-0.5 text-[9px] font-medium leading-none text-sky-500"
|
||||
>
|
||||
{t("gitlab:mrChipAutoMerge")}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MRStatusChipTriggerContent({
|
||||
openCount,
|
||||
liveStatus,
|
||||
automation,
|
||||
}: {
|
||||
openCount: number;
|
||||
liveStatus: MRChipStatus;
|
||||
automation: ChipAutomation;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<IconGitMerge className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
<MRStatusChipGlyph status={liveStatus} />
|
||||
{openCount > 1 && (
|
||||
<span className="text-[9px] font-semibold leading-none tabular-nums">{openCount}</span>
|
||||
)}
|
||||
<AutomationFlagBadges automation={automation} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { createElement } from "react";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useHoverPopover } from "@/hooks/domains/github/use-hover-popover";
|
||||
import { MRStatusChip } from "./mr-status-chip";
|
||||
import type { TaskMR, TaskMRAutomationOptions } from "@/lib/types/gitlab";
|
||||
|
||||
const OPEN_DELAY_MS = 150;
|
||||
const CHIP_TESTID = "mr-status-chip";
|
||||
const DATA_MR_IID = "data-mr-iid";
|
||||
const DATA_SELECTION_FROZEN = "data-selection-frozen";
|
||||
const DATA_MR_COUNT = "data-mr-count";
|
||||
const DATA_STATUS = "data-status";
|
||||
const POPOVER_STUB_TESTID = "mr-ci-popover-stub";
|
||||
|
||||
const gitlabMocks = vi.hoisted(() => ({ mrs: [] as TaskMR[] }));
|
||||
const touchMocks = vi.hoisted(() => ({ usesTouchDrawer: false }));
|
||||
const automationMocks = vi.hoisted(() => ({
|
||||
options: null as TaskMRAutomationOptions | null,
|
||||
}));
|
||||
const dialogMocks = vi.hoisted(() => ({ lastProps: null as { open: boolean } | null }));
|
||||
const unlinkMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const popoverRenderMRIIDs = vi.hoisted(() => [] as number[]);
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
workspaces: { activeId: "workspace-1" },
|
||||
repositories: { itemsByWorkspaceId: { "workspace-1": [] } },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
|
||||
useGitLabAvailable: () => true,
|
||||
useTaskMRs: () => gitlabMocks.mrs,
|
||||
useUnlinkTaskMR: () => unlinkMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/gitlab/use-task-mr-automation", () => ({
|
||||
useTaskMRAutomationOptions: () => ({ options: automationMocks.options }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/kanban/use-task-by-id", () => ({
|
||||
useTaskById: () => ({ id: "task-1", repositories: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-compact-task-chrome", () => ({
|
||||
useTouchDrawer: () => touchMocks.usesTouchDrawer,
|
||||
}));
|
||||
|
||||
vi.mock("./task-mr-link-dialog", () => ({
|
||||
TaskMRLinkDialog: (props: { open: boolean }) => {
|
||||
dialogMocks.lastProps = props;
|
||||
return <div data-testid="mr-link-dialog-stub" data-open={props.open ? "true" : "false"} />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./mr-ci-popover", () => ({
|
||||
MRCIPopover: ({
|
||||
mr,
|
||||
onLink,
|
||||
onUnlink,
|
||||
}: {
|
||||
mr: TaskMR;
|
||||
onLink: () => void;
|
||||
onUnlink: () => void;
|
||||
}) => {
|
||||
popoverRenderMRIIDs.push(mr.mr_iid);
|
||||
return (
|
||||
<div data-testid={POPOVER_STUB_TESTID}>
|
||||
popover for !{mr.mr_iid}
|
||||
<button type="button" onClick={onLink}>
|
||||
link another
|
||||
</button>
|
||||
<button type="button" onClick={onUnlink}>
|
||||
unlink
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./mr-topbar-button", () => ({
|
||||
useMRPopoverInteractions: () => {
|
||||
const hover = useHoverPopover({ openDelayMs: OPEN_DELAY_MS, closeDelayMs: OPEN_DELAY_MS });
|
||||
return { usesTouchDrawer: false, ...hover };
|
||||
},
|
||||
}));
|
||||
|
||||
function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
|
||||
return {
|
||||
id: "association-1",
|
||||
task_id: "task-1",
|
||||
host: "https://gitlab.example",
|
||||
project_path: "group/project",
|
||||
mr_iid: 81,
|
||||
mr_url: "https://gitlab.example/group/project/-/merge_requests/81",
|
||||
mr_title: "Test MR",
|
||||
head_branch: "feature",
|
||||
base_branch: "main",
|
||||
author_username: "alice",
|
||||
state: "open",
|
||||
approval_state: "",
|
||||
pipeline_state: "",
|
||||
merge_status: "",
|
||||
draft: false,
|
||||
approval_count: 0,
|
||||
required_approvals: 0,
|
||||
pipeline_jobs_total: 0,
|
||||
pipeline_jobs_pass: 0,
|
||||
reviewer_count: 0,
|
||||
unapproved_reviewers: 0,
|
||||
unresolved_discussions: 0,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAutomation(overrides: Partial<TaskMRAutomationOptions> = {}): TaskMRAutomationOptions {
|
||||
return {
|
||||
task_id: "task-1",
|
||||
auto_fix_enabled: false,
|
||||
auto_merge_enabled: false,
|
||||
auto_fix_max_rounds: 10,
|
||||
effective_auto_fix_prompt: "",
|
||||
using_default_prompt: true,
|
||||
prompt_on_review_requested: false,
|
||||
prompt_on_merged: false,
|
||||
prompt_on_closed: false,
|
||||
review_reviewer_username: "",
|
||||
updated_at: "",
|
||||
mr_states: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function resetChipMocks() {
|
||||
vi.useFakeTimers();
|
||||
touchMocks.usesTouchDrawer = false;
|
||||
automationMocks.options = null;
|
||||
dialogMocks.lastProps = null;
|
||||
unlinkMock.mockClear();
|
||||
popoverRenderMRIIDs.length = 0;
|
||||
}
|
||||
|
||||
function teardownChipMocks() {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
describe("MRStatusChip rendering and selection", () => {
|
||||
beforeEach(resetChipMocks);
|
||||
afterEach(teardownChipMocks);
|
||||
|
||||
it("renders nothing when the task has no linked MRs", () => {
|
||||
gitlabMocks.mrs = [];
|
||||
render(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
expect(screen.queryByTestId(CHIP_TESTID)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when the only linked MR is terminal", () => {
|
||||
gitlabMocks.mrs = [makeMR({ state: "merged" })];
|
||||
render(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
expect(screen.queryByTestId(CHIP_TESTID)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the trigger for a single open MR with the expected attributes", () => {
|
||||
gitlabMocks.mrs = [makeMR({ pipeline_state: "failure" })];
|
||||
render(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
const trigger = screen.getByTestId(CHIP_TESTID);
|
||||
expect(trigger.getAttribute(DATA_STATUS)).toBe("failed");
|
||||
expect(trigger.getAttribute(DATA_MR_COUNT)).toBe("1");
|
||||
expect(trigger.getAttribute(DATA_MR_IID)).toBe("81");
|
||||
expect(trigger.getAttribute("data-mr-state")).toBe("open");
|
||||
expect(trigger.getAttribute("data-mr-ready-to-merge")).toBe("false");
|
||||
expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("false");
|
||||
});
|
||||
|
||||
it("selects the highest-ranked open MR and reports the total open count", () => {
|
||||
gitlabMocks.mrs = [
|
||||
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
|
||||
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }),
|
||||
makeMR({ id: "c", mr_iid: 3, state: "merged" }),
|
||||
];
|
||||
render(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
const trigger = screen.getByTestId(CHIP_TESTID);
|
||||
expect(trigger.getAttribute(DATA_STATUS)).toBe("failed");
|
||||
expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
|
||||
// Terminal MR is not counted toward data-mr-count.
|
||||
expect(trigger.getAttribute(DATA_MR_COUNT)).toBe("2");
|
||||
});
|
||||
|
||||
it("renders the auto-fix badge at round 0 with no lifecycle state, and the auto-merge badge", () => {
|
||||
gitlabMocks.mrs = [makeMR()];
|
||||
automationMocks.options = makeAutomation({
|
||||
auto_fix_enabled: true,
|
||||
auto_fix_max_rounds: 5,
|
||||
auto_merge_enabled: true,
|
||||
});
|
||||
render(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
const badge = screen.getByTestId("mr-status-auto-fix-chip");
|
||||
expect(badge.textContent).toContain("0/5");
|
||||
expect(badge.getAttribute("data-auto-fix-exhausted")).toBe("false");
|
||||
expect(screen.getByTestId("mr-status-auto-merge-chip")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders neither badge when automation is disabled", () => {
|
||||
gitlabMocks.mrs = [makeMR()];
|
||||
automationMocks.options = makeAutomation();
|
||||
render(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
expect(screen.queryByTestId("mr-status-auto-fix-chip")).toBeNull();
|
||||
expect(screen.queryByTestId("mr-status-auto-merge-chip")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MRStatusChip disclosure interactions", () => {
|
||||
beforeEach(resetChipMocks);
|
||||
afterEach(teardownChipMocks);
|
||||
|
||||
it("opens the hover popover on the fine-pointer variant and freezes the acted-on MR while open", () => {
|
||||
gitlabMocks.mrs = [
|
||||
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
|
||||
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }),
|
||||
];
|
||||
const { rerender } = render(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
const trigger = screen.getByTestId(CHIP_TESTID);
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(trigger);
|
||||
vi.advanceTimersByTime(OPEN_DELAY_MS);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId(POPOVER_STUB_TESTID).textContent).toContain("!2");
|
||||
expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("true");
|
||||
expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
|
||||
|
||||
// Store update makes MR "a" the new live selection (now also failed,
|
||||
// and it outranks "b" by the mr_iid tiebreak) while the popover stays
|
||||
// open on "b": data-status tracks the live selection, data-mr-iid and
|
||||
// data-selection-frozen stay frozen on "b" (spec: Selection - freezing
|
||||
// while the disclosure is open). A `rerender()` is required — mutating
|
||||
// the hoisted mock array alone does not re-render the component.
|
||||
gitlabMocks.mrs = [
|
||||
makeMR({ id: "a", mr_iid: 1, pipeline_state: "failure" }),
|
||||
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }),
|
||||
];
|
||||
act(() => {
|
||||
rerender(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
});
|
||||
|
||||
expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
|
||||
expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("true");
|
||||
expect(trigger.getAttribute(DATA_STATUS)).toBe("failed");
|
||||
});
|
||||
|
||||
it("keeps the aria-label naming the acted-on MR's own status while frozen, not the live selection's", () => {
|
||||
gitlabMocks.mrs = [
|
||||
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }), // running, rank 4
|
||||
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }), // failed, rank 5
|
||||
];
|
||||
const { rerender } = render(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
const trigger = screen.getByTestId(CHIP_TESTID);
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(trigger);
|
||||
vi.advanceTimersByTime(OPEN_DELAY_MS);
|
||||
});
|
||||
|
||||
// Frozen on MR "b" (failed) — live and acted-on agree here, a sanity check.
|
||||
expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
|
||||
expect(trigger.getAttribute("aria-label")).toContain("pipeline failed");
|
||||
|
||||
// MR "b" (the frozen, acted-on MR) becomes ready; MR "a" is unchanged and
|
||||
// now outranks it, so the LIVE selection flips to "a" (still running).
|
||||
// data-mr-iid stays on "b" (still open, so the freeze holds), and
|
||||
// data-status tracks the live selection ("running") by design — but the
|
||||
// aria-label describes the acted-on MR, so it must say "ready to merge"
|
||||
// (b's own status), never "pipeline running" (spec: Accessibility, "does
|
||||
// NOT follow data-status's live tracking").
|
||||
gitlabMocks.mrs = [
|
||||
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
|
||||
makeMR({ id: "b", mr_iid: 2, approval_state: "approved", pipeline_state: "success" }),
|
||||
];
|
||||
act(() => {
|
||||
rerender(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
});
|
||||
|
||||
expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
|
||||
expect(trigger.getAttribute(DATA_STATUS)).toBe("running");
|
||||
expect(trigger.getAttribute("aria-label")).toContain("ready to merge");
|
||||
expect(trigger.getAttribute("aria-label")).not.toContain("pipeline running");
|
||||
});
|
||||
|
||||
it("calls the unlink hook with the acted-on MR's association id", () => {
|
||||
gitlabMocks.mrs = [makeMR({ id: "assoc-xyz" })];
|
||||
render(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
const trigger = screen.getByTestId(CHIP_TESTID);
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(trigger);
|
||||
vi.advanceTimersByTime(OPEN_DELAY_MS);
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "unlink" }));
|
||||
expect(unlinkMock).toHaveBeenCalledWith("assoc-xyz");
|
||||
});
|
||||
|
||||
it("closes the popover before opening the link dialog", () => {
|
||||
gitlabMocks.mrs = [makeMR()];
|
||||
render(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
const trigger = screen.getByTestId(CHIP_TESTID);
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(trigger);
|
||||
vi.advanceTimersByTime(OPEN_DELAY_MS);
|
||||
});
|
||||
expect(screen.getByTestId(POPOVER_STUB_TESTID)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "link another" }));
|
||||
|
||||
expect(screen.queryByTestId(POPOVER_STUB_TESTID)).toBeNull();
|
||||
expect(dialogMocks.lastProps?.open).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MRStatusChip freeze and forced close", () => {
|
||||
beforeEach(resetChipMocks);
|
||||
afterEach(teardownChipMocks);
|
||||
|
||||
// spec: Failure modes / Idempotency and re-render — "if the transitioned MR
|
||||
// is the frozen one, the popover or drawer closes; if another open MR
|
||||
// remains the trigger re-selects it." Exercises useFrozenChipSelection's
|
||||
// shouldForceClose path (mr-status-chip-selection.ts:98), which previously
|
||||
// had zero coverage (Review round 1 finding #4).
|
||||
it("force-closes the popover and re-selects the remaining open MR when the frozen MR goes terminal", () => {
|
||||
gitlabMocks.mrs = [
|
||||
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }), // running, rank 4
|
||||
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }), // failed, rank 5 — gets frozen
|
||||
];
|
||||
const { rerender } = render(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
const trigger = screen.getByTestId(CHIP_TESTID);
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(trigger);
|
||||
vi.advanceTimersByTime(OPEN_DELAY_MS);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId(POPOVER_STUB_TESTID)).toBeTruthy();
|
||||
expect(trigger.getAttribute(DATA_MR_IID)).toBe("2");
|
||||
expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("true");
|
||||
|
||||
// The frozen MR ("b") merges elsewhere while the popover is open; "a"
|
||||
// remains the task's only open MR.
|
||||
gitlabMocks.mrs = [
|
||||
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
|
||||
makeMR({ id: "b", mr_iid: 2, state: "merged" }),
|
||||
];
|
||||
act(() => {
|
||||
rerender(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId(POPOVER_STUB_TESTID)).toBeNull();
|
||||
expect(trigger.getAttribute(DATA_MR_COUNT)).toBe("1");
|
||||
expect(trigger.getAttribute(DATA_MR_IID)).toBe("1");
|
||||
expect(trigger.getAttribute(DATA_SELECTION_FROZEN)).toBe("false");
|
||||
});
|
||||
|
||||
// Review round 1 finding #6 (codex): shouldForceClose and actedOnMR's
|
||||
// fallback to the live selection are computed synchronously in the same
|
||||
// render as the store update, but closing the disclosure is a `useEffect`
|
||||
// that only fires after that render commits. Without the `!shouldForceClose`
|
||||
// guard in MRStatusChipPopover/Drawer, MRCIPopover renders — with active
|
||||
// merge/unlink controls — for the swapped-in MR ("a") for one commit before
|
||||
// the effect closes the popover. Tracking every mr_iid MRCIPopover was ever
|
||||
// rendered with (not just the final settled DOM, which act() would show as
|
||||
// already-closed either way) is what actually catches this.
|
||||
it("never renders MRCIPopover for the swapped-in MR during the forced-close transition", () => {
|
||||
gitlabMocks.mrs = [
|
||||
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
|
||||
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }), // frozen
|
||||
];
|
||||
const { rerender } = render(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
const trigger = screen.getByTestId(CHIP_TESTID);
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(trigger);
|
||||
vi.advanceTimersByTime(OPEN_DELAY_MS);
|
||||
});
|
||||
expect(popoverRenderMRIIDs).toEqual([2]);
|
||||
|
||||
gitlabMocks.mrs = [
|
||||
makeMR({ id: "a", mr_iid: 1, pipeline_state: "pending" }),
|
||||
makeMR({ id: "b", mr_iid: 2, state: "merged" }),
|
||||
];
|
||||
act(() => {
|
||||
rerender(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
});
|
||||
|
||||
// Across the whole transition (freeze open -> b vanishes -> popover
|
||||
// closes), MRCIPopover is only ever rendered for "b" (the MR the user
|
||||
// opened the popover on) — never for "a" (the swapped-in live MR).
|
||||
expect(popoverRenderMRIIDs).not.toContain(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MRStatusChip responsive variant", () => {
|
||||
beforeEach(resetChipMocks);
|
||||
afterEach(teardownChipMocks);
|
||||
|
||||
it("renders the coarse-pointer drawer variant instead of the popover", () => {
|
||||
touchMocks.usesTouchDrawer = true;
|
||||
gitlabMocks.mrs = [makeMR()];
|
||||
render(createElement(MRStatusChip, { taskId: "task-1" }));
|
||||
const trigger = screen.getByTestId(CHIP_TESTID);
|
||||
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByTestId("mr-status-chip-drawer")).toBeTruthy();
|
||||
expect(screen.getByTestId(POPOVER_STUB_TESTID).textContent).toContain("!81");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByTestId("mr-status-chip-drawer-close"));
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.getByTestId("mr-status-chip-drawer").getAttribute("data-state")).toBe("closed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id";
|
||||
import {
|
||||
useGitLabAvailable,
|
||||
useTaskMRs,
|
||||
useUnlinkTaskMR,
|
||||
} from "@/hooks/domains/gitlab/use-task-mr";
|
||||
import { useTaskMRAutomationOptions } from "@/hooks/domains/gitlab/use-task-mr-automation";
|
||||
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
|
||||
import { TaskMRLinkDialog } from "./task-mr-link-dialog";
|
||||
import { mrChipStatus, selectChipMR } from "./mr-task-icon";
|
||||
import { chipAutomation } from "./mr-status-chip-selection";
|
||||
import { MRStatusChipPopover } from "./mr-status-chip-popover";
|
||||
import { MRStatusChipDrawer } from "./mr-status-chip-drawer";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
const EMPTY_REPOSITORIES: Repository[] = [];
|
||||
const EMPTY_TASK_REPOSITORIES: Array<{ repository_id: string }> = [];
|
||||
|
||||
/**
|
||||
* Compact GitLab MR status chip for the chat status bar and passthrough
|
||||
* toolbar — the GitLab counterpart to PRStatusChip. Renders nothing when
|
||||
* the task has no MR whose `state === "open"` (spec: What, Failure modes).
|
||||
* `taskId`/`workspaceId` are resolved and narrowed here, before any hook
|
||||
* that should only run for a session view with a linked open MR
|
||||
* (useTaskMRAutomationOptions, useGitLabAvailable — spec: Sync and
|
||||
* freshness, "New trigger 1"/"New trigger 2") is called in the mounted
|
||||
* child below.
|
||||
*/
|
||||
export function MRStatusChip({ taskId }: { taskId: string | null }) {
|
||||
const workspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||
const mrs = useTaskMRs(taskId);
|
||||
const openMRs = Array.isArray(mrs) ? mrs.filter((mr) => mr.state === "open") : [];
|
||||
if (!taskId || !workspaceId || openMRs.length === 0) return null;
|
||||
return (
|
||||
<MRStatusChipMounted taskId={taskId} workspaceId={workspaceId} mrs={mrs} openMRs={openMRs} />
|
||||
);
|
||||
}
|
||||
|
||||
function MRStatusChipMounted({
|
||||
taskId,
|
||||
workspaceId,
|
||||
mrs,
|
||||
openMRs,
|
||||
}: {
|
||||
taskId: string;
|
||||
workspaceId: string;
|
||||
mrs: TaskMR[];
|
||||
openMRs: TaskMR[];
|
||||
}) {
|
||||
const repositories = useAppStore(
|
||||
(state) => state.repositories.itemsByWorkspaceId[workspaceId] ?? EMPTY_REPOSITORIES,
|
||||
);
|
||||
const task = useTaskById(taskId);
|
||||
const { options: automationOptions } = useTaskMRAutomationOptions(taskId);
|
||||
const canLink = useGitLabAvailable();
|
||||
const unlink = useUnlinkTaskMR(workspaceId);
|
||||
const usesTouchDrawer = useTouchDrawer();
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
|
||||
const liveSelected = selectChipMR(mrs);
|
||||
if (!liveSelected) return null;
|
||||
|
||||
const disclosureProps = {
|
||||
mrs,
|
||||
openCount: openMRs.length,
|
||||
liveSelected,
|
||||
liveStatus: mrChipStatus(liveSelected),
|
||||
automation: chipAutomation(automationOptions, openMRs),
|
||||
taskId,
|
||||
canLink,
|
||||
onUnlink: (associationId: string) => void unlink(associationId),
|
||||
onLink: () => setLinkOpen(true),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{usesTouchDrawer ? (
|
||||
<MRStatusChipDrawer {...disclosureProps} />
|
||||
) : (
|
||||
<MRStatusChipPopover {...disclosureProps} />
|
||||
)}
|
||||
<TaskMRLinkDialog
|
||||
open={linkOpen}
|
||||
onOpenChange={setLinkOpen}
|
||||
taskId={taskId}
|
||||
workspaceId={workspaceId}
|
||||
taskRepositories={task?.repositories ?? EMPTY_TASK_REPOSITORIES}
|
||||
repositories={repositories}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { getMRStatusColor, MRStatusIcon } from "./mr-task-icon";
|
||||
import { aggregateMRStatusColor, getMRStatusColor, MRStatusIcon } from "./mr-task-icon";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
@@ -71,4 +71,15 @@ describe("MR status colour parity (AC36)", () => {
|
||||
expect(getMRStatusColor(mr)).toBe(want);
|
||||
},
|
||||
);
|
||||
|
||||
// spec Constraints: aggregateMRStatusColor's first-in-input-order tie is
|
||||
// frozen, not fixed, by the MR status chip feature — this pins it in both
|
||||
// input orders so the chip's own array-order-independent selectChipMR
|
||||
// can never accidentally end up sharing this function's behaviour.
|
||||
it("aggregateMRStatusColor's all-terminal tie is decided by array order, in both orders", () => {
|
||||
const merged = makeMR({ id: "a", state: "merged" });
|
||||
const closed = makeMR({ id: "b", state: "closed" });
|
||||
expect(aggregateMRStatusColor([merged, closed])).toBe("text-purple-500");
|
||||
expect(aggregateMRStatusColor([closed, merged])).toBe(MUTED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
aggregateMRChipStatus,
|
||||
aggregateMRStatusColor,
|
||||
compareChipMR,
|
||||
getMRStatusColor,
|
||||
getMRTooltip,
|
||||
isMRReadyToMerge,
|
||||
mrChipStatus,
|
||||
MR_CHIP_STATUS_RANK,
|
||||
STATUS_RANK,
|
||||
selectChipMR,
|
||||
type MRChipStatus,
|
||||
} from "./mr-task-icon";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
const MUTED = "text-muted-foreground";
|
||||
const PURPLE = "text-purple-500";
|
||||
const RED = "text-red-500";
|
||||
const YELLOW = "text-yellow-500";
|
||||
|
||||
function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
|
||||
return {
|
||||
@@ -42,10 +52,10 @@ function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
|
||||
// AC35: one case per row of the priority table, plus order-proving cases.
|
||||
describe("getMRStatusColor", () => {
|
||||
it.each([
|
||||
["merged", makeMR({ state: "merged" }), "text-purple-500"],
|
||||
["merged", makeMR({ state: "merged" }), PURPLE],
|
||||
["closed", makeMR({ state: "closed" }), MUTED],
|
||||
["locked", makeMR({ state: "locked" }), MUTED],
|
||||
["pipeline failure", makeMR({ pipeline_state: "failure" }), "text-red-500"],
|
||||
["pipeline failure", makeMR({ pipeline_state: "failure" }), RED],
|
||||
["draft", makeMR({ draft: true }), MUTED],
|
||||
[
|
||||
"approved and pipeline success",
|
||||
@@ -53,7 +63,7 @@ describe("getMRStatusColor", () => {
|
||||
"text-emerald-400",
|
||||
],
|
||||
["approval pending", makeMR({ approval_state: "pending" }), "text-sky-400"],
|
||||
["pipeline pending", makeMR({ pipeline_state: "pending" }), "text-yellow-500"],
|
||||
["pipeline pending", makeMR({ pipeline_state: "pending" }), YELLOW],
|
||||
["otherwise", makeMR(), MUTED],
|
||||
])("%s -> %s", (_name, mr, expected) => {
|
||||
expect(getMRStatusColor(mr)).toBe(expected);
|
||||
@@ -61,7 +71,7 @@ describe("getMRStatusColor", () => {
|
||||
|
||||
it("order: merged beats a failing pipeline (row 1 before row 4)", () => {
|
||||
const mr = makeMR({ state: "merged", pipeline_state: "failure" });
|
||||
expect(getMRStatusColor(mr)).toBe("text-purple-500");
|
||||
expect(getMRStatusColor(mr)).toBe(PURPLE);
|
||||
});
|
||||
|
||||
it("order: closed beats approved (row 2 before row 6)", () => {
|
||||
@@ -76,12 +86,12 @@ describe("getMRStatusColor", () => {
|
||||
|
||||
it("order: a failing pipeline beats approved (row 4 before row 6) — approved with red CI is not emerald", () => {
|
||||
const mr = makeMR({ approval_state: "approved", pipeline_state: "failure" });
|
||||
expect(getMRStatusColor(mr)).toBe("text-red-500");
|
||||
expect(getMRStatusColor(mr)).toBe(RED);
|
||||
});
|
||||
|
||||
it("order: a running pipeline beats approved (row 8 is only reached if row 6 didn't match) — approved with pending CI is not emerald", () => {
|
||||
const mr = makeMR({ approval_state: "approved", pipeline_state: "pending" });
|
||||
expect(getMRStatusColor(mr)).toBe("text-yellow-500");
|
||||
expect(getMRStatusColor(mr)).toBe(YELLOW);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,7 +217,7 @@ describe("aggregateMRStatusColor", () => {
|
||||
makeMR({ id: "a", approval_state: "approved", pipeline_state: "success" }),
|
||||
makeMR({ id: "b", pipeline_state: "failure" }),
|
||||
];
|
||||
expect(aggregateMRStatusColor(mrs)).toBe("text-red-500");
|
||||
expect(aggregateMRStatusColor(mrs)).toBe(RED);
|
||||
});
|
||||
|
||||
it("drops terminal MRs when at least one MR is open", () => {
|
||||
@@ -215,11 +225,249 @@ describe("aggregateMRStatusColor", () => {
|
||||
makeMR({ id: "a", state: "merged" }),
|
||||
makeMR({ id: "b", state: "open", pipeline_state: "pending" }),
|
||||
];
|
||||
expect(aggregateMRStatusColor(mrs)).toBe("text-yellow-500");
|
||||
expect(aggregateMRStatusColor(mrs)).toBe(YELLOW);
|
||||
});
|
||||
|
||||
it("falls back to the first MR's colour when every MR is terminal", () => {
|
||||
const mrs = [makeMR({ id: "a", state: "merged" }), makeMR({ id: "b", state: "closed" })];
|
||||
expect(aggregateMRStatusColor(mrs)).toBe("text-purple-500");
|
||||
expect(aggregateMRStatusColor(mrs)).toBe(PURPLE);
|
||||
});
|
||||
|
||||
// Spec Constraints: "SHALL include at least one all-terminal pair in both
|
||||
// input orders" — pins the preserved first-in-input-order tie behaviour
|
||||
// that selectChipMR (below) deliberately does NOT share (spec round-4
|
||||
// finding F30).
|
||||
it("all-terminal pair: array order decides the tie, in both input orders", () => {
|
||||
const mergedFirst = [
|
||||
makeMR({ id: "a", state: "merged" }),
|
||||
makeMR({ id: "b", state: "closed" }),
|
||||
];
|
||||
const closedFirst = [
|
||||
makeMR({ id: "b", state: "closed" }),
|
||||
makeMR({ id: "a", state: "merged" }),
|
||||
];
|
||||
expect(aggregateMRStatusColor(mergedFirst)).toBe(PURPLE);
|
||||
expect(aggregateMRStatusColor(closedFirst)).toBe(MUTED);
|
||||
});
|
||||
});
|
||||
|
||||
// spec: State machine — mrChipStatus is the one priority table
|
||||
// getMRStatusColor now reads from.
|
||||
describe("mrChipStatus", () => {
|
||||
it.each([
|
||||
["merged", makeMR({ state: "merged" }), "merged"],
|
||||
["closed", makeMR({ state: "closed" }), "closed"],
|
||||
["locked", makeMR({ state: "locked" }), "closed"],
|
||||
["pipeline failure", makeMR({ pipeline_state: "failure" }), "failed"],
|
||||
["draft", makeMR({ draft: true }), "draft"],
|
||||
[
|
||||
"approved and pipeline success",
|
||||
makeMR({ approval_state: "approved", pipeline_state: "success" }),
|
||||
"ready",
|
||||
],
|
||||
["approval pending", makeMR({ approval_state: "pending" }), "awaiting_approval"],
|
||||
["pipeline pending", makeMR({ pipeline_state: "pending" }), "running"],
|
||||
["otherwise", makeMR(), "neutral"],
|
||||
] satisfies Array<[string, TaskMR, MRChipStatus]>)("%s -> %s", (_name, mr, expected) => {
|
||||
expect(mrChipStatus(mr)).toBe(expected);
|
||||
});
|
||||
|
||||
it("row 6 carries no pipeline condition: pending approval + pending pipeline is awaiting_approval, not running", () => {
|
||||
expect(mrChipStatus(makeMR({ approval_state: "pending", pipeline_state: "pending" }))).toBe(
|
||||
"awaiting_approval",
|
||||
);
|
||||
});
|
||||
|
||||
it("getMRStatusColor is a byte-identical lookup from mrChipStatus for every status", () => {
|
||||
const statuses: MRChipStatus[] = [
|
||||
"merged",
|
||||
"closed",
|
||||
"failed",
|
||||
"draft",
|
||||
"ready",
|
||||
"awaiting_approval",
|
||||
"running",
|
||||
"neutral",
|
||||
];
|
||||
const fixtures: Record<MRChipStatus, TaskMR> = {
|
||||
merged: makeMR({ state: "merged" }),
|
||||
closed: makeMR({ state: "closed" }),
|
||||
failed: makeMR({ pipeline_state: "failure" }),
|
||||
draft: makeMR({ draft: true }),
|
||||
ready: makeMR({ approval_state: "approved", pipeline_state: "success" }),
|
||||
awaiting_approval: makeMR({ approval_state: "pending" }),
|
||||
running: makeMR({ pipeline_state: "pending" }),
|
||||
neutral: makeMR(),
|
||||
};
|
||||
const expectedColor: Record<MRChipStatus, string> = {
|
||||
merged: PURPLE,
|
||||
closed: MUTED,
|
||||
failed: RED,
|
||||
draft: MUTED,
|
||||
ready: "text-emerald-400",
|
||||
awaiting_approval: "text-sky-400",
|
||||
running: YELLOW,
|
||||
neutral: MUTED,
|
||||
};
|
||||
for (const status of statuses) {
|
||||
expect(mrChipStatus(fixtures[status])).toBe(status);
|
||||
expect(getMRStatusColor(fixtures[status])).toBe(expectedColor[status]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// spec: Selection and ordering.
|
||||
describe("selectChipMR", () => {
|
||||
it("returns null when no MR is open", () => {
|
||||
expect(selectChipMR([makeMR({ state: "merged" }), makeMR({ state: "closed" })])).toBeNull();
|
||||
});
|
||||
|
||||
it("'open' is a positive equality test: locked and unknown states are not selectable", () => {
|
||||
const mrs = [makeMR({ id: "a", state: "locked" }), makeMR({ id: "b", state: "weird-state" })];
|
||||
expect(selectChipMR(mrs)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the maximum MR_CHIP_STATUS_RANK among open MRs", () => {
|
||||
const running = makeMR({ id: "a", pipeline_state: "pending" });
|
||||
const failed = makeMR({ id: "b", pipeline_state: "failure" });
|
||||
expect(selectChipMR([running, failed])?.id).toBe("b");
|
||||
});
|
||||
|
||||
it("tiebreaks by mr_iid ascending", () => {
|
||||
const mrs = [makeMR({ id: "a", mr_iid: 12 }), makeMR({ id: "b", mr_iid: 7 })];
|
||||
expect(selectChipMR(mrs)?.mr_iid).toBe(7);
|
||||
});
|
||||
|
||||
it("tiebreaks by project_path ascending when mr_iid ties", () => {
|
||||
const mrs = [
|
||||
makeMR({ id: "a", mr_iid: 7, project_path: "group/b" }),
|
||||
makeMR({ id: "b", mr_iid: 7, project_path: "group/a" }),
|
||||
];
|
||||
expect(selectChipMR(mrs)?.project_path).toBe("group/a");
|
||||
});
|
||||
|
||||
it("tiebreaks by id ascending when mr_iid and project_path both tie", () => {
|
||||
const mrs = [
|
||||
makeMR({ id: "z", mr_iid: 7, project_path: "group/a" }),
|
||||
makeMR({ id: "a", mr_iid: 7, project_path: "group/a" }),
|
||||
];
|
||||
expect(selectChipMR(mrs)?.id).toBe("a");
|
||||
});
|
||||
|
||||
it("is order-independent: the same winner is picked regardless of input array order", () => {
|
||||
const draft = makeMR({ id: "draft", mr_iid: 9, draft: true });
|
||||
const neutral = makeMR({ id: "neutral", mr_iid: 3 });
|
||||
expect(selectChipMR([draft, neutral])?.id).toBe("neutral");
|
||||
expect(selectChipMR([neutral, draft])?.id).toBe("neutral");
|
||||
});
|
||||
|
||||
// spec round-4 finding F30: selectChipMR must not mutate its input, since
|
||||
// useTaskMRs returns the store array by reference and that same array
|
||||
// backs aggregateMRStatusColor's array-order-dependent tie.
|
||||
it("does not mutate the input array's order", () => {
|
||||
const mrs = [makeMR({ id: "b", mr_iid: 2 }), makeMR({ id: "a", mr_iid: 1 })];
|
||||
const before = mrs.map((mr) => mr.id);
|
||||
selectChipMR(mrs);
|
||||
expect(mrs.map((mr) => mr.id)).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateMRChipStatus", () => {
|
||||
it("equals mrChipStatus(selectChipMR(mrs)) for a mixed list", () => {
|
||||
const mrs = [
|
||||
makeMR({ id: "a", pipeline_state: "pending" }),
|
||||
makeMR({ id: "b", pipeline_state: "failure" }),
|
||||
];
|
||||
expect(aggregateMRChipStatus(mrs)).toBe(mrChipStatus(selectChipMR(mrs) as TaskMR));
|
||||
});
|
||||
|
||||
it("is neutral when selectChipMR returns null", () => {
|
||||
expect(aggregateMRChipStatus([makeMR({ state: "merged" })])).toBe("neutral");
|
||||
});
|
||||
|
||||
// spec.md:931-934: "A property test SHALL assert this over shuffled input
|
||||
// orders." Enumerates every permutation of a small fixed list (no external
|
||||
// property-testing dependency exists in this repo) and asserts the actual
|
||||
// spec equality — aggregateMRChipStatus(mrs) === mrChipStatus(selectChipMR(mrs))
|
||||
// or "neutral" when selectChipMR is null — for EVERY order, not one
|
||||
// hand-picked shuffle compared only to itself.
|
||||
it("equals mrChipStatus(selectChipMR(mrs)), or neutral when null, over every permutation of a mixed list", () => {
|
||||
const mrs = [
|
||||
makeMR({ id: "a", mr_iid: 3 }), // rank 0 (neutral)
|
||||
makeMR({ id: "b", mr_iid: 9, draft: true }), // rank 0 (draft)
|
||||
makeMR({ id: "c", mr_iid: 1, pipeline_state: "failure" }), // rank 5 (failed) — the winner
|
||||
makeMR({ id: "d", mr_iid: 5, state: "merged" }), // terminal, excluded from selection
|
||||
makeMR({ id: "e", mr_iid: 12, pipeline_state: "pending" }), // rank 4 (running)
|
||||
];
|
||||
|
||||
for (const permutation of permutationsOf(mrs)) {
|
||||
const selected = selectChipMR(permutation);
|
||||
const expected = selected ? mrChipStatus(selected) : "neutral";
|
||||
expect(aggregateMRChipStatus(permutation)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("equals neutral over every permutation of an all-terminal list", () => {
|
||||
const mrs = [
|
||||
makeMR({ id: "a", state: "merged" }),
|
||||
makeMR({ id: "b", state: "closed" }),
|
||||
makeMR({ id: "c", state: "locked" }),
|
||||
];
|
||||
|
||||
for (const permutation of permutationsOf(mrs)) {
|
||||
expect(selectChipMR(permutation)).toBeNull();
|
||||
expect(aggregateMRChipStatus(permutation)).toBe("neutral");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** All permutations of a small array (n! — keep n <= ~6 to stay fast). */
|
||||
function permutationsOf<T>(items: T[]): T[][] {
|
||||
if (items.length <= 1) return [items];
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const rest = [...items.slice(0, i), ...items.slice(i + 1)];
|
||||
for (const permutation of permutationsOf(rest)) {
|
||||
result.push([items[i], ...permutation]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// spec: State machine, "Rank, and why aggregation has no tiebreak of its
|
||||
// own" — MR_CHIP_STATUS_RANK and STATUS_RANK are two separate tables keyed
|
||||
// on different things, required only to be order-equivalent under the
|
||||
// status-to-colour map (spec round-4 finding F34).
|
||||
describe("MR_CHIP_STATUS_RANK / STATUS_RANK order-equivalence", () => {
|
||||
const colorFor: Record<MRChipStatus, string> = {
|
||||
merged: PURPLE,
|
||||
closed: MUTED,
|
||||
failed: RED,
|
||||
draft: MUTED,
|
||||
ready: "text-emerald-400",
|
||||
awaiting_approval: "text-sky-400",
|
||||
running: YELLOW,
|
||||
neutral: MUTED,
|
||||
};
|
||||
const statuses = Object.keys(MR_CHIP_STATUS_RANK) as MRChipStatus[];
|
||||
|
||||
it("orders any two statuses the same way as STATUS_RANK does their colours", () => {
|
||||
for (const a of statuses) {
|
||||
for (const b of statuses) {
|
||||
const chipOrder = MR_CHIP_STATUS_RANK[a] < MR_CHIP_STATUS_RANK[b];
|
||||
const colorOrder = STATUS_RANK[colorFor[a]] < STATUS_RANK[colorFor[b]];
|
||||
expect(chipOrder).toBe(colorOrder);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareChipMR", () => {
|
||||
it("is a total order: exactly one of a<b, a===b, a>b holds for any pair", () => {
|
||||
const a = makeMR({ id: "a", mr_iid: 1, project_path: "group/a" });
|
||||
const b = makeMR({ id: "b", mr_iid: 1, project_path: "group/a" });
|
||||
expect(compareChipMR(a, b)).toBeLessThan(0);
|
||||
expect(compareChipMR(b, a)).toBeGreaterThan(0);
|
||||
expect(compareChipMR(a, a)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,29 +14,61 @@ const YELLOW_500 = "text-yellow-500";
|
||||
const SKY_400 = "text-sky-400";
|
||||
const EMERALD_400 = "text-emerald-400";
|
||||
|
||||
/**
|
||||
* The eight-state MR chip status derivation (spec: gitlab-mr-status-chip,
|
||||
* State machine). Total function, evaluated in this exact priority order —
|
||||
* first match wins. `getMRStatusColor` below is re-expressed as a lookup
|
||||
* from this so exactly one priority table exists (was previously its own
|
||||
* copy of the same if-chain).
|
||||
*/
|
||||
export type MRChipStatus =
|
||||
| "merged"
|
||||
| "closed"
|
||||
| "failed"
|
||||
| "draft"
|
||||
| "ready"
|
||||
| "awaiting_approval"
|
||||
| "running"
|
||||
| "neutral";
|
||||
|
||||
export function mrChipStatus(mr: TaskMR): MRChipStatus {
|
||||
if (mr.state === "merged") return "merged";
|
||||
if (mr.state === "closed" || mr.state === "locked") return "closed";
|
||||
if (mr.pipeline_state === "failure") return "failed";
|
||||
if (mr.draft) return "draft";
|
||||
if (mr.approval_state === "approved" && mr.pipeline_state === "success") return "ready";
|
||||
if (mr.approval_state === "pending") return "awaiting_approval";
|
||||
if (mr.pipeline_state === "pending") return "running";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
const MR_CHIP_STATUS_COLOR: Record<MRChipStatus, string> = {
|
||||
merged: PURPLE_500,
|
||||
closed: MUTED_FOREGROUND,
|
||||
failed: RED_500,
|
||||
draft: MUTED_FOREGROUND,
|
||||
ready: EMERALD_400,
|
||||
awaiting_approval: SKY_400,
|
||||
running: YELLOW_500,
|
||||
neutral: MUTED_FOREGROUND,
|
||||
};
|
||||
|
||||
/**
|
||||
* The single source of MR status colour for the GitLab directory (C10, Q5).
|
||||
* Consumed by both the Kanban card badge (MRTaskIcon, below) and the MR
|
||||
* topbar button (mr-topbar-button.tsx) — previously two private,
|
||||
* disagreeing copies (statusTextColor / MRStatusIcon). Evaluated in this
|
||||
* exact priority order (first match wins); see plan AC35 for the table this
|
||||
* mirrors row-for-row.
|
||||
* disagreeing copies (statusTextColor / MRStatusIcon). See plan AC35 for
|
||||
* the table this mirrors row-for-row.
|
||||
*/
|
||||
export function getMRStatusColor(mr: TaskMR): string {
|
||||
if (mr.state === "merged") return PURPLE_500;
|
||||
if (mr.state === "closed") return MUTED_FOREGROUND;
|
||||
if (mr.state === "locked") return MUTED_FOREGROUND;
|
||||
if (mr.pipeline_state === "failure") return RED_500;
|
||||
if (mr.draft) return MUTED_FOREGROUND;
|
||||
if (mr.approval_state === "approved" && mr.pipeline_state === "success") return EMERALD_400;
|
||||
if (mr.approval_state === "pending") return SKY_400;
|
||||
if (mr.pipeline_state === "pending") return YELLOW_500;
|
||||
return MUTED_FOREGROUND;
|
||||
return MR_CHIP_STATUS_COLOR[mrChipStatus(mr)];
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<string, number> = {
|
||||
// Higher = more attention-worthy. Drives the aggregated badge colour when
|
||||
// a task has multiple MRs (mirrors github's aggregatePRStatusColor).
|
||||
// Higher = more attention-worthy. Drives the aggregated badge colour when a
|
||||
// task has multiple MRs (mirrors github's aggregatePRStatusColor). Exported
|
||||
// so a test can assert order-equivalence against MR_CHIP_STATUS_RANK below
|
||||
// without hand-restating either table (spec round-4 finding F34).
|
||||
export const STATUS_RANK: Record<string, number> = {
|
||||
[RED_500]: 4,
|
||||
[YELLOW_500]: 3,
|
||||
[SKY_400]: 2,
|
||||
@@ -45,6 +77,72 @@ const STATUS_RANK: Record<string, number> = {
|
||||
[MUTED_FOREGROUND]: 0,
|
||||
};
|
||||
|
||||
// Ranks the eight chip statuses by attention-worthiness for selectChipMR's
|
||||
// rank filter (step 2). A separate table from STATUS_RANK above: this one is
|
||||
// keyed by status, that one by colour, and they serve different callers —
|
||||
// they are required only to be order-equivalent under the colour map, not
|
||||
// identical (spec: State machine, "Rank, and why aggregation has no tiebreak
|
||||
// of its own").
|
||||
export const MR_CHIP_STATUS_RANK: Record<MRChipStatus, number> = {
|
||||
failed: 5,
|
||||
running: 4,
|
||||
awaiting_approval: 3,
|
||||
ready: 2,
|
||||
merged: 0,
|
||||
closed: 0,
|
||||
draft: 0,
|
||||
neutral: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Total order over MRs used to break rank ties deterministically:
|
||||
* `mr_iid` ascending, then `project_path` ascending (byte-wise `<`), then
|
||||
* `id` ascending. `id` is the association primary key, so this is total.
|
||||
* Shared by selectChipMR (below) and the chip's badge-selection rule, which
|
||||
* uses the identical tiebreak (spec: Selection and ordering).
|
||||
*/
|
||||
export function compareChipMR(a: TaskMR, b: TaskMR): number {
|
||||
if (a.mr_iid !== b.mr_iid) return a.mr_iid - b.mr_iid;
|
||||
if (a.project_path !== b.project_path) return a.project_path < b.project_path ? -1 : 1;
|
||||
if (a.id === b.id) return 0;
|
||||
return a.id < b.id ? -1 : 1;
|
||||
}
|
||||
|
||||
function isChipMRBetter(candidate: TaskMR, current: TaskMR): boolean {
|
||||
const candidateRank = MR_CHIP_STATUS_RANK[mrChipStatus(candidate)];
|
||||
const currentRank = MR_CHIP_STATUS_RANK[mrChipStatus(current)];
|
||||
if (candidateRank !== currentRank) return candidateRank > currentRank;
|
||||
return compareChipMR(candidate, current) < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically picks the one MR the chip renders and acts on: restrict
|
||||
* to `state === "open"` exactly, keep the maximum MR_CHIP_STATUS_RANK, then
|
||||
* tiebreak via compareChipMR. Pure and non-mutating — it never sorts or
|
||||
* otherwise reorders `mrs` in place, so a caller holding the same array
|
||||
* reference (useTaskMRs returns the store array by reference) never has it
|
||||
* silently reordered under it (spec round-4 finding F30).
|
||||
*/
|
||||
export function selectChipMR(mrs: TaskMR[]): TaskMR | null {
|
||||
let best: TaskMR | null = null;
|
||||
for (const mr of mrs) {
|
||||
if (mr.state !== "open") continue;
|
||||
if (!best || isChipMRBetter(mr, best)) best = mr;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* `aggregateMRChipStatus(mrs)` is defined as the composition
|
||||
* `mrChipStatus(selectChipMR(mrs))`, returning `neutral` when selectChipMR
|
||||
* returns null. It has no tiebreak rule of its own — see State machine,
|
||||
* "Rank, and why aggregation has no tiebreak of its own".
|
||||
*/
|
||||
export function aggregateMRChipStatus(mrs: TaskMR[]): MRChipStatus {
|
||||
const selected = selectChipMR(mrs);
|
||||
return selected ? mrChipStatus(selected) : "neutral";
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort client-side approximation of the backend's
|
||||
* mrAutomationReadyToMerge, evaluated against whatever TaskMR fields are
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("@/components/state-provider", () => ({
|
||||
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
|
||||
useGitLabAvailable: () => true,
|
||||
useTaskMRs: () => gitlabMocks.mrs,
|
||||
useUnlinkTaskMR: () => vi.fn(async () => {}),
|
||||
useWorkspaceMRs: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ vi.mock("@/components/state-provider", () => ({
|
||||
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
|
||||
useGitLabAvailable: () => true,
|
||||
useTaskMRs: () => gitlabMocks.mrs,
|
||||
useUnlinkTaskMR: () => vi.fn(async () => {}),
|
||||
useWorkspaceMRs: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/components/state-provider", () => ({
|
||||
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
|
||||
useGitLabAvailable: () => true,
|
||||
useTaskMRs: () => gitlabMocks.mrs,
|
||||
useUnlinkTaskMR: () => vi.fn(async () => {}),
|
||||
useWorkspaceMRs: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -28,12 +28,11 @@ import { Popover, PopoverAnchor, PopoverContent } from "@kandev/ui/popover";
|
||||
import {
|
||||
useGitLabAvailable,
|
||||
useTaskMRs,
|
||||
useUnlinkTaskMR,
|
||||
useWorkspaceMRs,
|
||||
} from "@/hooks/domains/gitlab/use-task-mr";
|
||||
import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { deleteTaskMR } from "@/lib/api/domains/gitlab-api";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import { TaskMRLinkDialog } from "./task-mr-link-dialog";
|
||||
@@ -54,7 +53,7 @@ const MR_POPOVER_CLOSE_DELAY_MS = 150;
|
||||
* PRMultiButton (C7). A multi-MR aggregate popover is out of scope (§9) —
|
||||
* hovering a 2+ MR trigger only ever opens the click-driven dropdown.
|
||||
*/
|
||||
function useMRPopoverInteractions() {
|
||||
export function useMRPopoverInteractions() {
|
||||
const usesTouchDrawer = useTouchDrawer();
|
||||
const hover = useHoverPopover({
|
||||
openDelayMs: MR_POPOVER_OPEN_DELAY_MS,
|
||||
@@ -449,7 +448,6 @@ export const MRTopbarButton = memo(function MRTopbarButton({
|
||||
compact?: boolean;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
|
||||
const workspaceId = useAppStore((s) => s.workspaces.activeId);
|
||||
@@ -462,25 +460,10 @@ export const MRTopbarButton = memo(function MRTopbarButton({
|
||||
useWorkspaceMRs(workspaceId);
|
||||
const mrs = useTaskMRs(activeTaskId);
|
||||
const gitlabAvailable = useGitLabAvailable();
|
||||
const removeTaskMR = useAppStore((state) => state.removeTaskMR);
|
||||
const { toast } = useToast();
|
||||
const unlink = useUnlinkTaskMR(workspaceId);
|
||||
|
||||
if (!activeTaskId || !workspaceId) return null;
|
||||
|
||||
const unlink = async (associationId: string) => {
|
||||
try {
|
||||
await deleteTaskMR(associationId, workspaceId);
|
||||
removeTaskMR(workspaceId, associationId);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("gitlab:failedToUnlinkMergeRequest"),
|
||||
description:
|
||||
error instanceof Error ? error.message : t("gitlab:theMergeRequestIsStillLinked"),
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MRTopbarControl
|
||||
|
||||
@@ -19,6 +19,10 @@ vi.mock("@/components/github/pr-status-chip", () => ({
|
||||
PRStatusChip: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/gitlab/mr-status-chip", () => ({
|
||||
MRStatusChip: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/azure-devops/azure-devops-task-pull-request-chip", () => ({
|
||||
AzureDevOpsTaskPullRequestChip: () => null,
|
||||
}));
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TodoIndicator } from "./todo-indicator";
|
||||
import { AutoScrollToggleButton } from "./auto-scroll-toggle-button";
|
||||
import { PRMergedBanner, PRClosedBanner } from "./pr-archive-banners";
|
||||
import { PRStatusChip } from "@/components/github/pr-status-chip";
|
||||
import { MRStatusChip } from "@/components/gitlab/mr-status-chip";
|
||||
import { AzureDevOpsTaskPullRequestChip } from "@/components/azure-devops/azure-devops-task-pull-request-chip";
|
||||
import { shareableSessionStateClient } from "@/components/task/share/share-button";
|
||||
import { TranscriptNavGroup } from "@/components/task/chat/transcript-nav-group";
|
||||
@@ -429,6 +430,7 @@ function ChatStatusBar({
|
||||
{showTodos && <TodoIndicator todos={todoItems} />}
|
||||
{autopilot && <AutopilotChatChip />}
|
||||
<PRStatusChip taskId={taskId} />
|
||||
<MRStatusChip taskId={taskId} />
|
||||
<AzureDevOpsTaskPullRequestChip taskId={taskId} />
|
||||
{queueChip}
|
||||
{/* Distinct per-banner keys: the key remounts the banner on task switch
|
||||
|
||||
@@ -120,6 +120,10 @@ vi.mock("@/components/github/pr-status-chip", () => ({
|
||||
PRStatusChip: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/gitlab/mr-status-chip", () => ({
|
||||
MRStatusChip: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/azure-devops/azure-devops-task-pull-request-chip", () => ({
|
||||
AzureDevOpsTaskPullRequestChip: () => null,
|
||||
}));
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Button } from "@kandev/ui/button";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { PRStatusChip } from "@/components/github/pr-status-chip";
|
||||
import { MRStatusChip } from "@/components/gitlab/mr-status-chip";
|
||||
import { AzureDevOpsTaskPullRequestChip } from "@/components/azure-devops/azure-devops-task-pull-request-chip";
|
||||
import { PRMergedBanner } from "./chat/pr-archive-banners";
|
||||
import { type ChatInputContainerHandle } from "./chat/chat-input-container";
|
||||
@@ -535,6 +536,7 @@ function PassthroughStatusRow({
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<PRStatusChip taskId={taskId} />
|
||||
<MRStatusChip taskId={taskId} />
|
||||
<AzureDevOpsTaskPullRequestChip taskId={taskId} />
|
||||
{taskId && <PRMergedBanner key={taskId} taskId={taskId} />}
|
||||
{showProceed && nextStepName && (
|
||||
|
||||
@@ -747,6 +747,50 @@ export class SessionPage {
|
||||
await expect(this.prStatusChipDrawer()).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
// --- GitLab MR status chip accessors: mirrors the PR status chip shape
|
||||
// above, including its scoping (spec: gitlab-mr-status-chip, Constraints).
|
||||
|
||||
/** Compact GitLab MR status chip rendered in the chat status bar. */
|
||||
mrStatusChip(): Locator {
|
||||
return this.activeChat().getByTestId("chat-status-bar").getByTestId("mr-status-chip");
|
||||
}
|
||||
|
||||
/** Compact GitLab MR status chip rendered in the passthrough toolbar's status row. */
|
||||
mrStatusChipInPassthrough(): Locator {
|
||||
return this.page.getByTestId("passthrough-status-row").getByTestId("mr-status-chip");
|
||||
}
|
||||
|
||||
/** Mobile bottom-sheet drawer that hosts the chip's MRCIPopover body. */
|
||||
mrStatusChipDrawer(): Locator {
|
||||
return this.page.getByTestId("mr-status-chip-drawer");
|
||||
}
|
||||
|
||||
/** Close button inside the chip's mobile drawer. */
|
||||
mrStatusChipDrawerClose(): Locator {
|
||||
return this.page.getByTestId("mr-status-chip-drawer-close");
|
||||
}
|
||||
|
||||
/**
|
||||
* MRCIPopover body when rendered inside the chip's own disclosure — the
|
||||
* hover popover on a fine pointer, or the drawer on a coarse pointer.
|
||||
* `mr-topbar-popover-inner` is also emitted by MRTopbarButton's own
|
||||
* popover on the same route, so this scopes through the chip's own
|
||||
* wrapper testid (`mr-status-chip-popover` / `mr-status-chip-drawer`)
|
||||
* rather than resolving the inner testid globally.
|
||||
*/
|
||||
mrStatusChipPopoverInner(): Locator {
|
||||
return this.page
|
||||
.getByTestId("mr-status-chip-popover")
|
||||
.getByTestId("mr-topbar-popover-inner")
|
||||
.or(this.mrStatusChipDrawer().getByTestId("mr-topbar-popover-inner"));
|
||||
}
|
||||
|
||||
/** Tap the chip and wait for the mobile drawer to be visible. */
|
||||
async tapMRStatusChip(): Promise<void> {
|
||||
await this.mrStatusChip().tap();
|
||||
await expect(this.mrStatusChipDrawer()).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/** Multi-PR aggregate popover content (segmented tabs + selected PR's CI). */
|
||||
prTopbarPopoverAggregate(): Locator {
|
||||
return this.page.getByTestId("pr-multi-popover");
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { GITLAB_HOST, GITLAB_PROJECT } from "../../helpers/gitlab";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
|
||||
const MR_IID = 411;
|
||||
|
||||
/**
|
||||
* Seeds one open MR directly with state: "open" (already normalized) rather
|
||||
* than the shared seedGitLabReview helper's GitLab-native "opened" — the
|
||||
* mock provider never normalizes state, and the chip's spec-mandated
|
||||
* `state === "open"` exact-equality filter needs the normalized form. See
|
||||
* mr-status-chip.spec.ts's seedChipMR for the full rationale.
|
||||
*/
|
||||
async function seedTaskWithLinkedMR(apiClient: ApiClient, seedData: SeedData, title: string) {
|
||||
await apiClient.configureGitLab(seedData.workspaceId, GITLAB_HOST);
|
||||
const now = new Date().toISOString();
|
||||
await apiClient.mockGitLabAddMRs(seedData.workspaceId, GITLAB_PROJECT, [
|
||||
{
|
||||
iid: MR_IID,
|
||||
id: MR_IID + 10_000,
|
||||
project_id: 101,
|
||||
title: "Mobile chip MR",
|
||||
url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${MR_IID}`,
|
||||
web_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${MR_IID}`,
|
||||
state: "open",
|
||||
head_branch: "feature/mobile-chip",
|
||||
head_sha: `sha-${MR_IID}`,
|
||||
base_branch: "main",
|
||||
author_username: "contributor",
|
||||
project_namespace: "platform",
|
||||
project_path: GITLAB_PROJECT,
|
||||
body: "",
|
||||
draft: false,
|
||||
merge_status: "can_be_merged",
|
||||
has_conflicts: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
reviewers: [],
|
||||
assignees: [],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
]);
|
||||
await apiClient.updateRepository(seedData.repositoryId, {
|
||||
provider: "gitlab",
|
||||
provider_host: GITLAB_HOST,
|
||||
provider_owner: "platform",
|
||||
provider_name: "kandev",
|
||||
});
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
title,
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
await apiClient.linkTaskGitLabMR(seedData.workspaceId, {
|
||||
task_id: task.id,
|
||||
repository_id: seedData.repositoryId,
|
||||
mr_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${MR_IID}`,
|
||||
});
|
||||
return task.id;
|
||||
}
|
||||
|
||||
test.describe("mobile GitLab MR status chip", () => {
|
||||
test("tapping the chip opens the drawer with the popover body, and closing returns focus to the trigger", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const taskId = await seedTaskWithLinkedMR(apiClient, seedData, "Mobile MR chip");
|
||||
|
||||
await testPage.goto(`/t/${taskId}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
await expect(session.mrStatusChip()).toBeVisible({ timeout: 15_000 });
|
||||
await session.tapMRStatusChip();
|
||||
|
||||
const drawer = session.mrStatusChipDrawer();
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(session.mrStatusChipPopoverInner()).toContainText(`!${MR_IID}`);
|
||||
|
||||
await session.mrStatusChipDrawerClose().click();
|
||||
await expect(drawer).toBeHidden({ timeout: 5_000 });
|
||||
await expect(session.mrStatusChip()).toBeFocused();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { GITLAB_HOST, GITLAB_PROJECT } from "../../helpers/gitlab";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
|
||||
/**
|
||||
* Seeds one open MR with a successful pipeline and 0/1 approvals
|
||||
* (-> awaiting_approval per the chip's state machine). Deliberately does NOT
|
||||
* reuse the shared seedGitLabReview helper (e2e/helpers/gitlab.ts): that
|
||||
* helper seeds GitLab's raw wording state: "opened", which MRTopbarButton
|
||||
* doesn't care about (it renders on mrs.length > 0 regardless of state) but
|
||||
* the chip's spec-mandated `state === "open"` exact-equality filter does not
|
||||
* match. The mock provider never normalizes "opened" -> "open" (only the
|
||||
* real GitLab HTTP client's convertRawMR does that), so a chip-focused test
|
||||
* must seed the already-normalized value directly — the same convention
|
||||
* mr-task-card-badge.spec.ts's local seedMR helper already uses for the same
|
||||
* reason.
|
||||
*/
|
||||
async function seedChipMR(apiClient: ApiClient, workspaceId: string, iid: number, title: string) {
|
||||
await apiClient.configureGitLab(workspaceId, GITLAB_HOST);
|
||||
await apiClient.mockGitLabAddMRs(workspaceId, GITLAB_PROJECT, [mrSeed(iid, title)]);
|
||||
await apiClient.mockGitLabAddPipelines(workspaceId, GITLAB_PROJECT, [
|
||||
pipelineSeed(iid, "success"),
|
||||
]);
|
||||
await apiClient.mockGitLabAddApprovals(workspaceId, GITLAB_PROJECT, iid, [], 1);
|
||||
}
|
||||
|
||||
/** Create a passthrough agent profile, mirroring cli-mode/passthrough-toolbar.spec.ts. */
|
||||
async function createPassthroughProfile(apiClient: ApiClient, name: string): Promise<string> {
|
||||
const { agents } = await apiClient.listAgents();
|
||||
if (agents.length === 0) throw new Error("no agents registered in this e2e profile");
|
||||
const profile = await apiClient.createAgentProfile(agents[0].id, name, {
|
||||
model: "mock-fast",
|
||||
auto_approve: true,
|
||||
cli_passthrough: true,
|
||||
});
|
||||
return profile.id;
|
||||
}
|
||||
|
||||
async function createTask(apiClient: ApiClient, seedData: SeedData, title: string) {
|
||||
await apiClient.updateRepository(seedData.repositoryId, {
|
||||
provider: "gitlab",
|
||||
provider_host: GITLAB_HOST,
|
||||
provider_owner: "platform",
|
||||
provider_name: "kandev",
|
||||
});
|
||||
return apiClient.createTaskWithAgent(seedData.workspaceId, title, seedData.agentProfileId, {
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
}
|
||||
|
||||
async function linkMR(apiClient: ApiClient, seedData: SeedData, taskId: string, iid: number) {
|
||||
await apiClient.linkTaskGitLabMR(seedData.workspaceId, {
|
||||
task_id: taskId,
|
||||
repository_id: seedData.repositoryId,
|
||||
mr_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function openTask(
|
||||
testPage: import("@playwright/test").Page,
|
||||
session: SessionPage,
|
||||
taskId: string,
|
||||
) {
|
||||
await testPage.goto(`/t/${taskId}`);
|
||||
await session.waitForLoad();
|
||||
}
|
||||
|
||||
test.describe("GitLab MR status chip", () => {
|
||||
test("renders in the chat status bar for a single open MR, and hovering reveals the popover body", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const MR_IID = 401;
|
||||
await seedChipMR(apiClient, seedData.workspaceId, MR_IID, "Chip render MR");
|
||||
const task = await createTask(apiClient, seedData, "MR chip render");
|
||||
await linkMR(apiClient, seedData, task.id, MR_IID);
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await openTask(testPage, session, task.id);
|
||||
|
||||
const chip = session.mrStatusChip();
|
||||
await expect(chip).toBeVisible({ timeout: 15_000 });
|
||||
// seedChipMR: pipeline succeeds, approvals 0/1 -> awaiting_approval.
|
||||
await expect(chip).toHaveAttribute("data-status", "awaiting_approval");
|
||||
await expect(chip).toHaveAttribute("data-mr-count", "1");
|
||||
await expect(chip).toHaveAttribute("data-mr-iid", String(MR_IID));
|
||||
await expect(chip).toHaveAttribute("data-selection-frozen", "false");
|
||||
|
||||
await chip.hover();
|
||||
const inner = session.mrStatusChipPopoverInner();
|
||||
await expect(inner).toBeVisible({ timeout: 5_000 });
|
||||
await expect(inner).toContainText(`!${MR_IID}`);
|
||||
|
||||
const box = await inner.boundingBox();
|
||||
expect(box?.y).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("selects the awaiting-approval MR over a draft one (higher rank), and freezes the acted-on MR while the popover is open", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const DRAFT_IID = 402;
|
||||
const AWAITING_IID = 403;
|
||||
await apiClient.configureGitLab(seedData.workspaceId, GITLAB_HOST);
|
||||
// The mock provider's pipeline list is keyed by project only (shared by
|
||||
// every MR in it, not per-ref), so both MRs get the same successful
|
||||
// pipeline here and are differentiated by draft/approval state instead.
|
||||
await apiClient.mockGitLabAddMRs(seedData.workspaceId, GITLAB_PROJECT, [
|
||||
{ ...mrSeed(DRAFT_IID, "Draft MR"), draft: true },
|
||||
mrSeed(AWAITING_IID, "Awaiting approval MR"),
|
||||
]);
|
||||
await apiClient.mockGitLabAddPipelines(seedData.workspaceId, GITLAB_PROJECT, [
|
||||
pipelineSeed(DRAFT_IID, "success"),
|
||||
]);
|
||||
await apiClient.mockGitLabAddApprovals(
|
||||
seedData.workspaceId,
|
||||
GITLAB_PROJECT,
|
||||
AWAITING_IID,
|
||||
[],
|
||||
1,
|
||||
);
|
||||
|
||||
const task = await createTask(apiClient, seedData, "MR chip multi-select");
|
||||
await linkMR(apiClient, seedData, task.id, DRAFT_IID);
|
||||
await linkMR(apiClient, seedData, task.id, AWAITING_IID);
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await openTask(testPage, session, task.id);
|
||||
|
||||
const chip = session.mrStatusChip();
|
||||
await expect(chip).toBeVisible({ timeout: 15_000 });
|
||||
await expect(chip).toHaveAttribute("data-status", "awaiting_approval");
|
||||
await expect(chip).toHaveAttribute("data-mr-count", "2");
|
||||
await expect(chip).toHaveAttribute("data-mr-iid", String(AWAITING_IID));
|
||||
|
||||
await chip.hover();
|
||||
await expect(session.mrStatusChipPopoverInner()).toBeVisible({ timeout: 5_000 });
|
||||
await expect(chip).toHaveAttribute("data-selection-frozen", "true");
|
||||
// The frozen acted-on MR keeps naming the higher-ranked MR while the popover stays open.
|
||||
await expect(chip).toHaveAttribute("data-mr-iid", String(AWAITING_IID));
|
||||
});
|
||||
|
||||
test("unlinking from the chip popover removes the association and the chip unmounts", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const MR_IID = 404;
|
||||
await seedChipMR(apiClient, seedData.workspaceId, MR_IID, "Unlink MR");
|
||||
const task = await createTask(apiClient, seedData, "MR chip unlink");
|
||||
await linkMR(apiClient, seedData, task.id, MR_IID);
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await openTask(testPage, session, task.id);
|
||||
|
||||
const chip = session.mrStatusChip();
|
||||
await expect(chip).toBeVisible({ timeout: 15_000 });
|
||||
await chip.hover();
|
||||
const inner = session.mrStatusChipPopoverInner();
|
||||
await expect(inner).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await inner.getByRole("button", { name: `Unlink !${MR_IID}` }).click();
|
||||
await expect(chip).toHaveCount(0, { timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("activating link-another closes the popover before the link dialog opens", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const MR_IID = 405;
|
||||
await seedChipMR(apiClient, seedData.workspaceId, MR_IID, "Link another MR");
|
||||
const task = await createTask(apiClient, seedData, "MR chip link another");
|
||||
await linkMR(apiClient, seedData, task.id, MR_IID);
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await openTask(testPage, session, task.id);
|
||||
|
||||
const chip = session.mrStatusChip();
|
||||
await expect(chip).toBeVisible({ timeout: 15_000 });
|
||||
await chip.hover();
|
||||
const inner = session.mrStatusChipPopoverInner();
|
||||
await expect(inner).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await inner.getByTestId("mr-popover-link-another").click();
|
||||
await expect(session.mrStatusChipPopoverInner()).toBeHidden({ timeout: 5_000 });
|
||||
await expect(testPage.getByRole("dialog")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("renders auto-fix (round 0) and auto-merge badges once automation is enabled", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const MR_IID = 406;
|
||||
await seedChipMR(apiClient, seedData.workspaceId, MR_IID, "Automation badges MR");
|
||||
const task = await createTask(apiClient, seedData, "MR chip automation badges");
|
||||
await linkMR(apiClient, seedData, task.id, MR_IID);
|
||||
await apiClient.updateTaskMRAutomationOptions(task.id, {
|
||||
auto_fix_enabled: true,
|
||||
auto_merge_enabled: true,
|
||||
});
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await openTask(testPage, session, task.id);
|
||||
|
||||
const chip = session.mrStatusChip();
|
||||
await expect(chip).toBeVisible({ timeout: 15_000 });
|
||||
const autoFixBadge = chip.getByTestId("mr-status-auto-fix-chip");
|
||||
await expect(autoFixBadge).toBeVisible();
|
||||
await expect(autoFixBadge).toContainText("0/10");
|
||||
await expect(autoFixBadge).toHaveAttribute("data-auto-fix-exhausted", "false");
|
||||
await expect(chip.getByTestId("mr-status-auto-merge-chip")).toBeVisible();
|
||||
});
|
||||
|
||||
test("DOM order: pr-status-chip precedes mr-status-chip when a task has both an open PR and MR", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const MR_IID = 407;
|
||||
await seedChipMR(apiClient, seedData.workspaceId, MR_IID, "Dual chip MR");
|
||||
const task = await createTask(apiClient, seedData, "MR chip DOM order");
|
||||
await linkMR(apiClient, seedData, task.id, MR_IID);
|
||||
await apiClient.mockGitHubAssociateTaskPR({
|
||||
task_id: task.id,
|
||||
owner: "test-org",
|
||||
repo: "test-repo",
|
||||
pr_number: 501,
|
||||
pr_url: "https://github.com/test-org/test-repo/pull/501",
|
||||
pr_title: "Dual chip PR",
|
||||
head_branch: "feature/dual",
|
||||
base_branch: "main",
|
||||
author_login: "test-user",
|
||||
state: "open",
|
||||
});
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await openTask(testPage, session, task.id);
|
||||
|
||||
const statusBar = session.chatStatusBar();
|
||||
await expect(statusBar.getByTestId("pr-status-chip")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(session.mrStatusChip()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const order = await statusBar.evaluate((el) => {
|
||||
const children = Array.from(
|
||||
el.querySelectorAll("[data-testid='pr-status-chip'], [data-testid='mr-status-chip']"),
|
||||
);
|
||||
return children.map((child) => child.getAttribute("data-testid"));
|
||||
});
|
||||
expect(order.indexOf("pr-status-chip")).toBeLessThan(order.indexOf("mr-status-chip"));
|
||||
});
|
||||
|
||||
test("renders in the passthrough toolbar's status row for a passthrough session", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const MR_IID = 408;
|
||||
await seedChipMR(apiClient, seedData.workspaceId, MR_IID, "Passthrough chip MR");
|
||||
await apiClient.updateRepository(seedData.repositoryId, {
|
||||
provider: "gitlab",
|
||||
provider_host: GITLAB_HOST,
|
||||
provider_owner: "platform",
|
||||
provider_name: "kandev",
|
||||
});
|
||||
const profileId = await createPassthroughProfile(apiClient, "MR Chip Passthrough");
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"MR chip passthrough row",
|
||||
profileId,
|
||||
{
|
||||
description: "initial prompt",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
await linkMR(apiClient, seedData, task.id, MR_IID);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const card = kanban.taskCardByTitle("MR chip passthrough row");
|
||||
await expect(card).toBeVisible({ timeout: 30_000 });
|
||||
await card.click();
|
||||
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForPassthroughLoad(20_000);
|
||||
await session.waitForPassthroughLoaded(20_000);
|
||||
|
||||
const chip = session.mrStatusChipInPassthrough();
|
||||
await expect(chip).toBeVisible({ timeout: 15_000 });
|
||||
await expect(chip).toHaveAttribute("data-mr-iid", String(MR_IID));
|
||||
});
|
||||
});
|
||||
|
||||
function mrSeed(iid: number, title: string) {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: iid + 10_000,
|
||||
iid,
|
||||
project_id: 101,
|
||||
title,
|
||||
url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
|
||||
web_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
|
||||
state: "open",
|
||||
head_branch: `feature/${iid}`,
|
||||
head_sha: `sha-${iid}`,
|
||||
base_branch: "main",
|
||||
author_username: "contributor",
|
||||
project_namespace: "platform",
|
||||
project_path: GITLAB_PROJECT,
|
||||
body: "",
|
||||
draft: false,
|
||||
merge_status: "can_be_merged",
|
||||
has_conflicts: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
reviewers: [],
|
||||
assignees: [],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineSeed(iid: number, status: string) {
|
||||
return {
|
||||
id: iid + 20_000,
|
||||
iid: 1,
|
||||
status,
|
||||
source: "push",
|
||||
ref: `feature/${iid}`,
|
||||
sha: `sha-${iid}`,
|
||||
web_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/pipelines/${iid + 20_000}`,
|
||||
jobs_total: 2,
|
||||
jobs_passing: status === "success" ? 2 : 0,
|
||||
};
|
||||
}
|
||||
@@ -1421,6 +1421,11 @@ export const i18nGuardFiles = [
|
||||
"components/gitlab/mr-files-section.tsx",
|
||||
"components/gitlab/mr-overview-section.tsx",
|
||||
"components/gitlab/mr-reviewer-control.tsx",
|
||||
"components/gitlab/mr-status-chip.tsx",
|
||||
"components/gitlab/mr-status-chip-drawer.tsx",
|
||||
"components/gitlab/mr-status-chip-popover.tsx",
|
||||
"components/gitlab/mr-status-chip-selection.ts",
|
||||
"components/gitlab/mr-status-chip-trigger.tsx",
|
||||
"components/gitlab/mr-topbar-button.tsx",
|
||||
"components/gitlab/my-gitlab/issue-list.tsx",
|
||||
"components/gitlab/my-gitlab/list-toolbar.tsx",
|
||||
|
||||
@@ -7,15 +7,23 @@ import type { GitLabStatus, TaskMR } from "@/lib/types/gitlab";
|
||||
const fetchGitLabStatusMock = vi.fn<() => Promise<GitLabStatus | null>>();
|
||||
const listWorkspaceTaskMRsMock =
|
||||
vi.fn<(workspaceId: string) => Promise<{ task_mrs: Record<string, TaskMR[]> } | null>>();
|
||||
const deleteTaskMRMock = vi.fn<(associationId: string, workspaceId: string) => Promise<void>>();
|
||||
const EMPTY_TASK_MRS: Record<string, TaskMR[]> = {};
|
||||
const WORKSPACE_MRS_TEST_ID = "workspace-mrs";
|
||||
const toastMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api/domains/gitlab-api", () => ({
|
||||
fetchGitLabStatus: () => fetchGitLabStatusMock(),
|
||||
listWorkspaceTaskMRs: (workspaceId: string) => listWorkspaceTaskMRsMock(workspaceId),
|
||||
deleteTaskMR: (associationId: string, workspaceId: string) =>
|
||||
deleteTaskMRMock(associationId, workspaceId),
|
||||
}));
|
||||
|
||||
import { useGitLabAvailable, useTaskMRs, useWorkspaceMRs } from "./use-task-mr";
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
import { useGitLabAvailable, useTaskMRs, useUnlinkTaskMR, useWorkspaceMRs } from "./use-task-mr";
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return createElement(StateProvider, {
|
||||
@@ -352,3 +360,81 @@ describe("useGitLabAvailable", () => {
|
||||
expect(fetchGitLabStatusMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// spec: API surface, "one extraction ... moved verbatim". Both
|
||||
// MRTopbarButton and MRStatusChip call this closure, so parity between the
|
||||
// two surfaces holds because there is exactly one implementation.
|
||||
describe("useUnlinkTaskMR", () => {
|
||||
beforeEach(() => {
|
||||
deleteTaskMRMock.mockReset();
|
||||
toastMock.mockReset();
|
||||
});
|
||||
|
||||
it("is a no-op when workspaceId is null", async () => {
|
||||
const { result } = renderHook(() => useUnlinkTaskMR(null), { wrapper });
|
||||
await act(async () => {
|
||||
await result.current("assoc-1");
|
||||
});
|
||||
expect(deleteTaskMRMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the association from the store on success", async () => {
|
||||
deleteTaskMRMock.mockResolvedValueOnce(undefined);
|
||||
const mr = makeMR({ id: "assoc-1", task_id: "task-1" });
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const setTaskMRs = useAppStore((s) => s.setTaskMRs);
|
||||
const mrs = useTaskMRs("task-1", "ws-1");
|
||||
const unlink = useUnlinkTaskMR("ws-1");
|
||||
return { setTaskMRs, mrs, unlink };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
act(() => {
|
||||
result.current.setTaskMRs("ws-1", { "task-1": [mr] });
|
||||
});
|
||||
expect(result.current.mrs).toEqual([mr]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.unlink("assoc-1");
|
||||
});
|
||||
|
||||
expect(deleteTaskMRMock).toHaveBeenCalledWith("assoc-1", "ws-1");
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
// The whole point of this test: the association actually left the
|
||||
// store. Asserting only pre-unlink state (as this test previously did)
|
||||
// is phantom-green — deleting the removeTaskMR call from the hook still
|
||||
// leaves that assertion passing.
|
||||
expect(result.current.mrs).toEqual([]);
|
||||
});
|
||||
|
||||
it("toasts an error and leaves the association in the store on failure", async () => {
|
||||
deleteTaskMRMock.mockRejectedValueOnce(new Error("network down"));
|
||||
const mr = makeMR({ id: "assoc-1", task_id: "task-1" });
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const setTaskMRs = useAppStore((s) => s.setTaskMRs);
|
||||
const mrs = useTaskMRs("task-1", "ws-1");
|
||||
const unlink = useUnlinkTaskMR("ws-1");
|
||||
return { setTaskMRs, mrs, unlink };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
act(() => {
|
||||
result.current.setTaskMRs("ws-1", { "task-1": [mr] });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.unlink("assoc-1");
|
||||
});
|
||||
|
||||
// spec.md:1026-1029 names the specific title key (gitlab:failedToUnlinkMergeRequest)
|
||||
// both surfaces must use; assert its resolved English text since i18next
|
||||
// is the real instance in this test environment, not a raw-key stub.
|
||||
expect(toastMock).toHaveBeenCalledTimes(1);
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Failed to unlink merge request" }),
|
||||
);
|
||||
expect(result.current.mrs).toEqual([mr]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { listWorkspaceTaskMRs } from "@/lib/api/domains/gitlab-api";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deleteTaskMR, listWorkspaceTaskMRs } from "@/lib/api/domains/gitlab-api";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import { useGitLabStatus } from "./use-gitlab-status";
|
||||
|
||||
@@ -75,3 +77,39 @@ export function useGitLabAvailable(): boolean {
|
||||
const { status } = useGitLabStatus();
|
||||
return Boolean(status?.authenticated || status?.token_configured);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink closure shared by MRTopbarButton and MRStatusChip so the two
|
||||
* surfaces can never disagree on unlink behaviour (extracted rather than
|
||||
* duplicated — apps/web/eslint.config.mjs forbids identical functions and
|
||||
* 4+ duplicated strings, so a second copy would be a lint failure).
|
||||
* `workspaceId` may be null across the hook's unconditional call in a
|
||||
* component that only invokes the returned closure once a workspace is
|
||||
* known, mirroring how MRTopbarButton already calls its hooks before its
|
||||
* own `!workspaceId` early return.
|
||||
*/
|
||||
export function useUnlinkTaskMR(
|
||||
workspaceId: string | null,
|
||||
): (associationId: string) => Promise<void> {
|
||||
const { t } = useTranslation();
|
||||
const removeTaskMR = useAppStore((state) => state.removeTaskMR);
|
||||
const { toast } = useToast();
|
||||
|
||||
return useCallback(
|
||||
async (associationId: string) => {
|
||||
if (!workspaceId) return;
|
||||
try {
|
||||
await deleteTaskMR(associationId, workspaceId);
|
||||
removeTaskMR(workspaceId, associationId);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("gitlab:failedToUnlinkMergeRequest"),
|
||||
description:
|
||||
error instanceof Error ? error.message : t("gitlab:theMergeRequestIsStillLinked"),
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[workspaceId, removeTaskMR, toast, t],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -331,5 +331,21 @@
|
||||
"mrTooltipState": "State: {{state}}",
|
||||
"mrTooltipReview": "Review: {{state}}",
|
||||
"mrTooltipPipeline": "Pipeline: {{state}}",
|
||||
"mrTooltipReadyToMerge": "Ready to merge"
|
||||
"mrTooltipReadyToMerge": "Ready to merge",
|
||||
"mrChipAriaLabelSingle": "Merge request !{{mriid}}, {{status}}",
|
||||
"mrChipAriaLabelMulti_one": "{{count}} merge request, {{status}}",
|
||||
"mrChipAriaLabelMulti_other": "{{count}} merge requests, {{status}}",
|
||||
"mrChipStatusMerged": "merged",
|
||||
"mrChipStatusClosed": "closed",
|
||||
"mrChipStatusFailed": "pipeline failed",
|
||||
"mrChipStatusDraft": "draft",
|
||||
"mrChipStatusReady": "ready to merge",
|
||||
"mrChipStatusAwaitingApproval": "awaiting approval",
|
||||
"mrChipStatusRunning": "pipeline running",
|
||||
"mrChipStatusNeutral": "no pipeline",
|
||||
"mrChipAutoFix": "Auto-fix",
|
||||
"mrChipAutoMerge": "Auto-merge",
|
||||
"mrChipDrawerTitle": "Merge request !{{mriid}}",
|
||||
"mrChipDrawerDescription": "Pipeline status, review status, and merge request actions.",
|
||||
"mrChipDrawerClose": "Close merge request status"
|
||||
}
|
||||
|
||||
@@ -331,5 +331,21 @@
|
||||
"mrTooltipState": "Śţàţē: {{state}}",
|
||||
"mrTooltipReview": "Ŕēvĩēŵ: {{state}}",
|
||||
"mrTooltipPipeline": "Ƥĩƥēĺĩńē: {{state}}",
|
||||
"mrTooltipReadyToMerge": "Ŕēàďŷ ţō ḿēŕĝē"
|
||||
"mrTooltipReadyToMerge": "Ŕēàďŷ ţō ḿēŕĝē",
|
||||
"mrChipAriaLabelSingle": "Ḿēŕĝē ŕēqũēśţ !{{mriid}}, {{status}}",
|
||||
"mrChipAriaLabelMulti_one": "{{count}} ḿēŕĝē ŕēqũēśţ, {{status}}",
|
||||
"mrChipAriaLabelMulti_other": "{{count}} ḿēŕĝē ŕēqũēśţś, {{status}}",
|
||||
"mrChipStatusMerged": "ḿēŕĝēď",
|
||||
"mrChipStatusClosed": "ćĺōśēď",
|
||||
"mrChipStatusFailed": "ƥĩƥēĺĩńē ƒàĩĺēď",
|
||||
"mrChipStatusDraft": "ďŕàƒţ",
|
||||
"mrChipStatusReady": "ŕēàďŷ ţō ḿēŕĝē",
|
||||
"mrChipStatusAwaitingApproval": "àŵàĩţĩńĝ àƥƥŕōvàĺ",
|
||||
"mrChipStatusRunning": "ƥĩƥēĺĩńē ŕũńńĩńĝ",
|
||||
"mrChipStatusNeutral": "ńō ƥĩƥēĺĩńē",
|
||||
"mrChipAutoFix": "Àũţō-ƒĩx",
|
||||
"mrChipAutoMerge": "Àũţō-ḿēŕĝē",
|
||||
"mrChipDrawerTitle": "Ḿēŕĝē ŕēqũēśţ !{{mriid}}",
|
||||
"mrChipDrawerDescription": "Ƥĩƥēĺĩńē śţàţũś, ŕēvĩēŵ śţàţũś, àńď ḿēŕĝē ŕēqũēśţ àćţĩōńś.",
|
||||
"mrChipDrawerClose": "Ćĺōśē ḿēŕĝē ŕēqũēśţ śţàţũś"
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ Per-workspace credentials and triage triggers for external services.
|
||||
| [provider-aware-review-automation](integrations/provider-aware-review-automation.md) | approved |
|
||||
| [github-authentication](integrations/github-authentication.md) | draft |
|
||||
| [gitlab-integration](gitlab-integration/spec.md) | shipped |
|
||||
| [gitlab-mr-status-chip](gitlab-mr-status-chip/spec.md) | draft |
|
||||
| [gitlab-workflow-sync](gitlab-workflow-sync/spec.md) | shipped |
|
||||
| [jira-status-filter](jira-status-filter/spec.md) | shipped |
|
||||
| [enable-disable-toggle](integrations/enable-disable-toggle.md) | shipped |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user