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