diff --git a/apps/web/components/gitlab/mr-topbar-button.test.ts b/apps/web/components/gitlab/mr-topbar-button.test.ts index 680d2190dd..ae604fc61b 100644 --- a/apps/web/components/gitlab/mr-topbar-button.test.ts +++ b/apps/web/components/gitlab/mr-topbar-button.test.ts @@ -1,8 +1,66 @@ +import { createElement } from "react"; +import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { mrTriggerClass, openDesktopMRReview, openMobileMRReview } from "./mr-topbar-button"; +import { + MRTopbarButton, + mrTriggerClass, + openDesktopMRReview, + openMobileMRReview, +} from "./mr-topbar-button"; import type { TaskMR } from "@/lib/types/gitlab"; +const gitlabMocks = vi.hoisted(() => ({ mrs: [] as TaskMR[] })); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + tasks: { activeTaskId: "task-1" }, + workspaces: { activeId: "workspace-1" }, + repositories: { itemsByWorkspaceId: { "workspace-1": [] } }, + removeTaskMR: vi.fn(), + }), +})); + +vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({ + useGitLabAvailable: () => true, + useTaskMRs: () => gitlabMocks.mrs, + useWorkspaceMRs: vi.fn(), +})); + +vi.mock("@/hooks/domains/kanban/use-task-by-id", () => ({ + useTaskById: () => ({ id: "task-1", repositories: [] }), +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +vi.mock("./task-mr-link-dialog", () => ({ TaskMRLinkDialog: () => null })); + describe("mrTriggerClass", () => { + it("does not render a generic link control for an unlinked task", () => { + gitlabMocks.mrs = []; + render(createElement(MRTopbarButton)); + + expect(screen.queryByRole("button", { name: "Link GitLab merge request" })).toBeNull(); + }); + + it("keeps the linked merge request status control", () => { + gitlabMocks.mrs = [ + { + id: "association-1", + mr_iid: 81, + state: "opened", + project_path: "group/project", + mr_url: "https://gitlab.example/group/project/-/merge_requests/81", + } as TaskMR, + ]; + render(createElement(MRTopbarButton)); + + const trigger = screen.getByTestId("mr-topbar-button"); + expect(trigger.getAttribute("data-mr-iid")).toBe("81"); + }); + it("uses a 44px trigger on mobile", () => { expect(mrTriggerClass(true, true)).toContain("h-11"); expect(mrTriggerClass(true, true)).toContain("w-11"); diff --git a/apps/web/components/gitlab/mr-topbar-button.tsx b/apps/web/components/gitlab/mr-topbar-button.tsx index 4a7432ba94..4c2d5e4a5c 100644 --- a/apps/web/components/gitlab/mr-topbar-button.tsx +++ b/apps/web/components/gitlab/mr-topbar-button.tsx @@ -8,7 +8,6 @@ import { IconClock, IconExternalLink, IconGitMerge, - IconLink, IconPlus, IconUnlink, IconX, @@ -241,19 +240,7 @@ function MRTopbarControl({ /> ); } - if (!gitlabAvailable) return null; - return ( - - ); + return null; } export const MRTopbarButton = memo(function MRTopbarButton({ diff --git a/apps/web/components/gitlab/task-mr-link-dialog.test.tsx b/apps/web/components/gitlab/task-mr-link-dialog.test.tsx index e588ab333a..b33a5e29fe 100644 --- a/apps/web/components/gitlab/task-mr-link-dialog.test.tsx +++ b/apps/web/components/gitlab/task-mr-link-dialog.test.tsx @@ -1,9 +1,29 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Repository } from "@/lib/types/http"; import { TaskMRLinkDialog } from "./task-mr-link-dialog"; const appState = { setTaskMR: vi.fn() }; +const createTaskMR = vi.hoisted(() => vi.fn().mockResolvedValue({})); +const repositories = [ + { + id: "repository-1", + name: "kandev", + provider_owner: "platform", + provider_name: "kandev", + }, + { + id: "repository-2", + name: "docs", + provider_owner: "platform", + provider_name: "docs", + }, +] as Repository[]; +const taskRepositories = repositories.map((repository) => ({ repository_id: repository.id })); +const mrURL = "https://gitlab.example.test/platform/kandev/-/merge_requests/81"; +const mergeRequestURLLabel = "Merge request URL"; +const linkMergeRequestButton = "Link merge request"; +const workspaceId = "workspace-1"; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: typeof appState) => unknown) => selector(appState), @@ -13,52 +33,127 @@ vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: vi.fn() }), })); -vi.mock("@/lib/api/domains/gitlab-api", () => ({ - createTaskMR: vi.fn(), -})); +vi.mock("@/lib/api/domains/gitlab-api", () => ({ createTaskMR })); -afterEach(cleanup); +function dialog({ + open = true, + repositoryOptions = repositories, + taskRepositoryLinks = taskRepositories, +}: { + open?: boolean; + repositoryOptions?: Repository[]; + taskRepositoryLinks?: { repository_id: string }[]; +} = {}) { + return ( + + ); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); describe("TaskMRLinkDialog", () => { - it("preserves the typed URL when repository props refresh while open", () => { - const repository = { - id: "repository-1", - name: "kandev", - provider_owner: "platform", - provider_name: "kandev", - } as Repository; - const props = { - open: true, - onOpenChange: vi.fn(), - taskId: "task-1", - workspaceId: "workspace-1", - }; - const { rerender } = render( - , - ); + it("submits the default repository when initially mounted open", async () => { + render(dialog()); - const url = screen.getByLabelText("Merge request URL"); - fireEvent.change(url, { - target: { value: "https://gitlab.example.test/platform/kandev/-/merge_requests/81" }, + fireEvent.change(screen.getByLabelText(mergeRequestURLLabel), { + target: { value: mrURL }, }); + fireEvent.click(screen.getByRole("button", { name: linkMergeRequestButton })); + await waitFor(() => expect(createTaskMR).toHaveBeenCalledTimes(1)); + expect(createTaskMR).toHaveBeenCalledWith( + { + task_id: "task-1", + repository_id: "repository-1", + mr_url: mrURL, + }, + workspaceId, + ); + }); + + it("uses the default repository when repositories hydrate after opening", async () => { + const { rerender } = render(dialog({ repositoryOptions: [], taskRepositoryLinks: [] })); + + rerender(dialog()); + fireEvent.change(screen.getByLabelText(mergeRequestURLLabel), { + target: { value: mrURL }, + }); + fireEvent.click(screen.getByRole("button", { name: linkMergeRequestButton })); + + await waitFor(() => expect(createTaskMR).toHaveBeenCalledTimes(1)); + expect(createTaskMR).toHaveBeenCalledWith( + expect.objectContaining({ repository_id: "repository-1" }), + workspaceId, + ); + }); + + it("preserves the typed URL and repository selection when props refresh while open", async () => { + const { rerender } = render(dialog()); + + const url = screen.getByLabelText(mergeRequestURLLabel); + fireEvent.change(url, { + target: { value: mrURL }, + }); + fireEvent.click(screen.getByRole("combobox", { name: "Task repository" })); + fireEvent.click(await screen.findByRole("option", { name: "platform/docs" })); + + const newlyHydratedRepository = { + id: "repository-3", + name: "api", + provider_owner: "platform", + provider_name: "api", + } as Repository; rerender( - , + dialog({ + repositoryOptions: [newlyHydratedRepository, ...repositories], + taskRepositoryLinks: [{ repository_id: newlyHydratedRepository.id }, ...taskRepositories], + }), ); - expect((url as HTMLInputElement).value).toBe( - "https://gitlab.example.test/platform/kandev/-/merge_requests/81", + expect((url as HTMLInputElement).value).toBe(mrURL); + fireEvent.click(screen.getByRole("button", { name: linkMergeRequestButton })); + + await waitFor(() => expect(createTaskMR).toHaveBeenCalledTimes(1)); + expect(createTaskMR).toHaveBeenCalledWith( + expect.objectContaining({ + repository_id: "repository-2", + mr_url: mrURL, + }), + workspaceId, + ); + }); + + it("resets the URL and selection to the current default after closing", async () => { + const { rerender } = render(dialog()); + + fireEvent.change(screen.getByLabelText(mergeRequestURLLabel), { + target: { value: mrURL }, + }); + const reorderedLinks = [taskRepositories[1], taskRepositories[0]]; + rerender(dialog({ open: false, taskRepositoryLinks: reorderedLinks })); + rerender(dialog({ taskRepositoryLinks: reorderedLinks })); + + const url = screen.getByLabelText(mergeRequestURLLabel) as HTMLInputElement; + expect(url.value).toBe(""); + fireEvent.change(url, { + target: { value: "https://gitlab.example.test/platform/docs/-/merge_requests/12" }, + }); + fireEvent.click(screen.getByRole("button", { name: linkMergeRequestButton })); + + await waitFor(() => expect(createTaskMR).toHaveBeenCalledTimes(1)); + expect(createTaskMR).toHaveBeenCalledWith( + expect.objectContaining({ repository_id: "repository-2" }), + workspaceId, ); - expect( - (screen.getByRole("button", { name: "Link merge request" }) as HTMLButtonElement).disabled, - ).toBe(false); }); }); diff --git a/apps/web/components/gitlab/task-mr-link-dialog.tsx b/apps/web/components/gitlab/task-mr-link-dialog.tsx index a3930dbf0d..ad9a9b23a5 100644 --- a/apps/web/components/gitlab/task-mr-link-dialog.tsx +++ b/apps/web/components/gitlab/task-mr-link-dialog.tsx @@ -116,13 +116,19 @@ export function TaskMRLinkDialog({ taskRepositories: TaskRepositoryLink[]; repositories: Repository[]; }) { + const options = useRepositoryOptions(repositories, taskRepositories); + const defaultRepositoryID = options[0]?.id ?? NO_REPOSITORY; const [mrURL, setMRURL] = useState(""); - const [repositoryID, setRepositoryID] = useState(NO_REPOSITORY); + const [repositoryID, setRepositoryID] = useState(defaultRepositoryID); const [submitting, setSubmitting] = useState(false); const setTaskMR = useAppStore((state) => state.setTaskMR); const { toast } = useToast(); - const options = useRepositoryOptions(repositories, taskRepositories); - const defaultRepositoryID = options[0]?.id ?? NO_REPOSITORY; + + useEffect(() => { + if (repositoryID === NO_REPOSITORY && defaultRepositoryID !== NO_REPOSITORY) { + setRepositoryID(defaultRepositoryID); + } + }, [defaultRepositoryID, repositoryID]); useEffect(() => { if (open) return; diff --git a/apps/web/components/kanban-card-menu-items.test.tsx b/apps/web/components/kanban-card-menu-items.test.tsx index c62e5591ed..9c978bacac 100644 --- a/apps/web/components/kanban-card-menu-items.test.tsx +++ b/apps/web/components/kanban-card-menu-items.test.tsx @@ -98,6 +98,7 @@ describe("buildKanbanCardMenuEntries — external issue links", () => { stepsByWorkflowId: {}, onLinkPullRequest: vi.fn(), onLinkIssue: vi.fn(), + onLinkMergeRequest: vi.fn(), onLinkJiraTicket: vi.fn(), onLinkLinearIssue: vi.fn(), onLinkSentryIssue: vi.fn(), @@ -109,6 +110,7 @@ describe("buildKanbanCardMenuEntries — external issue links", () => { expect(itemLabels(linkMenu)).toEqual([ "GitHub Pull Request", "GitHub Issue", + "GitLab Merge Request", "Jira Ticket", "Linear Issue", "Sentry Issue", diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx index 058316904e..4fe3bcf35c 100644 --- a/apps/web/components/kanban-card-menu-items.tsx +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -4,6 +4,7 @@ import { useMemo, type ReactNode } from "react"; import { IconArchive, IconArrowRight, + IconBrandGitlab, IconBrandSentry, IconCircleDot, IconGitPullRequest, @@ -89,6 +90,7 @@ type BuildKanbanCardMenuEntriesArgs = { onDetach?: () => void; onLinkPullRequest?: () => void; onLinkIssue?: () => void; + onLinkMergeRequest?: () => void; onLinkJiraTicket?: () => void; onLinkLinearIssue?: () => void; onLinkSentryIssue?: () => void; @@ -227,10 +229,30 @@ function buildSendToWorkflowSubmenu({ }; } +function buildGitLabMergeRequestLinkEntry({ + disabled, + onLinkMergeRequest, +}: { + disabled?: boolean; + onLinkMergeRequest?: () => void; +}): KanbanCardMenuEntry | null { + if (!onLinkMergeRequest) return null; + return { + kind: "item", + key: "link-gitlab-merge-request", + testId: "task-context-link-gitlab-merge-request", + icon: , + label: "GitLab Merge Request", + disabled, + onSelect: onLinkMergeRequest, + }; +} + function buildLinkSubmenu({ disabled, onLinkPullRequest, onLinkIssue, + onLinkMergeRequest, onLinkJiraTicket, onLinkLinearIssue, onLinkSentryIssue, @@ -238,6 +260,7 @@ function buildLinkSubmenu({ disabled?: boolean; onLinkPullRequest?: () => void; onLinkIssue?: () => void; + onLinkMergeRequest?: () => void; onLinkJiraTicket?: () => void; onLinkLinearIssue?: () => void; onLinkSentryIssue?: () => void; @@ -245,6 +268,7 @@ function buildLinkSubmenu({ if ( !onLinkPullRequest && !onLinkIssue && + !onLinkMergeRequest && !onLinkJiraTicket && !onLinkLinearIssue && !onLinkSentryIssue @@ -274,6 +298,8 @@ function buildLinkSubmenu({ onSelect: onLinkIssue, }); } + const gitLabEntry = buildGitLabMergeRequestLinkEntry({ disabled, onLinkMergeRequest }); + if (gitLabEntry) children.push(gitLabEntry); if (onLinkJiraTicket) { children.push({ kind: "item", @@ -335,6 +361,7 @@ export function buildKanbanCardMenuEntries({ onDetach, onLinkPullRequest, onLinkIssue, + onLinkMergeRequest, onLinkJiraTicket, onLinkLinearIssue, onLinkSentryIssue, @@ -376,6 +403,7 @@ export function buildKanbanCardMenuEntries({ disabled: isProcessing, onLinkPullRequest, onLinkIssue, + onLinkMergeRequest, onLinkJiraTicket, onLinkLinearIssue, onLinkSentryIssue, diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 80a4338bfc..6b07b7f27d 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -19,6 +19,7 @@ import { import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability"; import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialog"; import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog"; +import { TaskMRLinkDialog } from "@/components/gitlab/task-mr-link-dialog"; import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; import { useTaskMultiSelectStore } from "@/hooks/use-task-multi-select"; import { useDetachTask } from "@/hooks/use-detach-task"; @@ -206,6 +207,7 @@ function useKanbanCardMenus({ const [showDetachConfirm, setShowDetachConfirm] = useState(false); const [showPRDialog, setShowPRDialog] = useState(false); const [showIssueDialog, setShowIssueDialog] = useState(false); + const [showMRDialog, setShowMRDialog] = useState(false); const [externalLinkProvider, setExternalLinkProvider] = useState( null, ); @@ -240,6 +242,7 @@ function useKanbanCardMenus({ task.parentTaskId && !actingOnMultiSelection ? () => setShowDetachConfirm(true) : undefined, onLinkPullRequest: () => setShowPRDialog(true), onLinkIssue: () => setShowIssueDialog(true), + onLinkMergeRequest: externalLinkAvailability.gitlab ? () => setShowMRDialog(true) : undefined, ...externalLinkHandlers(externalLinkAvailability, setExternalLinkProvider), }; @@ -266,6 +269,8 @@ function useKanbanCardMenus({ setShowPRDialog, showIssueDialog, setShowIssueDialog, + showMRDialog, + setShowMRDialog, externalLinkProvider, setExternalLinkProvider, }; @@ -332,6 +337,16 @@ function KanbanCardDialogs({ task={task} repositories={repositories} /> + {workspaceId && ( + + )} {menu.externalLinkProvider && workspaceId && ( Promise | void; onLinkPullRequest?: (taskId: string, taskTitle?: string) => void; onLinkIssue?: (taskId: string, taskTitle?: string) => void; + onLinkMergeRequest?: (taskId: string, taskTitle?: string) => void; onLinkJiraTicket?: (taskId: string, taskTitle?: string) => void; onLinkLinearIssue?: (taskId: string, taskTitle?: string) => void; onLinkSentryIssue?: (taskId: string, taskTitle?: string) => void; @@ -129,6 +131,7 @@ export function MobileTaskList({ onDetachTask={onDetachTask} onLinkPullRequest={onLinkPullRequest} onLinkIssue={onLinkIssue} + onLinkMergeRequest={onLinkMergeRequest} onLinkJiraTicket={onLinkJiraTicket} onLinkLinearIssue={onLinkLinearIssue} onLinkSentryIssue={onLinkSentryIssue} @@ -289,6 +292,11 @@ function TaskSwitcherSurfaceContent({ onOpenChange, linking.taskListHandlers.onLinkIssue, )} + onLinkMergeRequest={surfaceAction( + presentation, + onOpenChange, + linking.taskListHandlers.onLinkMergeRequest, + )} onLinkJiraTicket={surfaceAction( presentation, onOpenChange, diff --git a/apps/web/components/task/task-session-sidebar-dialogs.tsx b/apps/web/components/task/task-session-sidebar-dialogs.tsx index 0b046b1ad4..0a65d03d03 100644 --- a/apps/web/components/task/task-session-sidebar-dialogs.tsx +++ b/apps/web/components/task/task-session-sidebar-dialogs.tsx @@ -7,6 +7,7 @@ import { TaskDetachTargetConfirmDialog } from "./task-detach-confirm-dialog"; import { TaskExternalLinkDialog } from "./task-external-link-dialog"; import { TaskGitHubIssueDialog } from "./task-github-issue-dialog"; import { TaskGitHubPRDialog } from "./task-github-pr-dialog"; +import { TaskMRLinkDialog } from "@/components/gitlab/task-mr-link-dialog"; import type { Repository } from "@/lib/types/http"; import type { SidebarExternalLinkTarget, @@ -42,6 +43,8 @@ export type SidebarDialogsActions = { setLinkingPullRequestTask: (next: LinkTarget) => void; linkingIssueTask: LinkTarget; setLinkingIssueTask: (next: LinkTarget) => void; + linkingMergeRequestTask: LinkTarget; + setLinkingMergeRequestTask: (next: LinkTarget) => void; linkingExternalIssueTask: SidebarExternalLinkTarget | null; setLinkingExternalIssueTask: (next: SidebarExternalLinkTarget | null) => void; }; @@ -127,6 +130,8 @@ export function SidebarLinkDialogs({ | "setLinkingPullRequestTask" | "linkingIssueTask" | "setLinkingIssueTask" + | "linkingMergeRequestTask" + | "setLinkingMergeRequestTask" | "linkingExternalIssueTask" | "setLinkingExternalIssueTask" >; @@ -138,6 +143,8 @@ export function SidebarLinkDialogs({ setLinkingPullRequestTask, linkingIssueTask, setLinkingIssueTask, + linkingMergeRequestTask, + setLinkingMergeRequestTask, linkingExternalIssueTask, setLinkingExternalIssueTask, } = actions; @@ -163,6 +170,18 @@ export function SidebarLinkDialogs({ repositories={repositories} /> )} + {linkingMergeRequestTask && workspaceId && ( + { + if (!open) setLinkingMergeRequestTask(null); + }} + taskId={linkingMergeRequestTask.id} + workspaceId={workspaceId} + taskRepositories={linkingMergeRequestTask.repositories ?? []} + repositories={repositories} + /> + )} {linkingExternalIssueTask && workspaceId && ( (null); + const [linkingMergeRequestTask, setLinkingMergeRequestTask] = useState( + null, + ); const [linkingExternalIssueTask, setLinkingExternalIssueTask] = useState(null); @@ -64,6 +67,13 @@ export function useSidebarLinkActions(store: StoreApi) { [getLinkTarget], ); + const handleLinkMergeRequestTask = useCallback( + (taskId: string, fallbackTitle?: string) => { + setLinkingMergeRequestTask(getLinkTarget(taskId, fallbackTitle)); + }, + [getLinkTarget], + ); + const handleLinkExternalIssueTask = useCallback( (provider: ExternalLinkProvider, taskId: string, fallbackTitle?: string) => { setLinkingExternalIssueTask({ provider, task: getLinkTarget(taskId, fallbackTitle) }); @@ -96,6 +106,9 @@ export function useSidebarLinkActions(store: StoreApi) { linkingIssueTask, setLinkingIssueTask, handleLinkIssueTask, + linkingMergeRequestTask, + setLinkingMergeRequestTask, + handleLinkMergeRequestTask, linkingExternalIssueTask, setLinkingExternalIssueTask, handleLinkJiraTicketTask, diff --git a/apps/web/components/task/task-session-sidebar-task-linking.ts b/apps/web/components/task/task-session-sidebar-task-linking.ts index 334b0360f1..13a1a46699 100644 --- a/apps/web/components/task/task-session-sidebar-task-linking.ts +++ b/apps/web/components/task/task-session-sidebar-task-linking.ts @@ -1,5 +1,6 @@ "use client"; +import { useGitLabAvailable } from "@/hooks/domains/gitlab/use-task-mr"; import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability"; import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability"; import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability"; @@ -9,6 +10,7 @@ type SidebarActions = Pick< ReturnType, | "handleLinkPullRequestTask" | "handleLinkIssueTask" + | "handleLinkMergeRequestTask" | "handleLinkJiraTicketTask" | "handleLinkLinearIssueTask" | "handleLinkSentryIssueTask" @@ -16,12 +18,14 @@ type SidebarActions = Pick< export function useSidebarTaskLinking(workspaceId: string | null, actions: SidebarActions) { const jiraAvailable = useJiraAvailable(workspaceId); + const gitlabAvailable = useGitLabAvailable(); const linearAvailable = useLinearAvailable(workspaceId); const sentryAvailable = useSentryAvailable(workspaceId); return { onLinkPullRequest: actions.handleLinkPullRequestTask, onLinkIssue: actions.handleLinkIssueTask, + onLinkMergeRequest: gitlabAvailable ? actions.handleLinkMergeRequestTask : undefined, onLinkJiraTicket: jiraAvailable ? actions.handleLinkJiraTicketTask : undefined, onLinkLinearIssue: linearAvailable ? actions.handleLinkLinearIssueTask : undefined, onLinkSentryIssue: sentryAvailable ? actions.handleLinkSentryIssueTask : undefined, diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index b38ff8cbce..b9a5e33c92 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -2,6 +2,7 @@ import { cloneElement, isValidElement, useState } from "react"; import { + IconBrandGitlab, IconBrandSentry, IconCopy, IconCircleDot, @@ -51,6 +52,7 @@ type ContextMenuProps = { onDetachTask?: (taskId: string) => void; onLinkPullRequest?: (taskId: string, taskTitle?: string) => void; onLinkIssue?: (taskId: string, taskTitle?: string) => void; + onLinkMergeRequest?: (taskId: string, taskTitle?: string) => void; onLinkJiraTicket?: (taskId: string, taskTitle?: string) => void; onLinkLinearIssue?: (taskId: string, taskTitle?: string) => void; onLinkSentryIssue?: (taskId: string, taskTitle?: string) => void; @@ -82,6 +84,7 @@ export function TaskItemWithContextMenu({ onDetachTask, onLinkPullRequest, onLinkIssue, + onLinkMergeRequest, onLinkJiraTicket, onLinkLinearIssue, onLinkSentryIssue, @@ -123,6 +126,7 @@ export function TaskItemWithContextMenu({ onDetachTask={onDetachTask} onLinkPullRequest={onLinkPullRequest} onLinkIssue={onLinkIssue} + onLinkMergeRequest={onLinkMergeRequest} onLinkJiraTicket={onLinkJiraTicket} onLinkLinearIssue={onLinkLinearIssue} onLinkSentryIssue={onLinkSentryIssue} @@ -357,6 +361,7 @@ function selectTaskLinkActions( ContextMenuProps, | "onLinkPullRequest" | "onLinkIssue" + | "onLinkMergeRequest" | "onLinkJiraTicket" | "onLinkLinearIssue" | "onLinkSentryIssue" @@ -365,6 +370,7 @@ function selectTaskLinkActions( return { onLinkPullRequest: createTaskLinkSelectAction(task, handlers.onLinkPullRequest, closeMenu), onLinkIssue: createTaskLinkSelectAction(task, handlers.onLinkIssue, closeMenu), + onLinkMergeRequest: createTaskLinkSelectAction(task, handlers.onLinkMergeRequest, closeMenu), onLinkJiraTicket: createTaskLinkSelectAction(task, handlers.onLinkJiraTicket, closeMenu), onLinkLinearIssue: createTaskLinkSelectAction(task, handlers.onLinkLinearIssue, closeMenu), onLinkSentryIssue: createTaskLinkSelectAction(task, handlers.onLinkSentryIssue, closeMenu), @@ -504,6 +510,7 @@ function TaskLinkMenu({ disabled, onLinkPullRequest, onLinkIssue, + onLinkMergeRequest, onLinkJiraTicket, onLinkLinearIssue, onLinkSentryIssue, @@ -511,6 +518,7 @@ function TaskLinkMenu({ disabled?: boolean; onLinkPullRequest?: () => void; onLinkIssue?: () => void; + onLinkMergeRequest?: () => void; onLinkJiraTicket?: () => void; onLinkLinearIssue?: () => void; onLinkSentryIssue?: () => void; @@ -518,6 +526,7 @@ function TaskLinkMenu({ if ( !onLinkPullRequest && !onLinkIssue && + !onLinkMergeRequest && !onLinkJiraTicket && !onLinkLinearIssue && !onLinkSentryIssue @@ -543,6 +552,16 @@ function TaskLinkMenu({ GitHub Issue )} + {onLinkMergeRequest && ( + + + GitLab Merge Request + + )} {onLinkJiraTicket && ( diff --git a/apps/web/components/task/task-switcher.test.tsx b/apps/web/components/task/task-switcher.test.tsx index 39099ddeee..69f9cec457 100644 --- a/apps/web/components/task/task-switcher.test.tsx +++ b/apps/web/components/task/task-switcher.test.tsx @@ -221,6 +221,7 @@ describe("TaskSwitcher — detach menu", () => { describe("TaskSwitcher — external issue link menu", () => { it("offers configured external issue providers from the task context menu", async () => { + const onLinkMergeRequest = vi.fn(); render( { onSelectTask={vi.fn()} onLinkPullRequest={vi.fn()} onLinkIssue={vi.fn()} - {...({ - onLinkJiraTicket: vi.fn(), - onLinkLinearIssue: vi.fn(), - onLinkSentryIssue: vi.fn(), - } as Partial[0]>)} + onLinkMergeRequest={onLinkMergeRequest} + onLinkJiraTicket={vi.fn()} + onLinkLinearIssue={vi.fn()} + onLinkSentryIssue={vi.fn()} /> , ); @@ -251,21 +251,22 @@ describe("TaskSwitcher — external issue link menu", () => { expect(screen.getByText("Jira Ticket")).toBeTruthy(); expect(screen.getByText("Linear Issue")).toBeTruthy(); expect(screen.getByText("Sentry Issue")).toBeTruthy(); + expect(screen.getByText("GitLab Merge Request")).toBeTruthy(); }); }); - it("passes the visible task title to external issue link handlers", () => { + it("targets the selected task when linking a GitLab merge request", () => { const archivedTask: TaskSwitcherItem = { id: "archived-task", title: "Archived task title", isArchived: true, }; const closeMenu = vi.fn(); - const onLinkJiraTicket = vi.fn(); + const onLinkMergeRequest = vi.fn(); - createTaskLinkSelectAction(archivedTask, onLinkJiraTicket, closeMenu)?.(); + createTaskLinkSelectAction(archivedTask, onLinkMergeRequest, closeMenu)?.(); - expect(onLinkJiraTicket).toHaveBeenCalledWith(archivedTask.id, archivedTask.title); + expect(onLinkMergeRequest).toHaveBeenCalledWith(archivedTask.id, archivedTask.title); expect(closeMenu).toHaveBeenCalledOnce(); }); }); diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index ee94a26aed..2682126fce 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -62,6 +62,7 @@ type TaskSwitcherProps = { onDetachTask?: (taskId: string) => void; onLinkPullRequest?: TaskLinkHandler; onLinkIssue?: TaskLinkHandler; + onLinkMergeRequest?: TaskLinkHandler; onLinkJiraTicket?: TaskLinkHandler; onLinkLinearIssue?: TaskLinkHandler; onLinkSentryIssue?: TaskLinkHandler; @@ -146,6 +147,7 @@ type TaskRowProps = { onDetachTask?: (taskId: string) => void; onLinkPullRequest?: TaskLinkHandler; onLinkIssue?: TaskLinkHandler; + onLinkMergeRequest?: TaskLinkHandler; onLinkJiraTicket?: TaskLinkHandler; onLinkLinearIssue?: TaskLinkHandler; onLinkSentryIssue?: TaskLinkHandler; @@ -169,6 +171,7 @@ function taskLinkHandlerProps(props: Pick void; onLinkPullRequest?: TaskLinkHandler; onLinkIssue?: TaskLinkHandler; + onLinkMergeRequest?: TaskLinkHandler; onLinkJiraTicket?: TaskLinkHandler; onLinkLinearIssue?: TaskLinkHandler; onLinkSentryIssue?: TaskLinkHandler; @@ -444,6 +449,7 @@ function GroupSection({ onDetachTask, onLinkPullRequest, onLinkIssue, + onLinkMergeRequest, onLinkJiraTicket, onLinkLinearIssue, onLinkSentryIssue, @@ -482,6 +488,7 @@ function GroupSection({ onDetachTask, onLinkPullRequest, onLinkIssue, + onLinkMergeRequest, onLinkJiraTicket, onLinkLinearIssue, onLinkSentryIssue, @@ -538,6 +545,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onDetachTask, onLinkPullRequest, onLinkIssue, + onLinkMergeRequest, onLinkJiraTicket, onLinkLinearIssue, onLinkSentryIssue, @@ -594,6 +602,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onDetachTask={onDetachTask} onLinkPullRequest={onLinkPullRequest} onLinkIssue={onLinkIssue} + onLinkMergeRequest={onLinkMergeRequest} onLinkJiraTicket={onLinkJiraTicket} onLinkLinearIssue={onLinkLinearIssue} onLinkSentryIssue={onLinkSentryIssue} diff --git a/apps/web/e2e/tests/gitlab/gitlab-parity.spec.ts b/apps/web/e2e/tests/gitlab/gitlab-parity.spec.ts index ca1282afbe..c5c3b4c719 100644 --- a/apps/web/e2e/tests/gitlab/gitlab-parity.spec.ts +++ b/apps/web/e2e/tests/gitlab/gitlab-parity.spec.ts @@ -5,6 +5,7 @@ import { assertNoDocumentHorizontalOverflow, } from "../../helpers/layout-assertions"; import { GitLabPage } from "../../pages/gitlab-page"; +import { SessionPage } from "../../pages/session-page"; test.describe("GitLab workspace parity", () => { test("keeps self-managed browse results isolated when switching workspaces", async ({ @@ -115,8 +116,22 @@ test.describe("GitLab workspace parity", () => { await panel.getByRole("button", { name: "Unlink merge request" }).click(); await expect(testPage.getByTestId("mr-topbar-button")).toHaveCount(0); - await testPage.getByRole("button", { name: "Link GitLab merge request" }).click(); + const topbar = testPage.getByTestId("task-topbar"); + await expect( + topbar.getByRole("button", { name: /Link (?:MR|GitLab merge request)/i }), + ).toHaveCount(0); + + const session = new SessionPage(testPage); + const activeTaskRow = session.sidebar.locator( + '[data-testid="sidebar-task-item"][aria-current="true"]', + ); + await expect(activeTaskRow).toBeVisible(); + await activeTaskRow.click({ button: "right" }); + await testPage.getByRole("menuitem", { name: "Link", exact: true }).hover(); + await testPage.getByRole("menuitem", { name: "GitLab Merge Request" }).click(); + const linkDialog = testPage.getByRole("dialog", { name: "Link GitLab merge request" }); + await expect(linkDialog).toBeVisible(); await linkDialog .getByLabel("Merge request URL") .fill(`${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/81`); diff --git a/apps/web/e2e/tests/gitlab/mobile-gitlab-parity.spec.ts b/apps/web/e2e/tests/gitlab/mobile-gitlab-parity.spec.ts index f683b16ca6..f809f34358 100644 --- a/apps/web/e2e/tests/gitlab/mobile-gitlab-parity.spec.ts +++ b/apps/web/e2e/tests/gitlab/mobile-gitlab-parity.spec.ts @@ -1,9 +1,14 @@ -import { test, expect } from "../../fixtures/test-base"; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { test, expect, type SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; import { assertLocatorWithinViewportX, assertNoDocumentHorizontalOverflow, } from "../../helpers/layout-assertions"; -import { GITLAB_PROJECT, seedGitLabReview } from "../../helpers/gitlab"; +import { GITLAB_HOST, GITLAB_PROJECT, seedGitLabReview } from "../../helpers/gitlab"; +import { makeGitEnv } from "../../helpers/git-helper"; import { GitLabPage } from "../../pages/gitlab-page"; import { GitLabSettingsPage } from "../../pages/gitlab-settings-page"; import { KanbanPage } from "../../pages/kanban-page"; @@ -17,6 +22,43 @@ async function expectTouchTarget(locator: ReturnType, label expect(box.height, `${label} height`).toBeGreaterThanOrEqual(44); } +async function seedMultiRepoGitLabTask( + apiClient: ApiClient, + seedData: SeedData, + tmpDir: string, + mrIID: number, +) { + await seedGitLabReview(apiClient, seedData.workspaceId, mrIID, "Mobile contextual GitLab MR"); + await apiClient.updateRepository(seedData.repositoryId, { + provider: "gitlab", + provider_host: GITLAB_HOST, + provider_owner: "platform", + provider_name: "kandev", + }); + const secondaryRepoDir = path.join(tmpDir, "repos", "mobile-gitlab-secondary"); + fs.mkdirSync(secondaryRepoDir, { recursive: true }); + const gitEnv = makeGitEnv(tmpDir); + execSync("git init -b main", { cwd: secondaryRepoDir, env: gitEnv }); + execSync('git commit --allow-empty -m "init"', { cwd: secondaryRepoDir, env: gitEnv }); + const secondaryRepo = await apiClient.createRepository( + seedData.workspaceId, + secondaryRepoDir, + "main", + { + name: "Mobile GitLab secondary", + provider: "gitlab", + provider_host: GITLAB_HOST, + provider_owner: "platform", + provider_name: "docs", + }, + ); + return apiClient.createTask(seedData.workspaceId, "Mobile contextual GitLab link", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId, secondaryRepo.id], + }); +} + test.describe("Mobile GitLab parity", () => { test("browses, quick launches, reviews, subscribes, and unlinks without overflow", async ({ testPage, @@ -28,7 +70,7 @@ test.describe("Mobile GitLab parity", () => { await seedGitLabReview(apiClient, seedData.workspaceId, 111, "Mobile GitLab review"); await apiClient.updateRepository(seedData.repositoryId, { provider: "gitlab", - provider_host: "https://gitlab.example.test", + provider_host: GITLAB_HOST, provider_owner: "platform", provider_name: "kandev", }); @@ -85,6 +127,85 @@ test.describe("Mobile GitLab parity", () => { .toBe("chat"); }); + test("links a GitLab MR from the visible task actions menu", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + await testPage.setViewportSize({ width: 393, height: 851 }); + const mrIID = 113; + const title = "Mobile contextual GitLab link"; + const task = await seedMultiRepoGitLabTask(apiClient, seedData, backend.tmpDir, mrIID); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(testPage.getByTestId("mr-topbar-button")).toHaveCount(0); + + await testPage.getByTestId("mobile-session-menu").click(); + const taskDrawer = testPage.getByRole("dialog", { name: "Tasks" }); + const taskRow = taskDrawer.getByTestId("sidebar-task-item").filter({ hasText: title }); + await expect(taskRow).toBeVisible({ timeout: 10_000 }); + const taskActions = taskRow.getByRole("button", { name: "Task actions" }); + await expect(taskActions).toBeVisible(); + await expectTouchTarget(taskActions, "task actions button"); + await taskActions.click(); + + const linkTrigger = testPage.getByRole("menuitem", { name: "Link", exact: true }); + await expect(linkTrigger).toBeVisible(); + await linkTrigger.click(); + + const gitLabItem = testPage.getByRole("menuitem", { name: "GitLab Merge Request" }); + await expect(gitLabItem).toBeVisible(); + await expectTouchTarget(gitLabItem, "GitLab merge request link action"); + const nestedMenu = gitLabItem.locator("xpath=ancestor::*[@role='menu'][1]"); + await nestedMenu.evaluate((element) => + Promise.all( + element + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); + const menuBox = await nestedMenu.boundingBox(); + const viewport = testPage.viewportSize(); + if (!menuBox || !viewport) throw new Error("mobile GitLab link menu has no layout box"); + expect(menuBox.x).toBeGreaterThanOrEqual(8); + expect(menuBox.x).toBeLessThanOrEqual(18); + expect(menuBox.width).toBeGreaterThanOrEqual(viewport.width - 36); + expect(viewport.width - (menuBox.x + menuBox.width)).toBeGreaterThanOrEqual(8); + expect(viewport.width - (menuBox.x + menuBox.width)).toBeLessThanOrEqual(18); + expect(menuBox.y).toBeGreaterThanOrEqual(0); + expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(viewport.height); + expect(viewport.height - (menuBox.y + menuBox.height)).toBeGreaterThanOrEqual(8); + await assertNoDocumentHorizontalOverflow(testPage, "mobile GitLab task link menu"); + + await gitLabItem.click(); + const linkDialog = testPage.getByRole("dialog", { name: "Link GitLab merge request" }); + await expect(linkDialog).toBeVisible(); + await assertLocatorWithinViewportX(linkDialog, "mobile GitLab link dialog"); + await assertNoDocumentHorizontalOverflow(testPage, "mobile GitLab link dialog"); + await expect(linkDialog.getByRole("combobox", { name: "Task repository" })).toContainText( + GITLAB_PROJECT, + ); + await linkDialog + .getByLabel("Merge request URL") + .fill(`${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${mrIID}`); + await linkDialog.getByRole("button", { name: "Link merge request" }).click(); + + const mrButton = testPage.getByTestId("mr-topbar-button"); + await expect(mrButton).toHaveAttribute("data-mr-iid", String(mrIID)); + await expect(mrButton).toHaveAttribute("data-mr-state", "opened"); + const response = await apiClient.rawRequest("GET", `/api/v1/gitlab/tasks/${task.id}/mrs`); + expect(response.ok).toBe(true); + const linked = (await response.json()) as { + task_mrs: Array<{ repository_id?: string }>; + }; + expect(linked.task_mrs).toEqual([ + expect.objectContaining({ repository_id: seedData.repositoryId }), + ]); + }); + test("watch controls remain touch sized and persist a pause", async ({ testPage, apiClient, diff --git a/docs/plans/gitlab-task-link-menu/plan.md b/docs/plans/gitlab-task-link-menu/plan.md new file mode 100644 index 0000000000..4272f7e0ab --- /dev/null +++ b/docs/plans/gitlab-task-link-menu/plan.md @@ -0,0 +1,124 @@ +--- +spec: docs/specs/gitlab-integration/spec.md +created: 2026-07-22 +status: approved +--- + +# Implementation Plan: GitLab Task Link Menu + +## Overview + +Align GitLab task linking with the existing GitHub interaction: unlinked tasks +do not reserve top-bar space, and linking starts from the task's contextual +`Link` submenu. Reuse the existing GitLab MR dialog and task-menu plumbing, then +update desktop and mobile E2E coverage to prove the new entry points. + +## Frontend + +### Top-bar behavior + +- Update `apps/web/components/gitlab/mr-topbar-button.tsx` so an unlinked task + renders no generic link action. +- Preserve the linked-MR status/dropdown control, including `Link another merge + request`, open, and unlink behavior. + +### Shared task link menu + +- Extend `apps/web/components/kanban-card-menu-items.tsx` and + `apps/web/components/task/task-switcher-context-menu.tsx` with a + `GitLab Merge Request` link action. +- Wire the existing `TaskMRLinkDialog` through kanban-card and sidebar/mobile + task-switcher link state. Surface the action only while GitLab is configured + for the active workspace. +- Reuse the task's repository associations and the existing workspace + repository list; do not add a new API or duplicate link submission logic. + +### Mobile design contract + +- **Desktop outcome:** right-clicking a task opens `Link` > `GitLab Merge + Request`; submitting the existing dialog links the MR and reveals its status + control in the task top bar. +- **Mobile entry point:** the visible `Task actions` ellipsis in the existing + task-switcher drawer opens the same contextual menu; no long press is needed. +- **Nearest exemplar:** + `apps/web/components/task/mobile/session-task-switcher-sheet.tsx` and + `apps/web/components/task/task-item.tsx` provide the discoverable ellipsis, + responsive menu treatment, and touch geometry. +- **Hierarchy and presentation:** task row > actions menu > `Link` submenu > + `GitLab Merge Request` > existing link dialog. This is a short secondary + action, so the responsive context-menu bottom sheet plus existing dialog is + preferable to a new route or full-height surface. +- **Scrolling and geometry:** retain the menu primitive's safe-area-aware, + internally scrolling mobile surface and 44px menu rows. The task-switcher + drawer remains the outer list scroll owner while the portalled menu is open. +- **Shared logic:** desktop and mobile use the same availability hook, menu + callbacks, target state, repository resolution, and `TaskMRLinkDialog`. + +## Tests + +- **What:** the shared link-menu model includes `GitLab Merge Request` only + when its callback is present. + **File:** `apps/web/components/kanban-card-menu-items.test.tsx`. + **How:** extend the existing menu-entry unit tests and assert ordering and + omission. +- **What:** a task-switcher link selection closes the menu and targets the + selected task. + **File:** `apps/web/components/task/task-switcher.test.tsx` or a focused + colocated test if the existing helper coverage is sufficient. + **How:** exercise the existing provider-neutral selection helper with the new + callback plumbing. +- **What:** the GitLab top bar renders no generic action without linked MRs and + keeps the linked-MR control behavior. + **File:** `apps/web/components/gitlab/mr-topbar-button.test.ts` or a focused + component test where practical. + **How:** prefer testing extracted render-policy logic; do not add broad React + component tests solely for markup. + +## E2E Tests + +- **Desktop scenario:** update + `apps/web/e2e/tests/gitlab/gitlab-parity.spec.ts` so an unlinked task has no + `Link MR` top-bar button, then right-click the task row, choose `Link` > + `GitLab Merge Request`, submit the dialog, and verify the linked-MR status + control appears and survives reload. +- **Mobile scenario:** extend + `apps/web/e2e/tests/gitlab/mobile-gitlab-parity.spec.ts` or the focused mobile + task-link menu spec so the visible `Task actions` ellipsis opens the menu, + the nested GitLab action is touch reachable and viewport-contained, and the + dialog opens without right-click or long press. + +## Implementation Waves + +Wave 1: + +- [x] [task-01-frontend-link-menu](task-01-frontend-link-menu.md) + +Wave 2: + +- [x] [task-02-e2e-contextual-linking](task-02-e2e-contextual-linking.md) + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run components/kanban-card-menu-items.test.tsx components/task/task-switcher.test.tsx components/gitlab/mr-topbar-button.test.ts +cd apps/web && pnpm run typecheck +cd apps/web && pnpm e2e:run tests/gitlab/gitlab-parity.spec.ts tests/gitlab/mobile-gitlab-parity.spec.ts +make fmt +make typecheck test lint +``` + +## Verification Status + +- Targeted desktop and mobile GitLab Playwright coverage passes (6/6), including + validated screenshots, mobile touch targets, and viewport containment. +- Aggregate format, typecheck, unit, and lint checks pass. Aggregate tests pass + with `TMPDIR=/tmp`. + +## Risks + +- Task menu props cross kanban, desktop sidebar, and mobile task-switcher + surfaces; missing one caller would make the action viewport-dependent. +- GitLab availability is workspace-scoped. The action must disappear when the + active workspace has no configured GitLab connection. +- Removing the generic top-bar entry invalidates the existing manual-link E2E + steps; those must be changed rather than deleted. diff --git a/docs/plans/gitlab-task-link-menu/task-01-frontend-link-menu.md b/docs/plans/gitlab-task-link-menu/task-01-frontend-link-menu.md new file mode 100644 index 0000000000..ea3727d497 --- /dev/null +++ b/docs/plans/gitlab-task-link-menu/task-01-frontend-link-menu.md @@ -0,0 +1,68 @@ +--- +id: "01-frontend-link-menu" +title: "GitLab MR contextual link action" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/gitlab-integration/spec.md" +--- + +# Task 01: GitLab MR Contextual Link Action + +## Acceptance + +- An unlinked task renders no generic GitLab `Link MR` action in desktop or + mobile task top bars, while linked MR status/dropdown behavior is unchanged. +- A GitLab-configured workspace exposes `GitLab Merge Request` in the shared + task `Link` submenu on kanban cards, desktop sidebar rows, and the mobile task + switcher; unconfigured workspaces omit it. +- Selecting the action opens the existing `TaskMRLinkDialog` for the selected + task and active workspace with that task's repositories. + +## Files Likely Touched + +- `apps/web/components/gitlab/mr-topbar-button.tsx` +- `apps/web/components/gitlab/mr-topbar-button.test.ts` +- `apps/web/components/kanban-external-link-availability.ts` +- `apps/web/components/kanban-card-menu-items.tsx` +- `apps/web/components/kanban-card-menu-items.test.tsx` +- `apps/web/components/kanban-card.tsx` +- `apps/web/components/task/task-switcher-context-menu.tsx` +- `apps/web/components/task/task-switcher.tsx` +- `apps/web/components/task/task-switcher.test.tsx` +- `apps/web/components/task/task-session-sidebar-link-actions.ts` +- `apps/web/components/task/task-session-sidebar-task-linking.ts` +- `apps/web/components/task/task-session-sidebar-dialogs.tsx` +- `apps/web/components/task/mobile/session-task-switcher-sheet.tsx` + +## Inputs + +- Spec `What` bullets and the three contextual-link/top-bar scenarios. +- Plan `Frontend`, `Mobile design contract`, and `Tests` sections. +- Existing GitHub menu callback plumbing and `TaskGitHubPRDialog` wiring. +- Existing `TaskMRLinkDialog`, `useGitLabAvailable`, and linked-MR top-bar + dropdown behavior. + +## Implementation Notes + +- Follow `/tdd` and `/mobile-parity`; do not spawn subagents. +- Add the failing menu-policy/unit coverage before production changes. +- Keep the action label provider-specific: `GitLab Merge Request`. +- Preserve `Link another merge request` inside the linked-MR top-bar dropdown. +- Do not add backend endpoints, state slices, or a second GitLab link dialog. +- Update only this task file's status. Do not edit `plan.md`. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run components/kanban-card-menu-items.test.tsx components/task/task-switcher.test.tsx components/gitlab/mr-topbar-button.test.ts +cd apps/web && pnpm run typecheck +``` + +## Output Contract + +Report the behavior implemented, files changed, RED/GREEN evidence, targeted +test and typecheck results, mobile parity observations, blockers, and residual +risks. Set this task to `in_progress` before code changes and `done` only after +acceptance and verification pass. diff --git a/docs/plans/gitlab-task-link-menu/task-02-e2e-contextual-linking.md b/docs/plans/gitlab-task-link-menu/task-02-e2e-contextual-linking.md new file mode 100644 index 0000000000..4b032833e7 --- /dev/null +++ b/docs/plans/gitlab-task-link-menu/task-02-e2e-contextual-linking.md @@ -0,0 +1,70 @@ +--- +id: "02-e2e-contextual-linking" +title: "GitLab contextual linking E2E" +status: done +wave: 2 +depends_on: ["01-frontend-link-menu"] +plan: "plan.md" +spec: "../../specs/gitlab-integration/spec.md" +--- + +# Task 02: GitLab Contextual Linking E2E + +## Acceptance + +- Desktop E2E proves an unlinked task has no generic top-bar link action and + can link an MR through right-click `Link` > `GitLab Merge Request`. +- Mobile E2E proves the visible `Task actions` ellipsis exposes the same nested + action, opens the link dialog, and does not rely on right-click or long press. +- The desktop flow proves the linked-MR status control appears after linking + and survives reload; mobile menu surfaces remain viewport-contained with + touch-sized rows and no document horizontal overflow. + +## Files Likely Touched + +- `apps/web/e2e/tests/gitlab/gitlab-parity.spec.ts` +- `apps/web/e2e/tests/gitlab/mobile-gitlab-parity.spec.ts` +- Optional focused page-object helper under `apps/web/e2e/pages/` only when it + removes repeated stable selectors. + +## Dependencies + +- `01-frontend-link-menu` + +## Inputs + +- Spec contextual-link/top-bar scenarios. +- Plan `E2E Tests` and `Mobile design contract` sections. +- Existing manual-link section in `gitlab-parity.spec.ts`. +- Existing mobile task-actions geometry coverage in + `apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts` and nested-link + coverage in `apps/web/e2e/tests/task/mobile-external-link-menu.spec.ts`. + +## Implementation Notes + +- Follow `/e2e`, `/tdd`, and `/mobile-parity`; do not spawn subagents. +- Confirm the updated test fails for the expected missing menu action before + relying on the implementation. +- Use the managed `pnpm e2e:run` runner so production frontend assets are + rebuilt before the tests execute. +- Update only this task file's status. Do not edit `plan.md`. + +## Verification + +```bash +cd apps/web && pnpm e2e:run tests/gitlab/gitlab-parity.spec.ts tests/gitlab/mobile-gitlab-parity.spec.ts +``` + +## Verification Status + +The targeted desktop and mobile GitLab Playwright scenarios pass (6/6). +Desktop and mobile screenshots were validated, along with mobile menu touch +targets and viewport containment. Aggregate format, typecheck, unit, and lint +checks pass, and aggregate tests pass with `TMPDIR=/tmp`. + +## Output Contract + +Report changed E2E scenarios, the exact command and result, rendered desktop +and phone observations, failure artifact paths if any, blockers, and residual +risks. Set this task to `in_progress` before changes and `done` only after both +desktop and mobile acceptance criteria pass. diff --git a/docs/specs/gitlab-integration/spec.md b/docs/specs/gitlab-integration/spec.md index 8ef19fb6f7..6c651ffc99 100644 --- a/docs/specs/gitlab-integration/spec.md +++ b/docs/specs/gitlab-integration/spec.md @@ -1,7 +1,7 @@ --- -status: shipped +status: building created: 2026-05-04 -updated: 2026-07-20 +updated: 2026-07-22 owner: tbd --- @@ -43,6 +43,13 @@ workflows are not usable end to end. - Users can link an existing task to a merge request by pasting a full MR URL, including URLs from the workspace's configured self-managed host. They can unlink one association without deleting the task or upstream MR. +- For a workspace with GitLab configured, task context menus expose + `GitLab Merge Request` inside the shared `Link` submenu. Desktop users can + reach it by right-clicking a task, and touch users can reach the same action + through the task row's visible actions menu. +- An unlinked task does not show a persistent `Link MR` action in its task top + bar. After at least one MR is linked, the top bar shows the linked-MR status + control and continues to allow opening, unlinking, or linking another MR. - Linked MRs are visible from both the GitLab list and task detail. Multiple tasks can link to one MR, and a multi-repository task can link one MR per repository. @@ -322,6 +329,16 @@ protocol action name for compatibility. - **GIVEN** a self-managed workspace connection, **WHEN** a user links a valid MR URL from that host, **THEN** the task shows the linked MR and its live review details after reload. +- **GIVEN** a task with no linked MR in a GitLab-configured workspace, **WHEN** + the task detail opens, **THEN** the top bar has no `Link MR` action and the + task's contextual `Link` submenu offers `GitLab Merge Request`. +- **GIVEN** a touch viewport and a task with no linked MR in a GitLab-configured + workspace, **WHEN** the user opens the task row's visible actions menu and + chooses `Link` then `GitLab Merge Request`, **THEN** the GitLab MR link dialog + opens without relying on right-click or long press. +- **GIVEN** a task with a linked GitLab MR, **WHEN** the task detail opens, + **THEN** the top bar shows the linked MR status control rather than a generic + link action. - **GIVEN** an MR URL from a different host, **WHEN** it is linked in the current workspace, **THEN** the request is rejected and no association is written. - **GIVEN** a linked MR, **WHEN** the user unlinks it, **THEN** it disappears