From 7365db7064ce6643241704e85ef982147032848e Mon Sep 17 00:00:00 2001 From: Alassane <79718216+yattdev@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:38:21 -0400 Subject: [PATCH] feat(web): add rich task and GitLab MR hover previews (#2610) Co-authored-by: ayattara Co-authored-by: Carlos Florencio --- apps/web/app/tasks/rich-task-list-row.tsx | 9 +- .../tasks-page-client.mr-hydration.test.tsx | 16 +- apps/web/app/tasks/tasks-page-client.tsx | 7 +- .../gitlab/mr-task-icon.render.test.tsx | 119 +++++- .../components/gitlab/mr-task-icon.test.ts | 61 ++- apps/web/components/gitlab/mr-task-icon.tsx | 82 ++-- .../gitlab/mr-task-status-summary.test.ts | 159 ++++++++ .../gitlab/mr-task-status-summary.tsx | 142 +++++++ .../change-request-task-status-summary.tsx | 78 +++- .../components/kanban-card-content.test.tsx | 20 +- apps/web/components/kanban-card-content.tsx | 11 +- apps/web/components/kanban-card-title.tsx | 22 + .../task/file-context-menu.test.tsx | 143 ++++++- .../web/components/task/file-context-menu.tsx | 27 +- apps/web/components/task/task-item.tsx | 18 +- .../components/task/task-session-sidebar.tsx | 2 + apps/web/components/task/task-subtask-row.tsx | 67 ++++ .../task/task-title-hover-card.test.tsx | 377 ++++++++++++++++++ .../components/task/task-title-hover-card.tsx | 163 ++++++++ .../gitlab/mr-badge-sidebar-and-list.spec.ts | 178 +++++++++ .../tests/gitlab/mr-task-card-badge.spec.ts | 81 +++- .../mobile-task-title-hover-subtasks.spec.ts | 54 +++ .../kanban/task-title-hover-subtasks.spec.ts | 140 +++++++ .../saved-layout-session-isolation.spec.ts | 5 +- apps/web/e2e/tests/pr/pr-status-badge.spec.ts | 3 + .../tests/task/add-workspace-sources.spec.ts | 20 +- .../e2e/tests/task/file-tree-rename.spec.ts | 8 +- .../task/task-title-hover-surfaces.spec.ts | 178 +++++++++ apps/web/eslint.i18n.options.mjs | 4 + .../hooks/domains/gitlab/use-task-mr.test.ts | 86 ++++ apps/web/hooks/domains/gitlab/use-task-mr.ts | 32 +- .../domains/kanban/use-task-subtasks.test.tsx | 139 +++++++ .../hooks/domains/kanban/use-task-subtasks.ts | 59 +++ apps/web/src/locales/en/gitlab.json | 22 +- apps/web/src/locales/en/task.json | 2 + apps/web/src/locales/pseudo/gitlab.json | 22 +- apps/web/src/locales/pseudo/task.json | 2 + apps/web/src/locales/pt-pt/gitlab.json | 22 +- apps/web/src/locales/pt-pt/task.json | 2 + apps/web/src/locales/zh-cn/gitlab.json | 22 +- apps/web/src/locales/zh-cn/task.json | 2 + apps/web/src/locales/zh-hk/gitlab.json | 22 +- apps/web/src/locales/zh-hk/task.json | 2 + apps/web/src/locales/zh-tw/gitlab.json | 22 +- apps/web/src/locales/zh-tw/task.json | 2 + docs/plans/gitlab-mr-task-list-badges/plan.md | 4 + docs/plans/rich-task-title-previews/plan.md | 71 ++++ .../task-01-preview-and-hierarchy.md | 21 + .../task-02-contribution-status-and-state.md | 21 + .../task-03-end-to-end-verification.md | 21 + docs/specs/INDEX.md | 1 + docs/specs/gitlab-mr-task-list-badges/spec.md | 5 + docs/specs/tasks/rich-task-title-previews.md | 67 ++++ 53 files changed, 2697 insertions(+), 168 deletions(-) create mode 100644 apps/web/components/gitlab/mr-task-status-summary.test.ts create mode 100644 apps/web/components/gitlab/mr-task-status-summary.tsx create mode 100644 apps/web/components/kanban-card-title.tsx create mode 100644 apps/web/components/task/task-subtask-row.tsx create mode 100644 apps/web/components/task/task-title-hover-card.test.tsx create mode 100644 apps/web/components/task/task-title-hover-card.tsx create mode 100644 apps/web/e2e/tests/gitlab/mr-badge-sidebar-and-list.spec.ts create mode 100644 apps/web/e2e/tests/kanban/mobile-task-title-hover-subtasks.spec.ts create mode 100644 apps/web/e2e/tests/kanban/task-title-hover-subtasks.spec.ts create mode 100644 apps/web/e2e/tests/task/task-title-hover-surfaces.spec.ts create mode 100644 apps/web/hooks/domains/kanban/use-task-subtasks.test.tsx create mode 100644 apps/web/hooks/domains/kanban/use-task-subtasks.ts create mode 100644 docs/plans/rich-task-title-previews/plan.md create mode 100644 docs/plans/rich-task-title-previews/task-01-preview-and-hierarchy.md create mode 100644 docs/plans/rich-task-title-previews/task-02-contribution-status-and-state.md create mode 100644 docs/plans/rich-task-title-previews/task-03-end-to-end-verification.md create mode 100644 docs/specs/tasks/rich-task-title-previews.md diff --git a/apps/web/app/tasks/rich-task-list-row.tsx b/apps/web/app/tasks/rich-task-list-row.tsx index 1a047eaec2..15139cc72c 100644 --- a/apps/web/app/tasks/rich-task-list-row.tsx +++ b/apps/web/app/tasks/rich-task-list-row.tsx @@ -5,6 +5,7 @@ import { Badge } from "@kandev/ui/badge"; import { PRTaskIcon } from "@/components/github/pr-task-icon"; import { RegisteredChangeRequestTaskIcon } from "@/components/integrations/registered-change-request-task-icon"; import { MRTaskIcon } from "@/components/gitlab/mr-task-icon"; +import { TaskTitleHoverCard } from "@/components/task/task-title-hover-card"; import { useTaskPendingInput, type PendingInput } from "@/hooks/use-task-pending-input"; import { getTaskStateIcon } from "@/lib/ui/state-icons"; import type { Repository, Task } from "@/lib/types/http"; @@ -43,9 +44,11 @@ function PrimaryTaskLine({ hasPendingPermission: pendingInput.permission, interrupted: task.interrupted, })} - - {task.title} - + + + {task.title} + + {showContributions && ( { - it("calls useWorkspaceMRs with the workspace id when task details are off", () => { +describe("TasksPageClient change-request hydration (AC12)", () => { + it("hydrates PRs and MRs with the workspace id when task details are off", () => { renderPage(false); expect(useWorkspaceMRsMock).toHaveBeenCalledWith("ws1"); - // The gated GitHub call is unaffected: contrast case proving the two - // hooks are wired differently on purpose. Exact-call-record equality - // (not toHaveBeenCalledWith, a presence check) so the assertion fails - // if a regression also calls the hook ungated. - expect(useWorkspacePRsMock.mock.calls).toEqual([[null]]); + expect(useWorkspacePRsMock).toHaveBeenCalledWith("ws1"); }); it("calls useWorkspaceMRs with the workspace id when task details are on", () => { diff --git a/apps/web/app/tasks/tasks-page-client.tsx b/apps/web/app/tasks/tasks-page-client.tsx index 6eb3f2c1c8..3907b8496d 100644 --- a/apps/web/app/tasks/tasks-page-client.tsx +++ b/apps/web/app/tasks/tasks-page-client.tsx @@ -527,10 +527,9 @@ export function TasksPageClient(props: TasksPageClientProps) { state.kanban.workflowId === s.activeWorkflowId ? state.kanban.steps : EMPTY_WORKFLOW_STEPS, ); useWorkflowSnapshot(s.activeWorkflowId); - useWorkspacePRs(showTaskDetails ? s.activeWorkspaceId : null); - // Unconditional, unlike useWorkspacePRs above: useWorkspaceMRs(null) clears - // every workspace's cached MRs, not just this page's, and the sidebar (also - // mounted here) reads that same cache — see spec "Hydration ownership". + // Task-title previews render PR/MR glyphs independently of the list-details + // preference, so both workspace caches must be hydrated here. + useWorkspacePRs(s.activeWorkspaceId); useWorkspaceMRs(s.activeWorkspaceId); useForegroundRefresh(() => s.fetchTasks(true), Boolean(s.activeWorkspaceId), s.activeWorkspaceId); const { handleSortChange, handleGroupChange } = useTasksListPreferenceSync({ diff --git a/apps/web/components/gitlab/mr-task-icon.render.test.tsx b/apps/web/components/gitlab/mr-task-icon.render.test.tsx index d7c6f8da8e..886670e596 100644 --- a/apps/web/components/gitlab/mr-task-icon.render.test.tsx +++ b/apps/web/components/gitlab/mr-task-icon.render.test.tsx @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; -import { StateProvider, useAppStore } from "@/components/state-provider"; +import { StateProvider } from "@/components/state-provider"; import type { TaskMR } from "@/lib/types/gitlab"; import { MRTaskIcon } from "./mr-task-icon"; @@ -39,19 +39,18 @@ function makeMR(overrides: Partial = {}): TaskMR { }; } -// Seeds the store and renders MRTaskIcon inside the same provider tree in one -// pass — each StateProvider mount owns an independent store instance, so a -// separate seed-then-render step would seed a store the render never reads. +// Seeds the store via StateProvider's initialState rather than a render-time +// setTaskMRs call, matching the pattern task-title-hover-card.test.tsx uses. function renderMRTaskIcon(mrs: TaskMR[]) { - function Fixture() { - const setTaskMRs = useAppStore((s) => s.setTaskMRs); - setTaskMRs("ws-1", { "task-1": mrs }); - return ; - } return render( - - + + , ); @@ -80,8 +79,98 @@ describe("MRTaskIcon", () => { renderMRTaskIcon([makeMR({ state: "merged" })]); const badge = screen.getByTestId(BADGE_TEST_ID); expect(badge.getAttribute("data-mr-state")).toBe("merged"); - // The tooltip text itself (getMRTooltip's "State: merged" output) is - // covered directly in mr-task-icon.test.ts — jsdom does not reliably - // open Radix Tooltip content on a non-focusable trigger span. + }); + + // AC1, AC7, AC24: the trigger is keyboard-focusable and accessible, and the + // rendered summary uses MR !{iid} plus labelled rows — no pipe-delimited + // string anywhere. + it("AC1/AC7: trigger has role=img, tabIndex=0, and a localized aria-label", () => { + renderMRTaskIcon([makeMR({ mr_iid: 42 })]); + const badge = screen.getByTestId(BADGE_TEST_ID); + expect(badge.getAttribute("role")).toBe("img"); + expect(badge.getAttribute("tabIndex")).toBe("0"); + expect(badge.getAttribute("aria-label")).toBe("Merge request !42 status"); + }); + + it("AC1/AC24: hovering the trigger opens the structured summary with MR !iid and the title", () => { + renderMRTaskIcon([ + makeMR({ + mr_iid: 7, + mr_title: "Fix the login flow", + approval_state: "approved", + pipeline_state: "success", + detailed_merge_status: "mergeable", + }), + ]); + const badge = screen.getByTestId(BADGE_TEST_ID); + fireEvent.pointerEnter(badge, { pointerType: "mouse" }); + + // Radix mounts the tooltip content into both a measurement copy and the + // visible portal, so scope to the first (portal) match rather than + // asserting a single element. + expect(screen.getAllByTestId("mr-task-status-summary").length).toBeGreaterThan(0); + expect(screen.getAllByTestId("mr-task-status-iid")[0].textContent).toBe("MR !7"); + expect(screen.getAllByTestId("mr-task-status-title")[0].textContent).toBe("Fix the login flow"); + expect(screen.getAllByTestId("mr-task-status-review")[0].textContent).toContain("Review"); + expect(screen.getAllByTestId("mr-task-status-review")[0].textContent).toContain("Approved"); + expect(screen.getAllByTestId("mr-task-status-ci")[0].textContent).toContain("CI"); + expect(screen.getAllByTestId("mr-task-status-merge")[0].textContent).toContain("Merge"); + expect(document.body.textContent).not.toContain("|"); + }); + + it("AC1/AC24: the multi-MR badge renders one summary entry per MR, separated", () => { + renderMRTaskIcon([ + makeMR({ id: "a", mr_iid: 11, mr_title: "First linked MR", pipeline_state: "success" }), + makeMR({ id: "b", mr_iid: 12, mr_title: "Second linked MR", pipeline_state: "failure" }), + ]); + fireEvent.pointerEnter(screen.getByTestId(BADGE_TEST_ID), { pointerType: "mouse" }); + + // Radix force-mounts a measurement copy alongside the visible portal, so + // scope to one summary rather than counting matches across the document. + const summary = screen.getAllByTestId("mr-task-status-summary")[0]; + const entries = summary.querySelectorAll('[data-testid="mr-task-status-entry"]'); + expect(entries).toHaveLength(2); + expect(entries[0].textContent).toContain("MR !11"); + expect(entries[0].textContent).toContain("First linked MR"); + expect(entries[0].textContent).toContain("Passed"); + expect(entries[1].textContent).toContain("MR !12"); + expect(entries[1].textContent).toContain("Second linked MR"); + expect(entries[1].textContent).toContain("Failed"); + // Only the trailing entries carry the divider. + expect(entries[0].className).not.toContain("border-t"); + expect(entries[1].className).toContain("border-t"); + }); + + it("AC7: the multi-MR badge reports ready-to-merge only when every open MR is ready", () => { + const ready = { + pipeline_state: "success" as const, + detailed_merge_status: "mergeable", + }; + renderMRTaskIcon([makeMR({ id: "a", ...ready }), makeMR({ id: "b", mr_iid: 2, ...ready })]); + expect(screen.getByTestId(BADGE_TEST_ID).getAttribute("data-mr-ready-to-merge")).toBe("true"); + + cleanup(); + renderMRTaskIcon([ + makeMR({ id: "a", ...ready }), + makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }), + ]); + expect(screen.getByTestId(BADGE_TEST_ID).getAttribute("data-mr-ready-to-merge")).toBe("false"); + }); + + it("malformed store value still bails to null instead of throwing", () => { + render( + + + + + , + ); + expect(screen.queryByTestId(BADGE_TEST_ID)).toBeNull(); }); }); diff --git a/apps/web/components/gitlab/mr-task-icon.test.ts b/apps/web/components/gitlab/mr-task-icon.test.ts index 2df4c39e89..50b437a592 100644 --- a/apps/web/components/gitlab/mr-task-icon.test.ts +++ b/apps/web/components/gitlab/mr-task-icon.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"; import { aggregateMRChipStatus, aggregateMRStatusColor, + areAllOpenMRsReadyToMerge, compareChipMR, getMRStatusColor, - getMRTooltip, isMRReadyToMerge, mrChipStatus, MR_CHIP_STATUS_RANK, @@ -95,37 +95,6 @@ describe("getMRStatusColor", () => { }); }); -// AC37: colour is never the only carrier of MR state — the tooltip text -// states it explicitly for any non-open MR. -describe("getMRTooltip", () => { - it("states the MR state for a non-open MR", () => { - expect(getMRTooltip(makeMR({ state: "merged" }))).toContain("State: merged"); - expect(getMRTooltip(makeMR({ state: "closed" }))).toContain("State: closed"); - }); - - it("omits the state line for an open MR", () => { - expect(getMRTooltip(makeMR({ state: "open" }))).not.toContain("State:"); - }); - - it("flags a ready-to-merge MR", () => { - expect( - getMRTooltip( - makeMR({ - approval_state: "approved", - pipeline_state: "success", - merge_status: "can_be_merged", - }), - ), - ).toContain("Ready to merge"); - }); - - it("flags a draft MR without claiming it's ready to merge", () => { - const tooltip = getMRTooltip(makeMR({ draft: true })); - expect(tooltip).toContain("Draft"); - expect(tooltip).not.toContain("Ready to merge"); - }); -}); - describe("isMRReadyToMerge", () => { it("is true only for open, non-draft MRs with a passing pipeline and a mergeable status", () => { expect( @@ -471,3 +440,31 @@ describe("compareChipMR", () => { expect(compareChipMR(a, a)).toBe(0); }); }); + +// AC7: data-mr-ready-to-merge on the multi-MR badge, mirroring github's +// areAllOpenPRsReadyToMerge. +describe("areAllOpenMRsReadyToMerge", () => { + const readyMR = makeMR({ + approval_state: "approved", + pipeline_state: "success", + merge_status: "can_be_merged", + }); + + it("is false when there are no open MRs", () => { + expect(areAllOpenMRsReadyToMerge([makeMR({ state: "merged" })])).toBe(false); + }); + + it("is true when every open MR is ready to merge", () => { + expect(areAllOpenMRsReadyToMerge([readyMR, { ...readyMR, id: "b", mr_iid: 2 }])).toBe(true); + }); + + it("is false when any open MR is not ready to merge", () => { + const notReady = makeMR({ id: "b", mr_iid: 2, pipeline_state: "pending" }); + expect(areAllOpenMRsReadyToMerge([readyMR, notReady])).toBe(false); + }); + + it("ignores terminal siblings when deciding readiness", () => { + const merged = makeMR({ id: "c", mr_iid: 3, state: "merged" }); + expect(areAllOpenMRsReadyToMerge([readyMR, merged])).toBe(true); + }); +}); diff --git a/apps/web/components/gitlab/mr-task-icon.tsx b/apps/web/components/gitlab/mr-task-icon.tsx index 362a358174..dd15e47c02 100644 --- a/apps/web/components/gitlab/mr-task-icon.tsx +++ b/apps/web/components/gitlab/mr-task-icon.tsx @@ -2,10 +2,12 @@ import { IconGitMerge } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; -import { t } from "@/lib/i18n"; +import { useChangeRequestTaskTooltipState } from "@/components/integrations/use-change-request-task-tooltip-state"; import { useTaskMRs } from "@/hooks/domains/gitlab/use-task-mr"; import type { TaskMR } from "@/lib/types/gitlab"; +import { deriveMRTaskStatusSummary, MRTaskStatusSummary } from "./mr-task-status-summary"; const MUTED_FOREGROUND = "text-muted-foreground"; const PURPLE_500 = "text-purple-500"; @@ -170,14 +172,14 @@ function mrMergeStatusReady(mr: TaskMR): boolean { return mr.merge_status === "can_be_merged"; } -export function getMRTooltip(mr: TaskMR): string { - const parts = [`!${mr.mr_iid}: ${mr.mr_title}`]; - if (mr.state !== "open") parts.push(t("gitlab:mrTooltipState", { state: mr.state })); - if (mr.approval_state) parts.push(t("gitlab:mrTooltipReview", { state: mr.approval_state })); - if (mr.pipeline_state) parts.push(t("gitlab:mrTooltipPipeline", { state: mr.pipeline_state })); - if (mr.draft) parts.push(t("gitlab:draft")); - else if (isMRReadyToMerge(mr)) parts.push(t("gitlab:mrTooltipReadyToMerge")); - return parts.join(" | "); +/** + * True when at least one MR is open AND every open MR is ready to merge. + * Terminal (merged/closed/locked) siblings are ignored so they can't drag the + * result to false. Mirrors github's areAllOpenPRsReadyToMerge. + */ +export function areAllOpenMRsReadyToMerge(mrs: TaskMR[]): boolean { + const openMRs = mrs.filter((mr) => mr.state === "open"); + return openMRs.length > 0 && openMRs.every(isMRReadyToMerge); } /** @@ -226,51 +228,73 @@ export function MRTaskIcon({ taskId }: { taskId: string }) { } function SingleMRIcon({ taskId, mr }: { taskId: string; mr: TaskMR }) { + const { t } = useTranslation(); + const tooltip = useChangeRequestTaskTooltipState(); + const readyToMerge = isMRReadyToMerge(mr); + const summary = deriveMRTaskStatusSummary(mr, readyToMerge); return ( - + - + - {/* MR title/state come from the linked GitLab MR itself, not - translatable UI copy — mirrors github's untranslated getPRTooltip. */} - {getMRTooltip(mr)} + + + ); } function MultiMRIcon({ taskId, mrs }: { taskId: string; mrs: TaskMR[] }) { + const { t } = useTranslation(); + const tooltip = useChangeRequestTaskTooltipState(); const aggregateColor = aggregateMRStatusColor(mrs); + const allReady = areAllOpenMRsReadyToMerge(mrs); + const summaries = mrs.map((mr) => deriveMRTaskStatusSummary(mr, isMRReadyToMerge(mr))); return ( - + - - {mrs.length} + - -
- {mrs.map((mr) => ( -
- - - - {getMRTooltip(mr)} -
- ))} -
+ +
); diff --git a/apps/web/components/gitlab/mr-task-status-summary.test.ts b/apps/web/components/gitlab/mr-task-status-summary.test.ts new file mode 100644 index 0000000000..ebf335ead1 --- /dev/null +++ b/apps/web/components/gitlab/mr-task-status-summary.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import type { TaskMR } from "@/lib/types/gitlab"; +import { deriveMRTaskStatusSummary } from "./mr-task-status-summary"; + +function makeMR(overrides: Partial = {}): TaskMR { + return { + id: "id", + task_id: "task-1", + host: "https://gitlab.com", + project_path: "group/project", + mr_iid: 12, + mr_url: "", + mr_title: "Fix the thing", + 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, + }; +} + +describe("deriveMRTaskStatusSummary", () => { + it("derives review, CI, and merge rows for an open, approved, mergeable MR", () => { + const mr = makeMR({ + approval_state: "approved", + pipeline_state: "success", + detailed_merge_status: "mergeable", + }); + + const summary = deriveMRTaskStatusSummary(mr, true); + + expect(summary).toEqual({ + iid: 12, + title: "Fix the thing", + rows: [ + { kind: "review", status: "approved", tone: "success" }, + { kind: "ci", status: "passed", tone: "success" }, + { kind: "merge", status: "ready", tone: "success" }, + ], + }); + }); + + it("renders CI failed in the danger tone", () => { + const summary = deriveMRTaskStatusSummary(makeMR({ pipeline_state: "failure" }), false); + expect(summary.rows).toContainEqual({ kind: "ci", status: "failed", tone: "danger" }); + }); + + it("renders merge conflicts in the danger tone from detailed_merge_status", () => { + const summary = deriveMRTaskStatusSummary( + makeMR({ detailed_merge_status: "cannot_be_merged" }), + false, + ); + expect(summary.rows).toContainEqual({ kind: "merge", status: "conflicts", tone: "danger" }); + }); + + it("renders merge conflicts from the legacy merge_status when detailed_merge_status is absent", () => { + const summary = deriveMRTaskStatusSummary(makeMR({ merge_status: "cannot_be_merged" }), false); + expect(summary.rows).toContainEqual({ kind: "merge", status: "conflicts", tone: "danger" }); + }); + + it("renders a draft MR with the draft merge row", () => { + const summary = deriveMRTaskStatusSummary(makeMR({ draft: true }), false); + expect(summary.rows).toEqual([{ kind: "merge", status: "draft", tone: "muted" }]); + }); + + it("renders a State row and omits the Merge row for a merged MR", () => { + const summary = deriveMRTaskStatusSummary( + makeMR({ + state: "merged", + pipeline_state: "success", + detailed_merge_status: "mergeable", + }), + false, + ); + expect(summary.rows).toEqual([ + { kind: "state", status: "merged", tone: "merged" }, + { kind: "ci", status: "passed", tone: "success" }, + ]); + }); + + it("renders a Closed state row for a closed MR", () => { + const summary = deriveMRTaskStatusSummary(makeMR({ state: "closed" }), false); + expect(summary.rows).toEqual([{ kind: "state", status: "closed", tone: "danger" }]); + }); + + it("renders a Closed state row for a locked MR", () => { + const summary = deriveMRTaskStatusSummary(makeMR({ state: "locked" }), false); + expect(summary.rows).toEqual([{ kind: "state", status: "closed", tone: "danger" }]); + }); +}); + +describe("deriveMRTaskStatusSummary — empty and unrecognized values", () => { + it("renders no rows for an MR with every optional field empty", () => { + const summary = deriveMRTaskStatusSummary(makeMR(), false); + expect(summary.rows).toEqual([]); + expect(summary.iid).toBe(12); + expect(summary.title).toBe("Fix the thing"); + }); + + it("preserves an unrecognized pipeline_state verbatim in a raw row", () => { + const summary = deriveMRTaskStatusSummary( + makeMR({ pipeline_state: "canceled" as unknown as TaskMR["pipeline_state"] }), + false, + ); + expect(summary.rows).toContainEqual({ + kind: "ci", + status: "raw", + tone: "muted", + rawValue: "canceled", + }); + }); + + it("preserves an unrecognized detailed_merge_status verbatim in a raw row", () => { + const summary = deriveMRTaskStatusSummary( + makeMR({ detailed_merge_status: "policies_denied" }), + false, + ); + expect(summary.rows).toContainEqual({ + kind: "merge", + status: "raw", + tone: "muted", + rawValue: "policies_denied", + }); + }); + + it("omits the merge row entirely for checking/unchecked/ci_still_running (the CI row already says it)", () => { + for (const status of ["checking", "unchecked", "ci_still_running"]) { + const summary = deriveMRTaskStatusSummary(makeMR({ detailed_merge_status: status }), false); + expect(summary.rows.find((row) => row.kind === "merge")).toBeUndefined(); + } + }); + + it("renders an awaiting-approval review row in the info tone", () => { + const summary = deriveMRTaskStatusSummary(makeMR({ approval_state: "pending" }), false); + expect(summary.rows).toContainEqual({ + kind: "review", + status: "awaiting_approval", + tone: "info", + }); + }); + + it("does not render a merge row for a non-open MR even when readyToMerge is true", () => { + const summary = deriveMRTaskStatusSummary(makeMR({ state: "closed" }), true); + expect(summary.rows.find((row) => row.kind === "merge")).toBeUndefined(); + }); +}); diff --git a/apps/web/components/gitlab/mr-task-status-summary.tsx b/apps/web/components/gitlab/mr-task-status-summary.tsx new file mode 100644 index 0000000000..c93f803580 --- /dev/null +++ b/apps/web/components/gitlab/mr-task-status-summary.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { IconGitMerge } from "@tabler/icons-react"; +import type { TaskMR } from "@/lib/types/gitlab"; +import { + ChangeRequestTaskStatusSummary, + type ChangeRequestTaskStatusPresentation, + type ChangeRequestTaskSummaryRow, + type ChangeRequestTaskSummaryRowKind, + type ChangeRequestTaskSummaryStatus, + type ChangeRequestTaskSummaryTone, +} from "@/components/integrations/change-request-task-status-summary"; + +export type MRTaskSummaryRowKind = ChangeRequestTaskSummaryRowKind; +export type MRTaskSummaryTone = ChangeRequestTaskSummaryTone; +export type MRTaskSummaryStatus = ChangeRequestTaskSummaryStatus; +export type MRTaskSummaryRow = ChangeRequestTaskSummaryRow; + +export type MRTaskStatusSummaryData = { + iid: number; + title: string; + rows: MRTaskSummaryRow[]; +}; + +function rawRow(kind: MRTaskSummaryRowKind, rawValue: string): MRTaskSummaryRow { + return { kind, status: "raw", tone: "muted", rawValue }; +} + +function deriveStateRow(state: string): MRTaskSummaryRow | null { + if (!state || state === "open") return null; + if (state === "merged") return { kind: "state", status: "merged", tone: "merged" }; + if (state === "closed" || state === "locked") { + return { kind: "state", status: "closed", tone: "danger" }; + } + return rawRow("state", state); +} + +function deriveReviewRow(approvalState: string): MRTaskSummaryRow | null { + if (!approvalState) return null; + if (approvalState === "approved") { + return { kind: "review", status: "approved", tone: "success" }; + } + if (approvalState === "pending") { + return { kind: "review", status: "awaiting_approval", tone: "info" }; + } + return rawRow("review", approvalState); +} + +function deriveCIRow(pipelineState: string): MRTaskSummaryRow | null { + if (!pipelineState) return null; + if (pipelineState === "success") return { kind: "ci", status: "passed", tone: "success" }; + if (pipelineState === "failure") return { kind: "ci", status: "failed", tone: "danger" }; + if (pipelineState === "pending") return { kind: "ci", status: "in_progress", tone: "warning" }; + return rawRow("ci", pipelineState); +} + +// checking/unchecked/ci_still_running produce no merge row: the CI row above +// already communicates the wait, so a second "still checking" line would be +// redundant noise. +const MERGE_STATUS_NO_ROW = new Set(["checking", "unchecked", "ci_still_running"]); + +function deriveMergeStatusRow(mergeStatus: string): MRTaskSummaryRow | null { + if (!mergeStatus || MERGE_STATUS_NO_ROW.has(mergeStatus)) return null; + if (mergeStatus === "mergeable" || mergeStatus === "can_be_merged") { + return { kind: "merge", status: "mergeable", tone: "muted" }; + } + if (mergeStatus === "broken_status" || mergeStatus === "cannot_be_merged") { + return { kind: "merge", status: "conflicts", tone: "danger" }; + } + if (mergeStatus === "discussions_not_resolved") { + return { kind: "merge", status: "unresolved_discussions", tone: "warning" }; + } + if (mergeStatus === "not_approved") { + return { kind: "merge", status: "awaiting_approval", tone: "muted" }; + } + return rawRow("merge", mergeStatus); +} + +function deriveMergeRow(mr: TaskMR, readyToMerge: boolean): MRTaskSummaryRow | null { + if (mr.state !== "open") return null; + if (mr.draft) return { kind: "merge", status: "draft", tone: "muted" }; + if (readyToMerge) return { kind: "merge", status: "ready", tone: "success" }; + return deriveMergeStatusRow(mr.detailed_merge_status || mr.merge_status); +} + +export function deriveMRTaskStatusSummary( + mr: TaskMR, + readyToMerge: boolean, +): MRTaskStatusSummaryData { + const rows = [ + deriveStateRow(mr.state), + deriveReviewRow(mr.approval_state), + deriveCIRow(mr.pipeline_state), + deriveMergeRow(mr, readyToMerge), + ].filter((row): row is MRTaskSummaryRow => row !== null); + + return { iid: mr.mr_iid, title: mr.mr_title, rows }; +} + +const MR_PRESENTATION: ChangeRequestTaskStatusPresentation = { + summaryTestId: "mr-task-status-summary", + entryTestId: "mr-task-status-entry", + identifierTestId: "mr-task-status-iid", + titleTestId: "mr-task-status-title", + rowTestIdPrefix: "mr-task-status", + icon: IconGitMerge, + entryAriaLabelKey: "gitlab:mergeRequestStatus", + identifierLabelKey: "gitlab:mrTaskStatusIid", + rowLabelKeys: { + state: "gitlab:mrTaskStatusState", + review: "gitlab:mrTaskStatusReview", + ci: "gitlab:mrTaskStatusCi", + merge: "gitlab:mrTaskStatusMerge", + }, + statusLabelKeys: { + merged: "gitlab:merged", + closed: "gitlab:closed", + approved: "gitlab:approved", + awaiting_approval: "gitlab:awaitingApproval", + passed: "gitlab:passed", + failed: "gitlab:failed", + in_progress: "gitlab:inProgress", + draft: "gitlab:draft", + ready: "gitlab:readyToMerge", + mergeable: "gitlab:mergeable", + conflicts: "gitlab:conflicts", + unresolved_discussions: "gitlab:unresolvedDiscussions", + }, +}; + +export function MRTaskStatusSummary({ summaries }: { summaries: MRTaskStatusSummaryData[] }) { + return ( + ({ + number: summary.iid, + title: summary.title, + rows: summary.rows, + }))} + presentation={MR_PRESENTATION} + /> + ); +} diff --git a/apps/web/components/integrations/change-request-task-status-summary.tsx b/apps/web/components/integrations/change-request-task-status-summary.tsx index f9b884604c..16d78f1c90 100644 --- a/apps/web/components/integrations/change-request-task-status-summary.tsx +++ b/apps/web/components/integrations/change-request-task-status-summary.tsx @@ -29,6 +29,7 @@ export type ChangeRequestTaskSummaryStatus = | "approved" | "changes_requested" | "pending_review" + | "awaiting_approval" | "passed" | "failed" | "in_progress" @@ -37,6 +38,7 @@ export type ChangeRequestTaskSummaryStatus = | "conflicts" | "behind" | "blocked" + | "unresolved_discussions" | "mergeable" | "raw"; @@ -53,6 +55,19 @@ export type ChangeRequestTaskStatusSummaryData = { rows: ChangeRequestTaskSummaryRow[]; }; +export type ChangeRequestTaskStatusPresentation = { + summaryTestId: string; + entryTestId: string; + identifierTestId: string; + titleTestId: string; + rowTestIdPrefix: string; + icon: TablerIcon; + entryAriaLabelKey: string; + identifierLabelKey: string; + rowLabelKeys: Record; + statusLabelKeys?: Partial, string>>; +}; + const ROW_LABEL_KEYS: Record = { state: "github:prTaskStatusState", review: "github:prTaskStatusReview", @@ -75,6 +90,7 @@ const STATUS_LABEL_KEYS: Record, approved: "github:approved", changes_requested: "github:changesRequested", pending_review: "github:pendingReview", + awaiting_approval: "github:pendingReview", passed: "github:checkBucketPassed", failed: "github:checkBucketFailed", in_progress: "github:checkBucketInProgress", @@ -83,6 +99,7 @@ const STATUS_LABEL_KEYS: Record, conflicts: "github:conflicts", behind: "github:behindBase", blocked: "github:blocked", + unresolved_discussions: "github:blocked", mergeable: "github:mergeable", }; @@ -92,6 +109,7 @@ const STATUS_ICONS: Record = { approved: IconCheck, changes_requested: IconX, pending_review: IconClockHour4, + awaiting_approval: IconClockHour4, passed: IconCheck, failed: IconX, in_progress: IconClockHour4, @@ -100,13 +118,30 @@ const STATUS_ICONS: Record = { conflicts: IconAlertTriangle, behind: IconAlertTriangle, blocked: IconAlertTriangle, + unresolved_discussions: IconAlertTriangle, mergeable: IconGitMerge, raw: IconCircleDot, }; -function getStatusText(row: ChangeRequestTaskSummaryRow, t: TFunction): string { +const DEFAULT_PRESENTATION: ChangeRequestTaskStatusPresentation = { + summaryTestId: "pr-task-status-summary", + entryTestId: "pr-task-status-entry", + identifierTestId: "pr-task-status-number", + titleTestId: "pr-task-status-title", + rowTestIdPrefix: "pr-task-status", + icon: IconGitPullRequest, + entryAriaLabelKey: "github:pullRequestStatus", + identifierLabelKey: "github:prTaskStatusNumber", + rowLabelKeys: ROW_LABEL_KEYS, +}; + +function getStatusText( + row: ChangeRequestTaskSummaryRow, + presentation: ChangeRequestTaskStatusPresentation, + t: TFunction, +): string { if (row.status === "raw") return row.rawValue ?? ""; - return t(STATUS_LABEL_KEYS[row.status]); + return t(presentation.statusLabelKeys?.[row.status] ?? STATUS_LABEL_KEYS[row.status]); } function SummaryStatusIcon({ status }: { status: ChangeRequestTaskSummaryStatus }) { @@ -114,17 +149,23 @@ function SummaryStatusIcon({ status }: { status: ChangeRequestTaskSummaryStatus return