feat(web): let the dependency picker find tasks by PR or MR number (#3311)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-03 19:44:26 +00:00
committed by GitHub
parent decd317988
commit 7a0dc3bea1
14 changed files with 678 additions and 38 deletions
@@ -3,11 +3,31 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
import { afterEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import type { KanbanState } from "@/lib/state/slices/kanban/types";
import type { TaskMR } from "@/lib/types/gitlab";
import type { TaskPR } from "@/lib/types/github";
import { TaskCreateDependencies } from "./task-create-dialog-dependencies";
const workspaceHydrationMocks = vi.hoisted(() => ({
useWorkspacePRs: vi.fn(),
useWorkspaceMRs: vi.fn(),
}));
vi.mock("@/hooks/domains/github/use-task-pr", () => workspaceHydrationMocks);
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => workspaceHydrationMocks);
const WORKSPACE_ID = "workspace-1";
type MockStore = {
kanban: { tasks: KanbanState["tasks"] };
kanbanMulti: { snapshots: Record<string, { tasks?: KanbanState["tasks"] }> };
workspaces: { activeId: string | null };
workspaceContextGeneration: number;
taskMRs: { byWorkspaceId: Record<string, Record<string, TaskMR[]>> };
taskPRs: {
byTaskId: Record<string, TaskPR[]>;
workspaceId?: string | null;
workspaceContextGeneration?: number;
};
};
const ALPHA_ID = "task-alpha";
@@ -15,15 +35,54 @@ const BETA_ID = "task-beta";
const ALPHA_TITLE = "Alpha task";
const BETA_TITLE = "Beta task";
const NO_DEPENDENCY_LABEL = "No dependency";
const SEARCH_TASKS_LABEL = "Search tasks...";
const SEARCH_TASKS_LABEL = "Search tasks or #PR/MR number...";
const INFO_LABEL = "About task dependencies";
const TWO_DEPENDENCIES_LABEL = "2 dependencies";
const TRIGGER_TEST_ID = "task-create-dependencies-trigger";
const POPOVER_TEST_ID = "task-create-dependencies-popover";
let mockStore: MockStore = {
kanban: { tasks: [] },
kanbanMulti: { snapshots: {} },
};
function emptyStore(): MockStore {
return {
kanban: { tasks: [] },
kanbanMulti: { snapshots: {} },
workspaces: { activeId: WORKSPACE_ID },
workspaceContextGeneration: 0,
taskMRs: { byWorkspaceId: {} },
taskPRs: {
byTaskId: {},
workspaceId: WORKSPACE_ID,
workspaceContextGeneration: 0,
},
};
}
function taskPR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-assoc-1",
workspace_id: WORKSPACE_ID,
task_id: ALPHA_ID,
owner: "kdlbs",
repo: "kandev",
pr_number: 3295,
pr_url: "https://github.com/kdlbs/kandev/pull/3295",
pr_title: "fix flaky test",
head_branch: "fix",
base_branch: "main",
author_login: "nova28",
state: "merged",
review_state: "",
checks_state: "",
mergeable_state: "mergeable",
review_count: 0,
pending_review_count: 0,
comment_count: 0,
unresolved_review_threads: 0,
checks_total: 0,
...overrides,
} as TaskPR;
}
let mockStore: MockStore = emptyStore();
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: MockStore) => unknown) => selector(mockStore),
@@ -58,15 +117,24 @@ function ControlledDependencies({ initialValue = [] }: { initialValue?: string[]
return <TaskCreateDependencies value={value} onChange={setValue} />;
}
function optionTestId(taskId: string): string {
return `task-create-dependency-option-${taskId}`;
}
afterEach(() => {
cleanup();
mockStore = {
kanban: { tasks: [] },
kanbanMulti: { snapshots: {} },
};
vi.clearAllMocks();
mockStore = emptyStore();
});
describe("TaskCreateDependencies", () => {
it("hydrates change-request associations for the active workspace", () => {
renderDependencies();
expect(workspaceHydrationMocks.useWorkspacePRs).toHaveBeenCalledWith(WORKSPACE_ID);
expect(workspaceHydrationMocks.useWorkspaceMRs).toHaveBeenCalledWith(WORKSPACE_ID);
});
it("starts with a no-dependency selector and its dependency icon", () => {
renderDependencies();
@@ -77,6 +145,7 @@ describe("TaskCreateDependencies", () => {
it("shows searchable non-archived tasks with task icons and teaching help", async () => {
mockStore = {
...emptyStore(),
kanban: {
tasks: [
task(ALPHA_ID, ALPHA_TITLE),
@@ -93,14 +162,14 @@ describe("TaskCreateDependencies", () => {
renderDependencies();
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
const picker = screen.getByTestId("task-create-dependencies-popover");
const picker = screen.getByTestId(POPOVER_TEST_ID);
expect(within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL)).toBeTruthy();
expect(within(picker).getByTestId("task-create-dependency-info")).toBeTruthy();
expect(within(picker).getByTestId(`task-create-dependency-option-${ALPHA_ID}`)).toBeTruthy();
expect(within(picker).getByTestId(`task-create-dependency-option-${BETA_ID}`)).toBeTruthy();
expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy();
expect(within(picker).getByTestId(optionTestId(BETA_ID))).toBeTruthy();
expect(within(picker).queryByText("Archived task")).toBeNull();
expect(
within(within(picker).getByTestId(`task-create-dependency-option-${ALPHA_ID}`)).getByTestId(
within(within(picker).getByTestId(optionTestId(ALPHA_ID))).getByTestId(
"task-create-dependency-task-icon",
),
).toBeTruthy();
@@ -121,10 +190,10 @@ describe("TaskCreateDependencies", () => {
it("toggles multiple predecessors and exposes a localized count", () => {
mockStore = {
...emptyStore(),
kanban: {
tasks: [task(ALPHA_ID, ALPHA_TITLE), task(BETA_ID, BETA_TITLE)],
},
kanbanMulti: { snapshots: {} },
};
render(
@@ -134,22 +203,22 @@ describe("TaskCreateDependencies", () => {
);
fireEvent.click(screen.getByTestId("task-create-dependencies-trigger"));
fireEvent.click(screen.getByTestId(`task-create-dependency-option-${ALPHA_ID}`));
fireEvent.click(screen.getByTestId(optionTestId(ALPHA_ID)));
expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(ALPHA_TITLE);
fireEvent.click(screen.getByTestId(`task-create-dependency-option-${BETA_ID}`));
fireEvent.click(screen.getByTestId(optionTestId(BETA_ID)));
expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(TWO_DEPENDENCIES_LABEL);
fireEvent.click(screen.getByTestId(`task-create-dependency-option-${ALPHA_ID}`));
fireEvent.click(screen.getByTestId(optionTestId(ALPHA_ID)));
expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(BETA_TITLE);
});
it("clears all selected predecessors from the no-dependency entry", () => {
mockStore = {
...emptyStore(),
kanban: {
tasks: [task(ALPHA_ID, ALPHA_TITLE), task(BETA_ID, BETA_TITLE)],
},
kanbanMulti: { snapshots: {} },
};
render(
@@ -164,3 +233,125 @@ describe("TaskCreateDependencies", () => {
expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(NO_DEPENDENCY_LABEL);
});
});
describe("TaskCreateDependencies change-request search", () => {
it("narrows to the PR-linked task when searching by PR number and shows its badge", () => {
const prTask = task(ALPHA_ID, ALPHA_TITLE, {
statusSummary: { pull_request: { number: 3295, state: "merged" } } as never,
});
mockStore = {
...emptyStore(),
kanban: {
tasks: [prTask, task(BETA_ID, BETA_TITLE)],
},
};
renderDependencies();
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
const picker = screen.getByTestId(POPOVER_TEST_ID);
const alphaOption = within(picker).getByTestId(optionTestId(ALPHA_ID));
expect(within(alphaOption).getByText("#3295")).toBeTruthy();
const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL);
fireEvent.change(search, { target: { value: "3295" } });
expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy();
expect(within(picker).queryByTestId(optionTestId(BETA_ID))).toBeNull();
});
it("narrows to the MR-linked task when searching with a leading #", () => {
const mrTask = task(ALPHA_ID, ALPHA_TITLE);
mockStore = {
...emptyStore(),
kanban: { tasks: [mrTask, task(BETA_ID, BETA_TITLE)] },
taskMRs: {
byWorkspaceId: {
[WORKSPACE_ID]: {
[ALPHA_ID]: [
{
id: "mr-1",
task_id: ALPHA_ID,
host: "gitlab.com",
project_path: "kdlbs/kandev",
mr_iid: 42,
mr_url: "https://gitlab.com/kdlbs/kandev/-/merge_requests/42",
mr_title: "Fix",
head_branch: "fix",
base_branch: "main",
author_username: "nova28",
state: "open",
} as TaskMR,
],
},
},
},
};
renderDependencies();
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
const picker = screen.getByTestId(POPOVER_TEST_ID);
const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL);
fireEvent.change(search, { target: { value: "#42" } });
expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy();
expect(within(picker).queryByTestId(optionTestId(BETA_ID))).toBeNull();
});
it("lists unfiltered candidates most-recently-updated first", () => {
mockStore = {
...emptyStore(),
kanban: {
tasks: [
task(ALPHA_ID, ALPHA_TITLE, { updatedAt: "2026-01-01T00:00:00Z" }),
task(BETA_ID, BETA_TITLE, { updatedAt: "2026-06-01T00:00:00Z" }),
],
},
};
renderDependencies();
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
const picker = screen.getByTestId(POPOVER_TEST_ID);
const options = within(picker)
.getAllByTestId(/task-create-dependency-option-/)
.map((option) => option.getAttribute("data-testid"));
expect(options).toEqual([optionTestId(BETA_ID), optionTestId(ALPHA_ID)]);
});
});
describe("TaskCreateDependencies multi-PR change requests", () => {
it("shows a badge for every GitHub PR linked to a multi-repo task and finds it by any number", () => {
const prTask = task(ALPHA_ID, ALPHA_TITLE, {
statusSummary: { pull_request: { number: 1001, state: "open" } } as never,
});
mockStore = {
...emptyStore(),
kanban: {
tasks: [prTask, task(BETA_ID, BETA_TITLE)],
},
taskPRs: {
byTaskId: {
[ALPHA_ID]: [
taskPR({ id: "pr-assoc-1", pr_number: 1001, repo: "kandev" }),
taskPR({ id: "pr-assoc-2", pr_number: 1002, repo: "kandev-docs" }),
],
},
workspaceId: WORKSPACE_ID,
workspaceContextGeneration: 0,
},
};
renderDependencies();
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
const picker = screen.getByTestId(POPOVER_TEST_ID);
const alphaOption = within(picker).getByTestId(optionTestId(ALPHA_ID));
expect(within(alphaOption).getByText("#1001")).toBeTruthy();
expect(within(alphaOption).getByText("#1002")).toBeTruthy();
const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL);
fireEvent.change(search, { target: { value: "1002" } });
expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy();
expect(within(picker).queryByTestId(optionTestId(BETA_ID))).toBeNull();
});
});
@@ -21,15 +21,28 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
import { useWorkspaceMRs } from "@/hooks/domains/gitlab/use-task-mr";
import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container";
import type { KanbanState } from "@/lib/state/slices/kanban/types";
import type { TaskMR } from "@/lib/types/gitlab";
import type { TaskPR } from "@/lib/types/github";
import { cn } from "@/lib/utils";
import {
changeRequestNumbers,
compareDependencyCandidates,
dependencyOptionValue,
} from "./task-create-dialog-dependency-search";
// Keep stable empty fallbacks here. Creating a fresh array/object in a store
// selector makes Object.is report a change on every read and can cause a
// render loop, which previously blanked the dependency chip.
const NO_TASKS: KanbanState["tasks"] = [];
const NO_SNAPSHOTS: Record<string, { tasks?: KanbanState["tasks"] }> = {};
const NO_MRS: TaskMR[] = [];
const NO_MRS_BY_TASK_ID: Record<string, TaskMR[]> = {};
const NO_PRS: TaskPR[] = [];
const NO_PRS_BY_TASK_ID: Record<string, TaskPR[]> = {};
export type TaskCreateDependenciesProps = {
/** Selected predecessor task IDs. */
@@ -56,32 +69,60 @@ function useBoardTasks(): KanbanState["tasks"] {
}, [boardTasks, snapshots]);
}
function useMRsByTaskId(): Record<string, TaskMR[]> {
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
const byWorkspaceId = useAppStore((state) => state.taskMRs.byWorkspaceId);
return (activeWorkspaceId && byWorkspaceId[activeWorkspaceId]) || NO_MRS_BY_TASK_ID;
}
function usePRsByTaskId(): Record<string, TaskPR[]> {
return useAppStore((state) => {
const activeWorkspaceId = state.workspaces.activeId;
if (
!activeWorkspaceId ||
state.taskPRs.workspaceId !== activeWorkspaceId ||
state.taskPRs.workspaceContextGeneration !== state.workspaceContextGeneration
) {
return NO_PRS_BY_TASK_ID;
}
return state.taskPRs.byTaskId;
});
}
function DependencyTriggerLabel({
value,
selectedTitle,
selectedNumber,
t,
}: {
value: string[];
selectedTitle?: string;
t: (key: string, options?: { count?: number }) => string;
selectedNumber?: number;
t: (key: string, options?: { count?: number; number?: number }) => string;
}) {
if (value.length === 0) return t("task:noDependency");
if (value.length === 1 && selectedTitle) return selectedTitle;
if (value.length === 1 && selectedTitle) {
if (selectedNumber === undefined) return selectedTitle;
return `${t("task:dependencyChangeRequestLabel", { number: selectedNumber })} · ${selectedTitle}`;
}
return t("task:dependencyCount", { count: value.length });
}
function DependencyOption({
task,
numbers,
selected,
onSelect,
}: {
task: KanbanState["tasks"][number];
numbers: number[];
selected: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
return (
<CommandItem
value={`${task.title} ${task.id}`}
value={dependencyOptionValue(task, numbers)}
onSelect={onSelect}
className="min-h-12 cursor-pointer gap-2 md:min-h-11"
data-testid={`task-create-dependency-option-${task.id}`}
@@ -92,7 +133,16 @@ function DependencyOption({
data-testid="task-create-dependency-task-icon"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate">{task.title}</span>
<span className="min-w-0 flex-1 line-clamp-2">{task.title}</span>
{numbers.map((number) => (
<span
key={number}
className="shrink-0 text-xs text-muted-foreground"
aria-label={t("task:dependencyChangeRequestAria", { number })}
>
{t("task:dependencyChangeRequestLabel", { number })}
</span>
))}
<IconCheck
className={cn("h-4 w-4 shrink-0", selected ? "opacity-100" : "opacity-0")}
aria-hidden="true"
@@ -103,6 +153,8 @@ function DependencyOption({
type DependencyPickerContentProps = {
candidates: KanbanState["tasks"];
mrsByTaskId: Record<string, TaskMR[]>;
prsByTaskId: Record<string, TaskPR[]>;
value: string[];
onChange: (next: string[]) => void;
setOpen: (open: boolean) => void;
@@ -111,6 +163,8 @@ type DependencyPickerContentProps = {
function DependencyPickerContent({
candidates,
mrsByTaskId,
prsByTaskId,
value,
onChange,
setOpen,
@@ -120,7 +174,7 @@ function DependencyPickerContent({
return (
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[min(320px,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] p-0"
className="w-[var(--radix-popover-trigger-width)] min-w-[min(420px,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] p-0"
data-testid="task-create-dependencies-popover"
portalContainer={portalContainer}
onWheel={(event) => event.stopPropagation()}
@@ -128,7 +182,7 @@ function DependencyPickerContent({
<Command>
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1">
<CommandInput placeholder={t("task:searchTasks")} />
<CommandInput placeholder={t("task:searchTasksOrChangeRequest")} />
</div>
<Tooltip>
<TooltipTrigger asChild>
@@ -173,6 +227,11 @@ function DependencyPickerContent({
<DependencyOption
key={task.id}
task={task}
numbers={changeRequestNumbers(
task,
mrsByTaskId[task.id] ?? NO_MRS,
prsByTaskId[task.id] ?? NO_PRS,
)}
selected={value.includes(task.id)}
onSelect={() => {
onChange(
@@ -194,17 +253,39 @@ export function TaskCreateDependencies({ value, onChange, disabled }: TaskCreate
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const portalContainer = useTaskCreateDialogPopoverContainer();
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
// The global new-task dialog is available outside the Kanban and Tasks
// routes, which normally hydrate these association stores.
useWorkspacePRs(activeWorkspaceId);
useWorkspaceMRs(activeWorkspaceId);
const tasks = useBoardTasks();
const mrsByTaskId = useMRsByTaskId();
const prsByTaskId = usePRsByTaskId();
const selectedTitle = useMemo(() => {
const selectedTask = useMemo(() => {
if (value.length !== 1) return undefined;
return tasks.find((task) => task.id === value[0])?.title;
return tasks.find((task) => task.id === value[0]);
}, [tasks, value]);
const selectedNumber = useMemo(() => {
if (!selectedTask) return undefined;
return changeRequestNumbers(
selectedTask,
mrsByTaskId[selectedTask.id] ?? NO_MRS,
prsByTaskId[selectedTask.id] ?? NO_PRS,
)[0];
}, [selectedTask, mrsByTaskId, prsByTaskId]);
const candidates = useMemo(
() => tasks.filter((task) => !task.isArchived).sort((a, b) => a.title.localeCompare(b.title)),
() => tasks.filter((task) => !task.isArchived).sort(compareDependencyCandidates),
[tasks],
);
const triggerLabel = <DependencyTriggerLabel value={value} selectedTitle={selectedTitle} t={t} />;
const triggerLabel = (
<DependencyTriggerLabel
value={value}
selectedTitle={selectedTask?.title}
selectedNumber={selectedNumber}
t={t}
/>
);
return (
<div className="min-w-0" data-testid="task-create-dependencies">
@@ -239,6 +320,8 @@ export function TaskCreateDependencies({ value, onChange, disabled }: TaskCreate
</PopoverTrigger>
<DependencyPickerContent
candidates={candidates}
mrsByTaskId={mrsByTaskId}
prsByTaskId={prsByTaskId}
value={value}
onChange={onChange}
setOpen={setOpen}
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import type { KanbanState } from "@/lib/state/slices/kanban/types";
import type { TaskMR } from "@/lib/types/gitlab";
import type { TaskPR } from "@/lib/types/github";
import {
changeRequestNumbers,
compareDependencyCandidates,
dependencyOptionValue,
} from "./task-create-dialog-dependency-search";
type Task = KanbanState["tasks"][number];
function task(overrides: Partial<Task> = {}): Task {
return {
id: "task-1",
workflowId: "workflow-1",
workflowStepId: "step-1",
title: "Task title",
position: 0,
isArchived: false,
...overrides,
};
}
function mr(mrIid: number, overrides: Partial<TaskMR> = {}): TaskMR {
return {
task_id: "task-1",
mr_iid: mrIid,
...overrides,
} as TaskMR;
}
function pr(prNumber: number, overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: `pr-assoc-${prNumber}`,
task_id: "task-1",
pr_number: prNumber,
...overrides,
} as TaskPR;
}
describe("changeRequestNumbers", () => {
it("returns the GitHub PR number from statusSummary", () => {
const t = task({ statusSummary: { pull_request: { number: 3295, state: "open" } } as never });
expect(changeRequestNumbers(t, [])).toEqual([3295]);
});
it("returns GitLab MR numbers", () => {
const t = task();
expect(changeRequestNumbers(t, [mr(12), mr(7)])).toEqual([7, 12]);
});
it("merges and de-duplicates GitHub and GitLab numbers, ascending", () => {
const t = task({ statusSummary: { pull_request: { number: 12, state: "open" } } as never });
expect(changeRequestNumbers(t, [mr(12), mr(7)])).toEqual([7, 12]);
});
it("returns an empty array when neither source has a number", () => {
const t = task();
expect(changeRequestNumbers(t, [])).toEqual([]);
});
it("includes every linked GitHub PR number for a multi-repo task, de-duplicated with statusSummary", () => {
const t = task({ statusSummary: { pull_request: { number: 1001, state: "open" } } as never });
expect(changeRequestNumbers(t, [], [pr(1001), pr(1002)])).toEqual([1001, 1002]);
});
it("merges GitHub PR, GitLab MR, and statusSummary numbers, ascending", () => {
const t = task({ statusSummary: { pull_request: { number: 12, state: "open" } } as never });
expect(changeRequestNumbers(t, [mr(7)], [pr(20)])).toEqual([7, 12, 20]);
});
});
describe("dependencyOptionValue", () => {
it("includes the title, id, and both '#N' and bare 'N' forms for each number", () => {
const t = task({ id: "task-1", title: "Fix login" });
const value = dependencyOptionValue(t, [3295]);
expect(value).toContain("Fix login");
expect(value).toContain("task-1");
expect(value).toContain("#3295");
expect(value).toContain("3295");
});
it("has no change-request tokens when there are no numbers", () => {
const t = task({ id: "task-1", title: "Fix login" });
expect(dependencyOptionValue(t, [])).toBe("Fix login task-1");
});
});
const EARLY = "2026-01-01T00:00:00Z";
const LATE = "2026-02-01T00:00:00Z";
describe("compareDependencyCandidates", () => {
it("orders by updatedAt descending", () => {
const a = task({ id: "a", updatedAt: EARLY });
const b = task({ id: "b", updatedAt: LATE });
expect(compareDependencyCandidates(a, b)).toBeGreaterThan(0);
expect(compareDependencyCandidates(b, a)).toBeLessThan(0);
});
it("uses createdAt to break equal updatedAt timestamps", () => {
const a = task({ id: "a", updatedAt: EARLY, createdAt: LATE });
const b = task({ id: "b", updatedAt: EARLY, createdAt: EARLY });
expect(compareDependencyCandidates(a, b)).toBeLessThan(0);
});
it("falls back to createdAt descending when updatedAt is missing", () => {
const a = task({ id: "a", createdAt: EARLY });
const b = task({ id: "b", createdAt: LATE });
expect(compareDependencyCandidates(a, b)).toBeGreaterThan(0);
});
it("falls back to title when both timestamps are missing", () => {
const a = task({ id: "a", title: "Bravo" });
const b = task({ id: "b", title: "Alpha" });
expect(compareDependencyCandidates(a, b)).toBeGreaterThan(0);
});
it("treats a task with a timestamp as more recent than one without", () => {
const a = task({ id: "a", updatedAt: EARLY });
const b = task({ id: "b" });
expect(compareDependencyCandidates(a, b)).toBeLessThan(0);
});
it("falls back to createdAt when updatedAt is malformed", () => {
const a = task({ id: "a", updatedAt: "not-a-date", createdAt: EARLY });
const b = task({ id: "b", updatedAt: LATE });
expect(compareDependencyCandidates(a, b)).toBeGreaterThan(0);
});
it("falls back to title when both timestamps are malformed", () => {
const a = task({ id: "a", title: "Bravo", updatedAt: "not-a-date" });
const b = task({ id: "b", title: "Alpha", createdAt: "also-not-a-date" });
expect(compareDependencyCandidates(a, b)).toBeGreaterThan(0);
});
});
@@ -0,0 +1,52 @@
import type { KanbanState } from "@/lib/state/slices/kanban/types";
import type { TaskMR } from "@/lib/types/gitlab";
import type { TaskPR } from "@/lib/types/github";
import { taskPRInfoFromSummary } from "@/components/task/task-pr-info";
type Task = KanbanState["tasks"][number];
/** GitHub PR + GitLab MR numbers linked to a task, de-duplicated and ascending. */
export function changeRequestNumbers(
task: Task,
mrsForTask: TaskMR[],
prsForTask: TaskPR[] = [],
): number[] {
const numbers = new Set<number>();
const prNumber = taskPRInfoFromSummary(task.statusSummary)?.number;
if (prNumber) numbers.add(prNumber);
for (const pr of prsForTask) numbers.add(pr.pr_number);
for (const mr of mrsForTask) numbers.add(mr.mr_iid);
return [...numbers].sort((a, b) => a - b);
}
/** cmdk search key: title, id, and both '#N' and bare 'N' for each change-request number. */
export function dependencyOptionValue(task: Task, numbers: number[]): string {
const parts = [task.title, task.id, ...numbers.map((n) => `#${n} ${n}`)];
return parts.join(" ");
}
function timestampMs(task: Task): number | undefined {
const updated = task.updatedAt ? Date.parse(task.updatedAt) : NaN;
if (Number.isFinite(updated)) return updated;
return createdAtMs(task);
}
function createdAtMs(task: Task): number | undefined {
const created = task.createdAt ? Date.parse(task.createdAt) : NaN;
return Number.isFinite(created) ? created : undefined;
}
/** Most-recently-updated first, using createdAt and then title as tie-breakers. */
export function compareDependencyCandidates(a: Task, b: Task): number {
const aTime = timestampMs(a);
const bTime = timestampMs(b);
if (aTime !== undefined && bTime !== undefined && aTime !== bTime) return bTime - aTime;
if (aTime !== undefined && bTime === undefined) return -1;
if (aTime === undefined && bTime !== undefined) return 1;
const aCreated = createdAtMs(a);
const bCreated = createdAtMs(b);
if (aCreated !== undefined && bCreated !== undefined && aCreated !== bCreated) {
return bCreated - aCreated;
}
return a.title.localeCompare(b.title);
}
@@ -1,4 +1,6 @@
import type { Locator } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
import { GITLAB_HOST, GITLAB_PROJECT, gitLabMR } from "../../helpers/gitlab";
import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
@@ -6,6 +8,7 @@ useRegularMode();
const DEPENDENCY_TRIGGER = "task-create-dependencies-trigger";
const DEPENDENCY_OPTION_PREFIX = "task-create-dependency-option-";
const SEARCH_PLACEHOLDER = "Search tasks or #PR/MR number...";
function taskIdFromUrl(url: string): string {
const match = url.match(/\/t\/([^/?]+)/);
@@ -13,6 +16,16 @@ function taskIdFromUrl(url: string): string {
return match[1];
}
async function openDependencyPicker(
dialog: Locator,
): Promise<{ dependency: Locator; picker: Locator }> {
const dependency = dialog.getByTestId(DEPENDENCY_TRIGGER);
await dependency.click();
const picker = dialog.getByTestId("task-create-dependencies-popover");
await expect(picker).toBeVisible();
return { dependency, picker };
}
test.describe("Task-create dependency selector", () => {
test("selects, clears, and persists multiple predecessor tasks", async ({
testPage,
@@ -114,7 +127,7 @@ test.describe("Task-create dependency selector", () => {
"This task waits until every selected task completes successfully.",
);
const search = picker.getByPlaceholder("Search tasks...");
const search = picker.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill("Dependency Alpha");
const alphaOption = picker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${alpha.id}`);
await expect(alphaOption).toBeVisible();
@@ -160,4 +173,112 @@ test.describe("Task-create dependency selector", () => {
await apiClient.deleteWorkflow(extraWorkflow.id).catch(() => {});
}
});
test("finds a candidate by its linked GitHub PR number and shows the badge", async ({
testPage,
apiClient,
seedData,
}) => {
const taskOptions = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
};
const withPR = await apiClient.createTask(
seedData.workspaceId,
"Task with a linked PR",
taskOptions,
);
await apiClient.createTask(seedData.workspaceId, "Task without a linked PR", taskOptions);
await apiClient.mockGitHubAssociateTaskPR({
workspace_id: seedData.workspaceId,
task_id: withPR.id,
owner: "kandev-e2e",
repo: "dependency-fixtures",
pr_number: 188,
pr_url: "https://github.test/kandev-e2e/dependency-fixtures/pull/188",
pr_title: "Dependency fixture PR",
head_branch: "feature/dependency-fixture",
base_branch: "main",
author_login: "e2e",
state: "open",
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
await dialog.getByTestId("task-create-advanced-settings-trigger").click();
const { dependency, picker } = await openDependencyPicker(dialog);
const option = picker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${withPR.id}`);
const search = picker.getByPlaceholder(SEARCH_PLACEHOLDER);
// Search by bare number.
await search.fill("188");
await expect(option).toBeVisible({ timeout: 15_000 });
await expect(option.locator('[aria-label="Linked pull or merge request #188"]')).toHaveText(
"#188",
);
// Search with a leading '#' matches the same candidate.
await search.fill("#188");
await expect(option).toBeVisible();
await option.click();
await expect(dependency).toContainText("#188 · Task with a linked PR");
});
test("finds a candidate by its linked GitLab MR number and shows the badge", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.configureGitLab(seedData.workspaceId, GITLAB_HOST);
const iid = 4321;
await apiClient.mockGitLabAddMRs(seedData.workspaceId, GITLAB_PROJECT, [
gitLabMR(iid, "Dependency fixture MR"),
]);
await apiClient.updateRepository(seedData.repositoryId, {
provider: "gitlab",
provider_host: GITLAB_HOST,
provider_owner: "platform",
provider_name: "kandev",
});
const taskOptions = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
};
const withMR = await apiClient.createTask(
seedData.workspaceId,
"Task with a linked MR",
taskOptions,
);
await apiClient.linkTaskGitLabMR(seedData.workspaceId, {
task_id: withMR.id,
repository_id: seedData.repositoryId,
mr_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
await dialog.getByTestId("task-create-advanced-settings-trigger").click();
const { dependency, picker } = await openDependencyPicker(dialog);
const option = picker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${withMR.id}`);
const search = picker.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill(String(iid));
await expect(option).toBeVisible({ timeout: 15_000 });
await expect(option.locator(`[aria-label="Linked pull or merge request #${iid}"]`)).toHaveText(
`#${iid}`,
);
await option.click();
await expect(dependency).toContainText(`#${iid} · Task with a linked MR`);
});
});
@@ -24,6 +24,19 @@ test.describe("Create task dependency selector on mobile", () => {
repository_ids: [seedData.repositoryId],
},
);
await apiClient.mockGitHubAssociateTaskPR({
workspace_id: seedData.workspaceId,
task_id: predecessor.id,
owner: "kandev-e2e",
repo: "dependency-fixtures",
pr_number: 188,
pr_url: "https://github.test/kandev-e2e/dependency-fixtures/pull/188",
pr_title: "Dependency fixture PR",
head_branch: "feature/dependency-fixture",
base_branch: "main",
author_login: "e2e",
state: "open",
});
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
@@ -88,8 +101,14 @@ test.describe("Create task dependency selector on mobile", () => {
const option = picker.getByTestId(`task-create-dependency-option-${predecessor.id}`);
await expect(option).toBeVisible();
await expect(option.getByTestId("task-create-dependency-task-icon")).toBeVisible();
const search = picker.getByPlaceholder("Search tasks or #PR/MR number...");
await search.fill("188");
await expect(option).toBeVisible();
await expect(option.locator('[aria-label="Linked pull or merge request #188"]')).toHaveText(
"#188",
);
await option.tap();
await expect(trigger).toContainText("Mobile dependency predecessor");
await expect(trigger).toContainText("#188 · Mobile dependency predecessor");
await assertNoDocumentHorizontalOverflow(testPage, "mobile dependency selection");
});
});
+3
View File
@@ -347,6 +347,8 @@
"dependencyBlockedBy": "Blocked by",
"dependencyBlocks": "Blocks",
"dependencyChainHalted": "A task this one depends on failed. The chain is stopped until you retry it or remove the dependency.",
"dependencyChangeRequestAria": "Linked pull or merge request #{{number}}",
"dependencyChangeRequestLabel": "#{{number}}",
"dependencyChipLabel": "Task dependencies",
"dependencyCountBlockedBy_one": "{{count}} blocker",
"dependencyCountBlockedBy_other": "{{count}} blockers",
@@ -1301,6 +1303,7 @@
"searchRepositories": "Search repositories...",
"searchRepositoriesOrPasteARemote": "Search repositories or paste a remote URL",
"searchTasks": "Search tasks...",
"searchTasksOrChangeRequest": "Search tasks or #PR/MR number...",
"searching": "Searching…",
"searchingWorkItems": "Searching work items…",
"selectASessionToOpenIts": "Select a session to open its worktree",
+3
View File
@@ -347,6 +347,8 @@
"dependencyBlockedBy": "Ɓĺōćķēď ƀŷ",
"dependencyBlocks": "Ɓĺōćķś",
"dependencyChainHalted": "À ţàśķ ţĥĩś ōńē ďēƥēńďś ōń ƒàĩĺēď. Ţĥē ćĥàĩń ĩś śţōƥƥēď ũńţĩĺ ŷōũ ŕēţŕŷ ĩţ ōŕ ŕēḿōvē ţĥē ďēƥēńďēńćŷ.",
"dependencyChangeRequestAria": "Ĺĩńķēď ƥũĺĺ ōŕ ḿēŕĝē ŕēqũēśţ #{{number}}",
"dependencyChangeRequestLabel": "#{{number}}",
"dependencyChipLabel": "Ţàśķ ďēƥēńďēńćĩēś",
"dependencyCountBlockedBy_one": "{{count}} ƀĺōćķēŕ",
"dependencyCountBlockedBy_other": "{{count}} ƀĺōćķēŕś",
@@ -1301,6 +1303,7 @@
"searchRepositories": "Śēàŕćĥ ŕēƥōśĩţōŕĩēś...",
"searchRepositoriesOrPasteARemote": "Śēàŕćĥ ŕēƥōśĩţōŕĩēś ōŕ ƥàśţē à ŕēḿōţē ŨŔĹ",
"searchTasks": "Śēàŕćĥ ţàśķś...",
"searchTasksOrChangeRequest": "Śēàŕćĥ ţàśķś ōŕ #ƤŔ/ḾŔ ńũḿƀēŕ...",
"searching": "Śēàŕćĥĩńĝ…",
"searchingWorkItems": "Śēàŕćĥĩńĝ ŵōŕķ ĩţēḿś…",
"selectASessionToOpenIts": "Śēĺēćţ à śēśśĩōń ţō ōƥēń ĩţś ŵōŕķţŕēē",
+3
View File
@@ -1250,6 +1250,7 @@
"searchRepositories": "Pesquisar repositórios...",
"searchRepositoriesOrPasteARemote": "Pesquise repositórios ou cole um URL remoto",
"searchTasks": "Pesquisar tarefas...",
"searchTasksOrChangeRequest": "Pesquisar tarefas ou número de PR/MR...",
"searching": "A pesquisar…",
"searchingWorkItems": "A pesquisar itens de trabalho…",
"selectASessionToOpenIts": "Selecione uma sessão para abrir a respetiva worktree",
@@ -1796,6 +1797,8 @@
"dependencyBlockedBy": "Bloqueada por",
"dependencyBlocks": "Bloqueia",
"dependencyChainHalted": "Uma tarefa de que esta depende falhou. A cadeia fica parada até a repetir ou remover a dependência.",
"dependencyChangeRequestAria": "Pull request ou merge request associado #{{number}}",
"dependencyChangeRequestLabel": "#{{number}}",
"dependencyChipLabel": "Dependências da tarefa",
"dependencyCount_one": "{{count}} dependência",
"dependencyCount_other": "{{count}} dependências",
+3
View File
@@ -1249,6 +1249,7 @@
"searchRepositories": "搜索仓库…",
"searchRepositoriesOrPasteARemote": "搜索仓库或粘贴远程 URL",
"searchTasks": "搜索任务…",
"searchTasksOrChangeRequest": "搜索任务或 PR/MR 编号…",
"searching": "正在搜索…",
"searchingWorkItems": "正在搜索工作项…",
"selectASessionToOpenIts": "选择会话以打开其工作树",
@@ -1796,6 +1797,8 @@
"dependencyBlockedBy": "被阻塞于",
"dependencyBlocks": "阻塞",
"dependencyChainHalted": "该任务依赖的某个任务失败了。在你重试或移除该依赖之前,整条链都会停住。",
"dependencyChangeRequestAria": "关联的拉取请求或合并请求 #{{number}}",
"dependencyChangeRequestLabel": "#{{number}}",
"dependencyChipLabel": "任务依赖",
"dependencyCount_one": "{{count}} 个依赖",
"dependencyCount_other": "{{count}} 个依赖",
+3
View File
@@ -1249,6 +1249,7 @@
"searchRepositories": "搜尋儲存庫…",
"searchRepositoriesOrPasteARemote": "搜尋儲存庫或貼上遠端 URL",
"searchTasks": "搜尋任務…",
"searchTasksOrChangeRequest": "搜尋任務或 PR/MR 編號…",
"searching": "正在搜尋…",
"searchingWorkItems": "正在搜尋工作項…",
"selectASessionToOpenIts": "選擇工作階段以開啟其工作樹",
@@ -1796,6 +1797,8 @@
"dependencyBlockedBy": "被阻塞於",
"dependencyBlocks": "阻塞",
"dependencyChainHalted": "該任務依賴的某個任務失敗了。在你重試或移除該依賴之前,整條鏈都會停住。",
"dependencyChangeRequestAria": "關聯的拉取要求或合併請求 #{{number}}",
"dependencyChangeRequestLabel": "#{{number}}",
"dependencyChipLabel": "任務依賴",
"dependencyCount_one": "{{count}} 個依賴",
"dependencyCount_other": "{{count}} 個依賴",
+3
View File
@@ -1249,6 +1249,7 @@
"searchRepositories": "搜尋儲存庫…",
"searchRepositoriesOrPasteARemote": "搜尋儲存庫或貼上遠端 URL",
"searchTasks": "搜尋任務…",
"searchTasksOrChangeRequest": "搜尋任務或 PR/MR 編號…",
"searching": "正在搜尋…",
"searchingWorkItems": "正在搜尋工作項…",
"selectASessionToOpenIts": "選擇工作階段以開啟其工作樹",
@@ -1796,6 +1797,8 @@
"dependencyBlockedBy": "被阻塞於",
"dependencyBlocks": "阻塞",
"dependencyChainHalted": "該任務依賴的某個任務失敗了。在你重試或移除該依賴之前,整條鏈都會停住。",
"dependencyChangeRequestAria": "關聯的提取要求或合併請求 #{{number}}",
"dependencyChangeRequestLabel": "#{{number}}",
"dependencyChipLabel": "任務依賴",
"dependencyCount_one": "{{count}} 個依賴",
"dependencyCount_other": "{{count}} 個依賴",
@@ -85,7 +85,9 @@ place for dependency selection and future less-common options.
- The row exposes `aria-expanded="true"`, and the expanded content is connected
to it through the normal collapsible relationship.
- Closing the section preserves every selected predecessor. Reopening it
reveals the existing selected title or localized count.
reveals the existing selected task's trigger label (its title, or
`#N · Title` when the task has a linked change-request number) or the
localized dependency count for multiple predecessors.
- The selector's search-row info control continues to explain dependency wait
behavior without changing the disclosure state or selection.
@@ -124,8 +126,10 @@ place for dependency selection and future less-common options.
- **GIVEN** the expanded selector with no predecessors, **THEN** its trigger
shows the existing `No dependency` label and dependency icon.
- **GIVEN** a selected predecessor, **WHEN** the user collapses and reopens the
advanced section, **THEN** the selected task title or localized dependency
count remains visible in the selector and the form state is unchanged.
advanced section, **THEN** the selected task's trigger label (its title, its
`#N · Title` change-request form, or a localized dependency count for
multiple predecessors) remains visible in the selector and the form state is
unchanged.
- **GIVEN** a workflow selector that is visible, hidden for a single workflow,
or locked by a caller, **WHEN** the create dialog renders, **THEN** the
workflow's existing behavior is preserved and the advanced dependency
@@ -40,13 +40,22 @@ set of tasks.
ability to choose multiple predecessor tasks by toggling task entries in the
same selector.
- With no predecessor selected, the trigger shows `No dependency` and the
dependency icon. With one predecessor selected, it shows that task's title.
With multiple predecessors selected, it shows a localized dependency count.
dependency icon. With one predecessor selected, it shows that task's title,
prefixed with a localized `#N ·` change-request label when the task has a
linked GitHub PR or GitLab MR number. With multiple predecessors selected,
it shows a localized dependency count.
- The selector includes a `No dependency` entry that clears every selected
predecessor. A selected task can be removed by toggling its entry off.
- Every task entry in the selector displays a task icon. Archived tasks are not
offered as candidates, and the existing candidate sources from both Kanban
board slices remain available.
- Every task entry in the selector displays a task icon. A task with a linked
GitHub PR or GitLab MR number also displays a localized `#N` change-request
badge for each linked number. Archived tasks are not offered as candidates,
and the existing candidate sources from both Kanban board slices remain
available.
- Candidates are ordered most-recently-updated first, falling back to
most-recently-created and then title when timestamps are unavailable or
equal.
- The selector's search field matches task title, task id, and any linked
change-request number in either `#N` or bare `N` form.
- The selector's search row has an info control on its right side. Hovering or
focusing the control explains that dependencies wait for all selected tasks
to finish successfully and that starting an agent starts this task
@@ -104,6 +113,13 @@ set of tasks.
the current task selection, and Escape or outside interaction dismisses it.
- **GIVEN** an archived board task, **WHEN** the user searches the dependency
selector, **THEN** the archived task is not offered.
- **GIVEN** a candidate task with a linked GitHub PR or GitLab MR number,
**WHEN** the user opens the selector, **THEN** that task's option shows a
`#N` change-request badge, and selecting it as the sole predecessor shows
the trigger as `#N · Title`.
- **GIVEN** a candidate task with a linked change-request number, **WHEN** the
user types that number or `#number` into the search field, **THEN** the
matching task is offered.
- **GIVEN** a Pixel 5-sized viewport, **WHEN** the user opens the create dialog
and dependency selector, **THEN** the trigger and task rows remain reachable,
the picker stays within the viewport, and the document has no horizontal