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:
nova28
2026-08-12 06:24:01 +08:00
committed by GitHub
parent 7822b21ef0
commit 5452a4b9ce
28 changed files with 3565 additions and 50 deletions
@@ -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);
});
});
+257 -9
View File
@@ -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);
});
});
+113 -15
View File
@@ -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 && (
+44
View File
@@ -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,
};
}
+5
View File
@@ -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]);
});
});
+40 -2
View File
@@ -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],
);
}
+17 -1
View File
@@ -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"
}
+17 -1
View File
@@ -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ũēśţ śţàţũś"
}
+1
View File
@@ -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