feat: move GitLab MR linking to task menus (#1868)

This commit is contained in:
Carlos Florêncio
2026-07-23 14:46:07 +01:00
committed by GitHub
parent e53fd038a8
commit f6007de1cb
21 changed files with 753 additions and 71 deletions
@@ -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");
@@ -8,7 +8,6 @@ import {
IconClock,
IconExternalLink,
IconGitMerge,
IconLink,
IconPlus,
IconUnlink,
IconX,
@@ -241,19 +240,7 @@ function MRTopbarControl({
/>
);
}
if (!gitlabAvailable) return null;
return (
<Button
size={compact ? "icon-sm" : "sm"}
variant="outline"
className={mrTriggerClass(compact, mobile)}
onClick={onLink}
aria-label="Link GitLab merge request"
>
<IconLink className="h-4 w-4 text-orange-500" />
{!compact && <span className="text-xs font-medium">Link MR</span>}
</Button>
);
return null;
}
export const MRTopbarButton = memo(function MRTopbarButton({
@@ -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 (
<TaskMRLinkDialog
open={open}
onOpenChange={vi.fn()}
taskId="task-1"
workspaceId={workspaceId}
taskRepositories={taskRepositoryLinks}
repositories={repositoryOptions}
/>
);
}
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(
<TaskMRLinkDialog
{...props}
taskRepositories={[{ repository_id: repository.id }]}
repositories={[repository]}
/>,
);
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(
<TaskMRLinkDialog
{...props}
taskRepositories={[{ repository_id: repository.id }]}
repositories={[{ ...repository }]}
/>,
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);
});
});
@@ -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;
@@ -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",
@@ -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: <IconBrandGitlab className="mr-2 h-4 w-4" />,
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,
+15
View File
@@ -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<ExternalLinkProvider | null>(
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 && (
<TaskMRLinkDialog
open={menu.showMRDialog}
onOpenChange={menu.setShowMRDialog}
taskId={task.id}
workspaceId={workspaceId}
taskRepositories={task.repositories ?? []}
repositories={repositories}
/>
)}
{menu.externalLinkProvider && workspaceId && (
<TaskExternalLinkDialog
open={true}
@@ -1,10 +1,12 @@
"use client";
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
import { useGitLabAvailable } from "@/hooks/domains/gitlab/use-task-mr";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability";
export type KanbanExternalLinkAvailability = {
gitlab?: boolean;
jira: boolean;
linear: boolean;
sentry: boolean;
@@ -14,6 +16,7 @@ export function useKanbanExternalLinkAvailability(
workspaceId: string | null,
): KanbanExternalLinkAvailability {
return {
gitlab: useGitLabAvailable(),
jira: useJiraAvailable(workspaceId),
linear: useLinearAvailable(workspaceId),
sentry: useSentryAvailable(workspaceId),
@@ -62,6 +62,7 @@ export function MobileTaskList({
onDetachTask,
onLinkPullRequest,
onLinkIssue,
onLinkMergeRequest,
onLinkJiraTicket,
onLinkLinearIssue,
onLinkSentryIssue,
@@ -80,6 +81,7 @@ export function MobileTaskList({
onDetachTask: (taskId: string) => Promise<void> | 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,
@@ -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 && (
<TaskMRLinkDialog
open={true}
onOpenChange={(open) => {
if (!open) setLinkingMergeRequestTask(null);
}}
taskId={linkingMergeRequestTask.id}
workspaceId={workspaceId}
taskRepositories={linkingMergeRequestTask.repositories ?? []}
repositories={repositories}
/>
)}
{linkingExternalIssueTask && workspaceId && (
<TaskExternalLinkDialog
open={true}
@@ -31,6 +31,9 @@ export function useSidebarLinkActions(store: StoreApi) {
null,
);
const [linkingIssueTask, setLinkingIssueTask] = useState<SidebarLinkTarget | null>(null);
const [linkingMergeRequestTask, setLinkingMergeRequestTask] = useState<SidebarLinkTarget | null>(
null,
);
const [linkingExternalIssueTask, setLinkingExternalIssueTask] =
useState<SidebarExternalLinkTarget | null>(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,
@@ -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<typeof useSidebarActions>,
| "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,
@@ -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
</ContextMenuItem>
)}
{onLinkMergeRequest && (
<ContextMenuItem
className="min-h-12! sm:min-h-7!"
disabled={disabled}
onSelect={onLinkMergeRequest}
>
<IconBrandGitlab className="mr-2 h-4 w-4" />
GitLab Merge Request
</ContextMenuItem>
)}
{onLinkJiraTicket && (
<ContextMenuItem disabled={disabled} onSelect={onLinkJiraTicket}>
<IconTicket className="mr-2 h-4 w-4" />
@@ -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(
<Providers>
<TaskSwitcher
@@ -233,11 +234,10 @@ describe("TaskSwitcher — external issue link menu", () => {
onSelectTask={vi.fn()}
onLinkPullRequest={vi.fn()}
onLinkIssue={vi.fn()}
{...({
onLinkJiraTicket: vi.fn(),
onLinkLinearIssue: vi.fn(),
onLinkSentryIssue: vi.fn(),
} as Partial<Parameters<typeof TaskSwitcher>[0]>)}
onLinkMergeRequest={onLinkMergeRequest}
onLinkJiraTicket={vi.fn()}
onLinkLinearIssue={vi.fn()}
onLinkSentryIssue={vi.fn()}
/>
</Providers>,
);
@@ -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();
});
});
@@ -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<TaskRowProps, keyof TaskLinkHandlerPro
return {
onLinkPullRequest: props.onLinkPullRequest,
onLinkIssue: props.onLinkIssue,
onLinkMergeRequest: props.onLinkMergeRequest,
onLinkJiraTicket: props.onLinkJiraTicket,
onLinkLinearIssue: props.onLinkLinearIssue,
onLinkSentryIssue: props.onLinkSentryIssue,
@@ -178,6 +181,7 @@ function taskLinkHandlerProps(props: Pick<TaskRowProps, keyof TaskLinkHandlerPro
type TaskLinkHandlerProps = {
onLinkPullRequest?: TaskLinkHandler;
onLinkIssue?: TaskLinkHandler;
onLinkMergeRequest?: TaskLinkHandler;
onLinkJiraTicket?: TaskLinkHandler;
onLinkLinearIssue?: TaskLinkHandler;
onLinkSentryIssue?: TaskLinkHandler;
@@ -404,6 +408,7 @@ type GroupSectionProps = {
onDetachTask?: (taskId: string) => 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}
@@ -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`);
@@ -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<GitLabPage["mrRow"]>, 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,
+124
View File
@@ -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.
@@ -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.
@@ -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.
+19 -2
View File
@@ -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