From 5452a4b9ce3e302353216c4ce7f69f18ea3f8427 Mon Sep 17 00:00:00 2001 From: nova28 Date: Wed, 12 Aug 2026 06:24:01 +0800 Subject: [PATCH] feat(gitlab): add MR status chip to chat and passthrough toolbars (#2537) Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> --- .../gitlab/mr-status-chip-drawer.tsx | 116 ++ .../gitlab/mr-status-chip-popover.tsx | 116 ++ .../gitlab/mr-status-chip-selection.test.ts | 157 ++ .../gitlab/mr-status-chip-selection.ts | 122 ++ .../gitlab/mr-status-chip-trigger.tsx | 175 +++ .../components/gitlab/mr-status-chip.test.tsx | 426 ++++++ apps/web/components/gitlab/mr-status-chip.tsx | 97 ++ .../gitlab/mr-status-colour-parity.test.tsx | 13 +- .../components/gitlab/mr-task-icon.test.ts | 266 +++- apps/web/components/gitlab/mr-task-icon.tsx | 128 +- .../gitlab/mr-topbar-button.hover.test.tsx | 1 + .../mr-topbar-button.layout-settle.test.tsx | 1 + .../gitlab/mr-topbar-button.test.ts | 1 + .../components/gitlab/mr-topbar-button.tsx | 23 +- .../task/chat/chat-input-area.test.tsx | 4 + .../components/task/chat/chat-input-area.tsx | 2 + .../task/passthrough-toolbar.test.tsx | 4 + .../components/task/passthrough-toolbar.tsx | 2 + apps/web/e2e/pages/session-page.ts | 44 + .../gitlab/mobile-mr-status-chip.spec.ts | 95 ++ .../e2e/tests/gitlab/mr-status-chip.spec.ts | 354 +++++ apps/web/eslint.i18n.options.mjs | 5 + .../hooks/domains/gitlab/use-task-mr.test.ts | 88 +- apps/web/hooks/domains/gitlab/use-task-mr.ts | 42 +- apps/web/src/locales/en/gitlab.json | 18 +- apps/web/src/locales/pseudo/gitlab.json | 18 +- docs/specs/INDEX.md | 1 + docs/specs/gitlab-mr-status-chip/spec.md | 1296 +++++++++++++++++ 28 files changed, 3565 insertions(+), 50 deletions(-) create mode 100644 apps/web/components/gitlab/mr-status-chip-drawer.tsx create mode 100644 apps/web/components/gitlab/mr-status-chip-popover.tsx create mode 100644 apps/web/components/gitlab/mr-status-chip-selection.test.ts create mode 100644 apps/web/components/gitlab/mr-status-chip-selection.ts create mode 100644 apps/web/components/gitlab/mr-status-chip-trigger.tsx create mode 100644 apps/web/components/gitlab/mr-status-chip.test.tsx create mode 100644 apps/web/components/gitlab/mr-status-chip.tsx create mode 100644 apps/web/e2e/tests/gitlab/mobile-mr-status-chip.spec.ts create mode 100644 apps/web/e2e/tests/gitlab/mr-status-chip.spec.ts create mode 100644 docs/specs/gitlab-mr-status-chip/spec.md diff --git a/apps/web/components/gitlab/mr-status-chip-drawer.tsx b/apps/web/components/gitlab/mr-status-chip-drawer.tsx new file mode 100644 index 0000000000..152b37f14f --- /dev/null +++ b/apps/web/components/gitlab/mr-status-chip-drawer.tsx @@ -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 ( + + {/* 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"). */} + + + + + + + {t("gitlab:mrChipDrawerTitle", { mriid: actedOnMR.mr_iid })} + + + {t("gitlab:mrChipDrawerDescription")} + + + + + +
+ {/* 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 && ( + onUnlink(actedOnMR.id)} + /> + )} +
+
+
+ ); +} diff --git a/apps/web/components/gitlab/mr-status-chip-popover.tsx b/apps/web/components/gitlab/mr-status-chip-popover.tsx new file mode 100644 index 0000000000..2405255d20 --- /dev/null +++ b/apps/web/components/gitlab/mr-status-chip-popover.tsx @@ -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 ( + + + + + 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 && ( + onUnlink(actedOnMR.id)} + /> + )} + + + ); +} diff --git a/apps/web/components/gitlab/mr-status-chip-selection.test.ts b/apps/web/components/gitlab/mr-status-chip-selection.test.ts new file mode 100644 index 0000000000..deb654bfd4 --- /dev/null +++ b/apps/web/components/gitlab/mr-status-chip-selection.test.ts @@ -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 { + 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 { + 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 { + 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 }); + }); +}); diff --git a/apps/web/components/gitlab/mr-status-chip-selection.ts b/apps/web/components/gitlab/mr-status-chip-selection.ts new file mode 100644 index 0000000000..8c85cd639f --- /dev/null +++ b/apps/web/components/gitlab/mr-status-chip-selection.ts @@ -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(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(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 }; +} diff --git a/apps/web/components/gitlab/mr-status-chip-trigger.tsx b/apps/web/components/gitlab/mr-status-chip-trigger.tsx new file mode 100644 index 0000000000..e53eef56d4 --- /dev/null +++ b/apps/web/components/gitlab/mr-status-chip-trigger.tsx @@ -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 = { + 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 ( +