diff --git a/apps/web/components/kanban/swimlane-container.test.ts b/apps/web/components/kanban/swimlane-container.test.ts index 0ebabac7ff..7a4c2f1ff2 100644 --- a/apps/web/components/kanban/swimlane-container.test.ts +++ b/apps/web/components/kanban/swimlane-container.test.ts @@ -65,6 +65,111 @@ describe("filterTasks — plugin task filter predicate", () => { }); }); +describe("filterTasks — searchQuery matches linked PR/MR numbers", () => { + const NO_REPO_FILTER = mapSelectedRepositoryIds([], []); + + it("keeps a task matching only by its linked PR/MR number", () => { + const snapshots = { + wf: { + tasks: [ + makeTask({ id: "1", title: "Fix EvaluateOnly", description: "unrelated" }), + makeTask({ id: "2", title: "on_agent_error never fires", description: "" }), + ], + steps: [], + }, + }; + + const result = filterTasks(snapshots, "wf", NO_REPO_FILTER, { + searchQuery: "3315", + vcsSearchTextByTaskId: { "2": "#3315" }, + }); + + expect(result.map((t) => t.id)).toEqual(["2"]); + }); + + it("matches a linked PR number by either a bare number or a #-prefixed query", () => { + const snapshots = { + wf: { + tasks: [makeTask({ id: "1", title: "Task" })], + steps: [], + }, + }; + const opts = { vcsSearchTextByTaskId: { "1": "#3315" } }; + + expect( + filterTasks(snapshots, "wf", NO_REPO_FILTER, { searchQuery: "3315", ...opts }).map( + (t) => t.id, + ), + ).toEqual(["1"]); + expect( + filterTasks(snapshots, "wf", NO_REPO_FILTER, { searchQuery: "#3315", ...opts }).map( + (t) => t.id, + ), + ).toEqual(["1"]); + }); + + it("matches the status-summary PR number before associations load", () => { + const snapshots = { + wf: { + tasks: [ + makeTask({ + id: "1", + title: "Task", + statusSummary: { pull_request: { number: 3295 } } as never, + }), + ], + steps: [], + }, + }; + + const result = filterTasks(snapshots, "wf", NO_REPO_FILTER, { + searchQuery: "#3295", + }); + + expect(result.map((t) => t.id)).toEqual(["1"]); + }); + + it("excludes a task whose linked PR number does not match the query", () => { + const snapshots = { + wf: { + tasks: [makeTask({ id: "1", title: "Task" })], + steps: [], + }, + }; + + const result = filterTasks(snapshots, "wf", NO_REPO_FILTER, { + searchQuery: "9999", + vcsSearchTextByTaskId: { "1": "#3315" }, + }); + + expect(result.map((t) => t.id)).toEqual([]); + }); + + it("composes the PR/MR search arm with repository and plugin filters", () => { + const snapshots = { + wf: { + tasks: [ + makeTask({ id: "1", title: "Task A", repositoryId: "repo-a" }), + makeTask({ id: "2", title: "Task B", repositoryId: "repo-b" }), + ], + steps: [], + }, + }; + const repoFilter = mapSelectedRepositoryIds( + [{ id: "repo-a", name: "A" } as never, { id: "repo-b", name: "B" } as never], + ["repo-a"], + ); + + const result = filterTasks(snapshots, "wf", repoFilter, { + searchQuery: "3315", + vcsSearchTextByTaskId: { "1": "#3315", "2": "#3315" }, + matchesPluginTaskFilters: (taskId) => taskId === "1", + }); + + expect(result.map((t) => t.id)).toEqual(["1"]); + }); +}); + describe("filterTasks — hiddenStepIds (per-workflow step visibility filter)", () => { const NO_REPO_FILTER = mapSelectedRepositoryIds([], []); const snapshots = { diff --git a/apps/web/components/kanban/swimlane-container.tsx b/apps/web/components/kanban/swimlane-container.tsx index b9a292ce3b..6b50daa36d 100644 --- a/apps/web/components/kanban/swimlane-container.tsx +++ b/apps/web/components/kanban/swimlane-container.tsx @@ -26,6 +26,10 @@ import { CSS } from "@dnd-kit/utilities"; import { useAppStore } from "@/components/state-provider"; import { useSwimlaneCollapse } from "@/hooks/domains/kanban/use-swimlane-collapse"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { + buildTaskVcsSearchIndex, + getTaskPRsByTaskIdForCurrentWorkspace, +} from "@/lib/kanban/task-search-index"; import { selectVisibleWorkflows } from "@/lib/kanban/workflow-swimlanes"; import { reorderWorkflows } from "@/lib/api"; import { SwimlaneSection } from "./swimlane-section"; @@ -107,11 +111,14 @@ function renderEmptyState(emptyMessage: string) { const EMPTY_SELECTED_REPOSITORY_IDS: string[] = []; const EMPTY_WORKFLOW_STEPS: WorkflowSnapshotData["steps"] = []; const WORKFLOW_POINTER_SENSOR_OPTIONS = { activationConstraint: { distance: 8 } }; +/** Stable identity so a workspace with no GitLab MRs does not re-trigger downstream memos. */ +const EMPTY_TASK_MRS_BY_TASK_ID: Record = {}; type WorkflowItemProps = { wf: { id: string; name: string }; repoFilter: Set; searchQuery: string; + vcsSearchTextByTaskId?: Record; matchesPluginTaskFilters?: (taskId: string) => boolean; ViewComponent: ComponentType; hideHeader: boolean; @@ -187,6 +194,7 @@ const WorkflowItemContent = memo(function WorkflowItemContent({ wf, repoFilter, searchQuery, + vcsSearchTextByTaskId, matchesPluginTaskFilters, ViewComponent, hideHeader, @@ -200,7 +208,13 @@ const WorkflowItemContent = memo(function WorkflowItemContent({ ...viewProps }: WorkflowItemContentProps) { const { snapshot, tasks, occupancyTasks, hiddenStepIds, hiddenSet, autoHideEmpty } = - useWorkflowSwimlaneData(wf.id, repoFilter, searchQuery, matchesPluginTaskFilters); + useWorkflowSwimlaneData( + wf.id, + repoFilter, + searchQuery, + matchesPluginTaskFilters, + vcsSearchTextByTaskId, + ); const snapshotSteps = snapshot?.steps ?? EMPTY_WORKFLOW_STEPS; const derivedAutoHiddenSet = useMemo( () => deriveAutoHiddenStepIds(snapshotSteps, occupancyTasks, autoHideEmpty, hiddenStepIds), @@ -291,6 +305,27 @@ function useWorkflowReorder( return { sensors, canSort, handleDragEnd }; } +/** One lowercase `#` haystack per task, built from its linked PRs/MRs in the active workspace. */ +function useVcsSearchIndex() { + const taskPRsByTaskId = useAppStore((state) => + getTaskPRsByTaskIdForCurrentWorkspace( + state.taskPRs, + state.workspaces.activeId, + state.workspaceContextGeneration, + ), + ); + const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); + const taskMRsByTaskId = useAppStore( + (state) => + (activeWorkspaceId && state.taskMRs.byWorkspaceId[activeWorkspaceId]) || + EMPTY_TASK_MRS_BY_TASK_ID, + ); + return useMemo( + () => buildTaskVcsSearchIndex(taskPRsByTaskId, taskMRsByTaskId), + [taskMRsByTaskId, taskPRsByTaskId], + ); +} + function getRenderedWorkflows( isMobileKanban: boolean, focusedWorkflowId: string | null, @@ -319,6 +354,7 @@ type WorkflowItemsProps = { workflows: { id: string; name: string }[]; repoFilter: Set; searchQuery: string; + vcsSearchTextByTaskId?: Record; matchesPluginTaskFilters?: (taskId: string) => boolean; ViewComponent: ComponentType; hideHeaders: boolean; @@ -337,6 +373,7 @@ function WorkflowItems({ workflows, repoFilter, searchQuery, + vcsSearchTextByTaskId, matchesPluginTaskFilters, ViewComponent, hideHeaders, @@ -358,6 +395,7 @@ function WorkflowItems({ wf={workflow} repoFilter={repoFilter} searchQuery={searchQuery} + vcsSearchTextByTaskId={vcsSearchTextByTaskId} matchesPluginTaskFilters={matchesPluginTaskFilters} ViewComponent={ViewComponent} hideHeader={hideHeaders} @@ -401,6 +439,52 @@ function usePublishMobileFocus(focusedWorkflowId: string | null) { }, [focusedWorkflowId, setMobileKanbanFocusedWorkflow]); } +type RenderedWorkflowLayoutOptions = { + workflowFilter: string | null; + orderedWorkflows: { id: string; name: string }[]; + getFilteredTasks: (workflowId: string) => Task[]; + hasLiveHiddenSteps: (workflowId: string) => boolean; + isMobileKanban: boolean; + workflowOptions: MobileWorkflowNavigation["workflows"]; + onWorkflowChange: SwimlaneContainerProps["onWorkflowChange"]; +}; + +function useRenderedWorkflowLayout({ + workflowFilter, + orderedWorkflows, + getFilteredTasks, + hasLiveHiddenSteps, + isMobileKanban, + workflowOptions, + onWorkflowChange, +}: RenderedWorkflowLayoutOptions) { + const visibleWorkflows = useStableWorkflowList( + selectVisibleWorkflows({ + workflowFilter, + orderedWorkflows, + hasTasks: (workflowId) => getFilteredTasks(workflowId).length > 0, + hasLiveHiddenSteps, + showEmptyBoard: isMobileKanban, + }), + ); + const focusedWorkflowId = visibleWorkflows[0]?.id ?? null; + usePublishMobileFocus(isMobileKanban ? focusedWorkflowId : null); + const renderedWorkflows = useStableWorkflowList( + getRenderedWorkflows(isMobileKanban, focusedWorkflowId, visibleWorkflows), + ); + const sortableWorkflowIds = useMemo( + () => renderedWorkflows.map((workflow) => workflow.id), + [renderedWorkflows], + ); + const mobileWorkflowNavigation = useMobileWorkflowNavigation( + isMobileKanban, + focusedWorkflowId, + workflowOptions, + onWorkflowChange, + ); + return { visibleWorkflows, renderedWorkflows, sortableWorkflowIds, mobileWorkflowNavigation }; +} + function useMobileWorkflowNavigation( isMobileKanban: boolean, focusedWorkflowId: string | null, @@ -426,6 +510,7 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) { const { isMobile } = useResponsiveBreakpoint(); const { onToggleStepVisibility, onToggleAutoHideEmpty } = useKanbanDisplaySettings(); const { isCollapsed, toggleCollapse } = useSwimlaneCollapse(); + const vcsSearchTextByTaskId = useVcsSearchIndex(); const { snapshots, isLoading, @@ -439,6 +524,7 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) { selectedRepositoryIds, searchQuery ?? "", containerProps.matchesPluginTaskFilters, + vcsSearchTextByTaskId, ); const { sensors: workflowSensors, @@ -448,30 +534,16 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) { const view = getEffectiveView(viewMode, isMobile); const isMobileKanban = isMobile && view.id === "kanban"; - const visibleWorkflows = useStableWorkflowList( - selectVisibleWorkflows({ + const { visibleWorkflows, renderedWorkflows, sortableWorkflowIds, mobileWorkflowNavigation } = + useRenderedWorkflowLayout({ workflowFilter, orderedWorkflows, - hasTasks: (workflowId) => getFilteredTasks(workflowId).length > 0, + getFilteredTasks, hasLiveHiddenSteps, - showEmptyBoard: isMobileKanban, - }), - ); - const focusedWorkflowId = visibleWorkflows[0]?.id ?? null; - usePublishMobileFocus(isMobileKanban ? focusedWorkflowId : null); - const renderedWorkflows = useStableWorkflowList( - getRenderedWorkflows(isMobileKanban, focusedWorkflowId, visibleWorkflows), - ); - const sortableWorkflowIds = useMemo( - () => renderedWorkflows.map((workflow) => workflow.id), - [renderedWorkflows], - ); - const mobileWorkflowNavigation = useMobileWorkflowNavigation( - isMobileKanban, - focusedWorkflowId, - workflowOptions, - containerProps.onWorkflowChange, - ); + isMobileKanban, + workflowOptions, + onWorkflowChange: containerProps.onWorkflowChange, + }); const emptyMessage = getEmptyMessage({ isLoading, @@ -502,6 +574,7 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) { workflows={renderedWorkflows} repoFilter={repoFilter} searchQuery={searchQuery ?? ""} + vcsSearchTextByTaskId={vcsSearchTextByTaskId} matchesPluginTaskFilters={containerProps.matchesPluginTaskFilters} ViewComponent={view.component} hideHeaders={hideHeaders} diff --git a/apps/web/components/task-create-dialog-dependency-search.ts b/apps/web/components/task-create-dialog-dependency-search.ts index 88adfdd9a2..a7e0b1276d 100644 --- a/apps/web/components/task-create-dialog-dependency-search.ts +++ b/apps/web/components/task-create-dialog-dependency-search.ts @@ -1,24 +1,8 @@ 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"; +export { changeRequestNumbers } from "@/lib/kanban/task-search-index"; 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(); - 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: Pick, numbers: number[]): string { const parts = [task.title, task.id, ...numbers.map((n) => `#${n} ${n}`)]; diff --git a/apps/web/e2e/tests/kanban/board-search-pr-number.spec.ts b/apps/web/e2e/tests/kanban/board-search-pr-number.spec.ts new file mode 100644 index 0000000000..3b504ff5be --- /dev/null +++ b/apps/web/e2e/tests/kanban/board-search-pr-number.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; + +test.describe("Kanban board search matches linked PR/MR numbers", () => { + test("searching a linked PR number finds the card by number alone, not by title", async ({ + testPage, + apiClient, + seedData, + }) => { + const stepOptions = { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }; + + // Title deliberately shares no words with the PR number so a match can + // only come from the linked PR, proving the number itself is indexed. + const prTask = await apiClient.createTask( + seedData.workspaceId, + "Board search PR-linked card", + stepOptions, + ); + const unrelatedTask = await apiClient.createTask( + seedData.workspaceId, + "Board search unrelated card", + stepOptions, + ); + + await apiClient.mockGitHubAssociateTaskPR({ + workspace_id: seedData.workspaceId, + task_id: prTask.id, + owner: "kandev-e2e", + repo: "board-search-fixtures", + pr_number: 93315, + pr_url: "https://github.test/kandev-e2e/board-search-fixtures/pull/93315", + pr_title: "Totally unrelated PR title", + head_branch: "feature/board-search", + base_branch: "main", + author_login: "board-search-author", + state: "open", + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await expect(kanban.taskCard(prTask.id)).toBeVisible(); + await expect(kanban.taskCard(unrelatedTask.id)).toBeVisible(); + + const search = testPage.getByTestId("kanban-header-search").getByPlaceholder("Search tasks..."); + + await search.fill("93315"); + await expect(kanban.taskCard(prTask.id)).toBeVisible(); + await expect(kanban.taskCard(unrelatedTask.id)).toBeHidden(); + + // The leading "#" form must match too. + await search.fill("#93315"); + await expect(kanban.taskCard(prTask.id)).toBeVisible(); + await expect(kanban.taskCard(unrelatedTask.id)).toBeHidden(); + + // A number that matches nothing hides both cards. + await search.fill("77777"); + await expect(kanban.taskCard(prTask.id)).toBeHidden(); + await expect(kanban.taskCard(unrelatedTask.id)).toBeHidden(); + + await search.fill(""); + await expect(kanban.taskCard(prTask.id)).toBeVisible(); + await expect(kanban.taskCard(unrelatedTask.id)).toBeVisible(); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-swimlane-render-data.ts b/apps/web/hooks/domains/kanban/use-swimlane-render-data.ts index bbdd77bacb..a7ab0e3cb5 100644 --- a/apps/web/hooks/domains/kanban/use-swimlane-render-data.ts +++ b/apps/web/hooks/domains/kanban/use-swimlane-render-data.ts @@ -19,6 +19,7 @@ type TaskProjectionCacheEntry = { hiddenStepIds: string[] | undefined; repoFilter: Set; searchQuery: string; + vcsSearchTextByTaskId: Record | undefined; matchesPluginTaskFilters: ((taskId: string) => boolean) | undefined; visibleTasks: Task[]; }; @@ -85,11 +86,78 @@ function useOrderedWorkflowLists( return { allOrderedWorkflows, orderedWorkflows }; } +type TaskProjectionCacheOptions = { + snapshots: Record; + hiddenWorkflowStepIds: Record; + repoFilter: Set; + searchQuery: string; + vcsSearchTextByTaskId: Record | undefined; + matchesPluginTaskFilters: ((taskId: string) => boolean) | undefined; +}; + +function useTaskProjectionCache({ + snapshots, + hiddenWorkflowStepIds, + repoFilter, + searchQuery, + vcsSearchTextByTaskId, + matchesPluginTaskFilters, +}: TaskProjectionCacheOptions) { + const projectionCacheRef = useRef(new Map()); + useEffect(() => { + for (const workflowId of projectionCacheRef.current.keys()) { + if (!(workflowId in snapshots)) projectionCacheRef.current.delete(workflowId); + } + }, [snapshots]); + return useCallback( + (workflowId: string) => { + const snapshot = snapshots[workflowId]; + const hiddenStepIds = hiddenWorkflowStepIds[workflowId]; + const cached = projectionCacheRef.current.get(workflowId); + if ( + cached?.snapshot === snapshot && + cached.hiddenStepIds === hiddenStepIds && + cached.repoFilter === repoFilter && + cached.searchQuery === searchQuery && + cached.vcsSearchTextByTaskId === vcsSearchTextByTaskId && + cached.matchesPluginTaskFilters === matchesPluginTaskFilters + ) { + return cached.visibleTasks; + } + const visibleTasks = projectWorkflowTasks(snapshots, workflowId, repoFilter, { + searchQuery, + vcsSearchTextByTaskId, + matchesPluginTaskFilters, + hiddenStepIds: hiddenStepIds?.length ? new Set(hiddenStepIds) : undefined, + }).visibleTasks; + projectionCacheRef.current.set(workflowId, { + snapshot, + hiddenStepIds, + repoFilter, + searchQuery, + vcsSearchTextByTaskId, + matchesPluginTaskFilters, + visibleTasks, + }); + return visibleTasks; + }, + [ + hiddenWorkflowStepIds, + matchesPluginTaskFilters, + repoFilter, + searchQuery, + vcsSearchTextByTaskId, + snapshots, + ], + ); +} + export function useSwimlaneRenderData( workflowFilter: string | null | undefined, selectedRepositoryIds: string[], searchQuery: string, matchesPluginTaskFilters?: (taskId: string) => boolean, + vcsSearchTextByTaskId?: Record, ) { const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); const isLoading = useAppStore((state) => state.kanbanMulti.isLoading); @@ -114,43 +182,14 @@ export function useSwimlaneRenderData( snapshots, ); - const projectionCacheRef = useRef(new Map()); - useEffect(() => { - for (const workflowId of projectionCacheRef.current.keys()) { - if (!(workflowId in snapshots)) projectionCacheRef.current.delete(workflowId); - } - }, [snapshots]); - const getFilteredTasks = useCallback( - (workflowId: string) => { - const snapshot = snapshots[workflowId]; - const hiddenStepIds = hiddenWorkflowStepIds[workflowId]; - const cached = projectionCacheRef.current.get(workflowId); - if ( - cached?.snapshot === snapshot && - cached.hiddenStepIds === hiddenStepIds && - cached.repoFilter === repoFilter && - cached.searchQuery === searchQuery && - cached.matchesPluginTaskFilters === matchesPluginTaskFilters - ) { - return cached.visibleTasks; - } - const visibleTasks = projectWorkflowTasks(snapshots, workflowId, repoFilter, { - searchQuery, - matchesPluginTaskFilters, - hiddenStepIds: hiddenStepIds?.length ? new Set(hiddenStepIds) : undefined, - }).visibleTasks; - projectionCacheRef.current.set(workflowId, { - snapshot, - hiddenStepIds, - repoFilter, - searchQuery, - matchesPluginTaskFilters, - visibleTasks, - }); - return visibleTasks; - }, - [hiddenWorkflowStepIds, matchesPluginTaskFilters, repoFilter, searchQuery, snapshots], - ); + const getFilteredTasks = useTaskProjectionCache({ + snapshots, + hiddenWorkflowStepIds, + repoFilter, + searchQuery, + vcsSearchTextByTaskId, + matchesPluginTaskFilters, + }); const hasLiveHiddenSteps = useCallback( (workflowId: string) => { @@ -196,6 +235,7 @@ export function useWorkflowSwimlaneData( repoFilter: Set, searchQuery: string, matchesPluginTaskFilters?: (taskId: string) => boolean, + vcsSearchTextByTaskId?: Record, ): { snapshot: WorkflowSnapshotData | undefined; tasks: Task[]; @@ -221,10 +261,19 @@ export function useWorkflowSwimlaneData( if (!snapshot) return { visibleTasks: [], occupancyTasks: [] }; return projectWorkflowTasks({ [workflowId]: snapshot }, workflowId, repoFilter, { searchQuery, + vcsSearchTextByTaskId, matchesPluginTaskFilters, hiddenStepIds: hiddenSet, }); - }, [hiddenSet, matchesPluginTaskFilters, repoFilter, searchQuery, snapshot, workflowId]); + }, [ + hiddenSet, + matchesPluginTaskFilters, + repoFilter, + searchQuery, + vcsSearchTextByTaskId, + snapshot, + workflowId, + ]); return { snapshot, diff --git a/apps/web/lib/kanban/task-projections.ts b/apps/web/lib/kanban/task-projections.ts index be6bf72f25..5729ce72b6 100644 --- a/apps/web/lib/kanban/task-projections.ts +++ b/apps/web/lib/kanban/task-projections.ts @@ -1,8 +1,10 @@ import type { Task } from "@/components/kanban-card"; import { filterTasksByRepositories, mapSelectedRepositoryIds } from "@/lib/kanban/filters"; +import { changeRequestSearchText } from "@/lib/kanban/task-search-index"; type FilterTasksOptions = { searchQuery?: string; + vcsSearchTextByTaskId?: Record; matchesPluginTaskFilters?: (taskId: string) => boolean; hiddenStepIds?: Set; }; @@ -16,7 +18,8 @@ export function filterTasks( const snapshot = snapshots[workflowId]; if (!snapshot) return []; let tasks = snapshot.tasks; - const { hiddenStepIds, searchQuery, matchesPluginTaskFilters } = options ?? {}; + const { hiddenStepIds, searchQuery, vcsSearchTextByTaskId, matchesPluginTaskFilters } = + options ?? {}; if (hiddenStepIds && hiddenStepIds.size > 0) { const liveStepIds = new Set(snapshot.steps.map((step) => step.id)); const effectiveHidden = new Set([...hiddenStepIds].filter((id) => liveStepIds.has(id))); @@ -30,7 +33,9 @@ export function filterTasks( tasks = tasks.filter( (task) => task.title.toLowerCase().includes(query) || - (task.description && task.description.toLowerCase().includes(query)), + (task.description && task.description.toLowerCase().includes(query)) || + changeRequestSearchText(task).toLowerCase().includes(query) || + (vcsSearchTextByTaskId?.[task.id]?.toLowerCase().includes(query) ?? false), ); } if (matchesPluginTaskFilters) { @@ -41,6 +46,7 @@ export function filterTasks( type WorkflowTaskProjectionOptions = { searchQuery: string; + vcsSearchTextByTaskId?: Record; matchesPluginTaskFilters?: (taskId: string) => boolean; hiddenStepIds?: Set; }; diff --git a/apps/web/lib/kanban/task-search-index.test.ts b/apps/web/lib/kanban/task-search-index.test.ts new file mode 100644 index 0000000000..701e224448 --- /dev/null +++ b/apps/web/lib/kanban/task-search-index.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + buildTaskVcsSearchIndex, + getTaskPRsByTaskIdForCurrentWorkspace, +} from "@/lib/kanban/task-search-index"; +import type { TaskPR } from "@/lib/types/github"; +import type { TaskMR } from "@/lib/types/gitlab"; + +function makePR(overrides: Partial & { task_id: string; pr_number: number }): TaskPR { + return { + id: `pr-${overrides.pr_number}`, + workspace_id: "ws-1", + owner: "acme", + repo: "widgets", + pr_url: "", + pr_title: "", + head_branch: "", + base_branch: "", + ...overrides, + } as TaskPR; +} + +function makeMR(overrides: Partial & { task_id: string; mr_iid: number }): TaskMR { + return { + id: `mr-${overrides.mr_iid}`, + host: "gitlab.com", + project_path: "acme/widgets", + mr_url: "", + mr_title: "", + head_branch: "", + base_branch: "", + author_username: "", + state: "open", + approval_state: "", + pipeline_state: "", + merge_status: "", + draft: false, + ...overrides, + } as TaskMR; +} + +describe("buildTaskVcsSearchIndex", () => { + it("indexes a GitHub-only task by its PR number", () => { + const index = buildTaskVcsSearchIndex( + { "task-1": [makePR({ task_id: "task-1", pr_number: 3315 })] }, + {}, + ); + + expect(index["task-1"]).toContain("#3315"); + }); + + it("indexes a GitLab-only task by its MR iid", () => { + const index = buildTaskVcsSearchIndex( + {}, + { "task-1": [makeMR({ task_id: "task-1", mr_iid: 42 })] }, + ); + + expect(index["task-1"]).toContain("#42"); + }); + + it("merges both PR and MR tokens for a task linked to both", () => { + const index = buildTaskVcsSearchIndex( + { "task-1": [makePR({ task_id: "task-1", pr_number: 3315 })] }, + { "task-1": [makeMR({ task_id: "task-1", mr_iid: 42 })] }, + ); + + expect(index["task-1"]).toContain("#3315"); + expect(index["task-1"]).toContain("#42"); + }); + + it("includes every linked PR for a multi-repo task", () => { + const index = buildTaskVcsSearchIndex( + { + "task-1": [ + makePR({ task_id: "task-1", pr_number: 100, repository_id: "repo-a" }), + makePR({ task_id: "task-1", pr_number: 200, repository_id: "repo-b" }), + ], + }, + {}, + ); + + expect(index["task-1"]).toContain("#100"); + expect(index["task-1"]).toContain("#200"); + }); + + it("omits an entry for a task with no linked PRs or MRs", () => { + const index = buildTaskVcsSearchIndex({ "task-1": [] }, {}); + + expect(index["task-1"]).toBeUndefined(); + }); + + it("returns an empty index for missing/empty inputs", () => { + expect(buildTaskVcsSearchIndex({}, {})).toEqual({}); + }); +}); + +describe("getTaskPRsByTaskIdForCurrentWorkspace", () => { + const byTaskId = { "task-1": [makePR({ task_id: "task-1", pr_number: 3315 })] }; + + it("returns the PR map when its scope matches the active workspace", () => { + const taskPRs = { + byTaskId, + workspaceId: "ws-1", + workspaceContextGeneration: 4, + }; + + expect(getTaskPRsByTaskIdForCurrentWorkspace(taskPRs, "ws-1", 4)).toBe(byTaskId); + }); + + it("returns an empty map when its workspace scope is stale", () => { + const taskPRs = { + byTaskId, + workspaceId: "ws-1", + workspaceContextGeneration: 4, + }; + + expect(getTaskPRsByTaskIdForCurrentWorkspace(taskPRs, "ws-2", 5)).toEqual({}); + }); +}); diff --git a/apps/web/lib/kanban/task-search-index.ts b/apps/web/lib/kanban/task-search-index.ts new file mode 100644 index 0000000000..c08674a013 --- /dev/null +++ b/apps/web/lib/kanban/task-search-index.ts @@ -0,0 +1,69 @@ +import type { TaskPRsState } from "@/lib/state/slices/github/types"; +import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; +import type { TaskPR } from "@/lib/types/github"; +import type { TaskMR } from "@/lib/types/gitlab"; + +type TaskWithStatusSummary = { + statusSummary?: TaskStatusSummary | null; +}; + +const EMPTY_TASK_PRS_BY_TASK_ID: Record = {}; + +/** Return GitHub associations only when their store scope matches the active workspace. */ +export function getTaskPRsByTaskIdForCurrentWorkspace( + taskPRs: Pick, + activeWorkspaceId: string | null, + workspaceContextGeneration: number, +): Record { + if ( + !activeWorkspaceId || + taskPRs.workspaceId !== activeWorkspaceId || + taskPRs.workspaceContextGeneration !== workspaceContextGeneration + ) { + return EMPTY_TASK_PRS_BY_TASK_ID; + } + return taskPRs.byTaskId; +} + +/** GitHub PR and GitLab MR numbers linked to a task, de-duplicated and ascending. */ +export function changeRequestNumbers( + task: TaskWithStatusSummary, + mrsForTask: TaskMR[] = [], + prsForTask: TaskPR[] = [], +): number[] { + const numbers = new Set(); + const summaryPRNumber = task.statusSummary?.pull_request?.number; + if (summaryPRNumber) numbers.add(summaryPRNumber); + 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); +} + +export function changeRequestSearchText( + task: TaskWithStatusSummary, + mrsForTask: TaskMR[] = [], + prsForTask: TaskPR[] = [], +): string { + return changeRequestNumbers(task, mrsForTask, prsForTask) + .map((number) => `#${number}`) + .join(" "); +} + +/** + * One haystack per task built from its linked PR/MR numbers, tokenized + * as `#` so both a bare number and a `#`-prefixed number substring-match. + */ +export function buildTaskVcsSearchIndex( + taskPRsByTaskId: Record, + taskMRsByTaskId: Record, +): Record { + const index: Record = {}; + const taskIds = new Set([...Object.keys(taskPRsByTaskId), ...Object.keys(taskMRsByTaskId)]); + + for (const taskId of taskIds) { + const text = changeRequestSearchText({}, taskMRsByTaskId[taskId], taskPRsByTaskId[taskId]); + if (text) index[taskId] = text; + } + + return index; +}