fix(web): make the board search box match linked PR/MR numbers (#3391)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-10 05:54:08 +08:00
committed by GitHub
parent 51aaac9a88
commit fcf46400df
8 changed files with 552 additions and 79 deletions
@@ -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 = {
@@ -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<string, never[]> = {};
type WorkflowItemProps = {
wf: { id: string; name: string };
repoFilter: Set<string>;
searchQuery: string;
vcsSearchTextByTaskId?: Record<string, string>;
matchesPluginTaskFilters?: (taskId: string) => boolean;
ViewComponent: ComponentType<ViewContentProps>;
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 `#<number>` 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<string>;
searchQuery: string;
vcsSearchTextByTaskId?: Record<string, string>;
matchesPluginTaskFilters?: (taskId: string) => boolean;
ViewComponent: ComponentType<ViewContentProps>;
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}
@@ -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<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: Pick<Task, "id" | "title">, numbers: number[]): string {
const parts = [task.title, task.id, ...numbers.map((n) => `#${n} ${n}`)];
@@ -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();
});
});
@@ -19,6 +19,7 @@ type TaskProjectionCacheEntry = {
hiddenStepIds: string[] | undefined;
repoFilter: Set<string>;
searchQuery: string;
vcsSearchTextByTaskId: Record<string, string> | undefined;
matchesPluginTaskFilters: ((taskId: string) => boolean) | undefined;
visibleTasks: Task[];
};
@@ -85,11 +86,78 @@ function useOrderedWorkflowLists(
return { allOrderedWorkflows, orderedWorkflows };
}
type TaskProjectionCacheOptions = {
snapshots: Record<string, WorkflowSnapshotData>;
hiddenWorkflowStepIds: Record<string, string[]>;
repoFilter: Set<string>;
searchQuery: string;
vcsSearchTextByTaskId: Record<string, string> | undefined;
matchesPluginTaskFilters: ((taskId: string) => boolean) | undefined;
};
function useTaskProjectionCache({
snapshots,
hiddenWorkflowStepIds,
repoFilter,
searchQuery,
vcsSearchTextByTaskId,
matchesPluginTaskFilters,
}: TaskProjectionCacheOptions) {
const projectionCacheRef = useRef(new Map<string, TaskProjectionCacheEntry>());
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<string, string>,
) {
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<string, TaskProjectionCacheEntry>());
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<string>,
searchQuery: string,
matchesPluginTaskFilters?: (taskId: string) => boolean,
vcsSearchTextByTaskId?: Record<string, string>,
): {
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,
+8 -2
View File
@@ -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<string, string>;
matchesPluginTaskFilters?: (taskId: string) => boolean;
hiddenStepIds?: Set<string>;
};
@@ -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<string, string>;
matchesPluginTaskFilters?: (taskId: string) => boolean;
hiddenStepIds?: Set<string>;
};
@@ -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<TaskPR> & { 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<TaskMR> & { 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({});
});
});
+69
View File
@@ -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<string, TaskPR[]> = {};
/** Return GitHub associations only when their store scope matches the active workspace. */
export function getTaskPRsByTaskIdForCurrentWorkspace(
taskPRs: Pick<TaskPRsState, "byTaskId" | "workspaceId" | "workspaceContextGeneration">,
activeWorkspaceId: string | null,
workspaceContextGeneration: number,
): Record<string, TaskPR[]> {
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<number>();
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 `#<number>` so both a bare number and a `#`-prefixed number substring-match.
*/
export function buildTaskVcsSearchIndex(
taskPRsByTaskId: Record<string, TaskPR[]>,
taskMRsByTaskId: Record<string, TaskMR[]>,
): Record<string, string> {
const index: Record<string, string> = {};
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;
}