mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Broaden comment matches in issue search
This commit is contained in:
parent
fcab770518
commit
1f78e55072
7 changed files with 267 additions and 26 deletions
|
|
@ -298,6 +298,51 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|||
expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]);
|
||||
});
|
||||
|
||||
it("ranks comment matches ahead of description-only matches", async () => {
|
||||
const companyId = randomUUID();
|
||||
const commentMatchId = randomUUID();
|
||||
const descriptionMatchId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: commentMatchId,
|
||||
companyId,
|
||||
title: "Comment match",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
id: descriptionMatchId,
|
||||
companyId,
|
||||
title: "Description match",
|
||||
description: "Contains pull/3303 in the description",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId: commentMatchId,
|
||||
body: "Reference: https://github.com/paperclipai/paperclip/pull/3303",
|
||||
});
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
q: "pull/3303",
|
||||
limit: 2,
|
||||
includeRoutineExecutions: true,
|
||||
});
|
||||
|
||||
expect(result.map((issue) => issue.id)).toEqual([commentMatchId, descriptionMatchId]);
|
||||
});
|
||||
|
||||
it("accepts issue identifiers through getById", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -997,8 +997,8 @@ export function issueService(db: Db) {
|
|||
WHEN ${titleContainsMatch} THEN 1
|
||||
WHEN ${identifierStartsWithMatch} THEN 2
|
||||
WHEN ${identifierContainsMatch} THEN 3
|
||||
WHEN ${descriptionContainsMatch} THEN 4
|
||||
WHEN ${commentContainsMatch} THEN 5
|
||||
WHEN ${commentContainsMatch} THEN 4
|
||||
WHEN ${descriptionContainsMatch} THEN 5
|
||||
ELSE 6
|
||||
END
|
||||
`;
|
||||
|
|
|
|||
190
ui/src/components/CommandPalette.test.tsx
Normal file
190
ui/src/components/CommandPalette.test.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
|
||||
const companyState = vi.hoisted(() => ({
|
||||
selectedCompanyId: "company-1",
|
||||
}));
|
||||
|
||||
const dialogState = vi.hoisted(() => ({
|
||||
openNewIssue: vi.fn(),
|
||||
openNewAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
const sidebarState = vi.hoisted(() => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockProjectsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => companyState,
|
||||
}));
|
||||
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => dialogState,
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => sidebarState,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: mockIssuesApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/agents", () => ({
|
||||
agentsApi: mockAgentsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/projects", () => ({
|
||||
projectsApi: mockProjectsApi,
|
||||
}));
|
||||
|
||||
vi.mock("./Identity", () => ({
|
||||
Identity: ({ name }: { name: string }) => <span>{name}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/command", () => ({
|
||||
CommandDialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? <div>{children}</div> : null),
|
||||
CommandEmpty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
CommandGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
CommandInput: ({
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<input
|
||||
aria-label="Command search"
|
||||
value={value}
|
||||
onChange={(event) => onValueChange(event.currentTarget.value)}
|
||||
/>
|
||||
<button type="button" aria-label="Set query" onClick={() => onValueChange("pull/3303")} />
|
||||
</div>
|
||||
),
|
||||
CommandItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onSelect?: () => void;
|
||||
}) => <button onClick={onSelect}>{children}</button>,
|
||||
CommandList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
CommandSeparator: () => <hr />,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForAssertion(assertion: () => void, attempts = 20) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await flush();
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{node}
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
return { root, queryClient };
|
||||
}
|
||||
|
||||
describe("CommandPalette", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
dialogState.openNewIssue.mockReset();
|
||||
dialogState.openNewAgent.mockReset();
|
||||
sidebarState.setSidebarOpen.mockReset();
|
||||
mockIssuesApi.list.mockReset();
|
||||
mockAgentsApi.list.mockReset();
|
||||
mockProjectsApi.list.mockReset();
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
mockProjectsApi.list.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("includes routine execution issues in search queries", async () => {
|
||||
const { root } = renderWithQueryClient(<CommandPalette />, container);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true }));
|
||||
});
|
||||
|
||||
const setQueryButton = container.querySelector('button[aria-label="Set query"]');
|
||||
expect(setQueryButton).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
setQueryButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
|
||||
q: "pull/3303",
|
||||
limit: 10,
|
||||
includeRoutineExecutions: true,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -65,7 +65,7 @@ export function CommandPalette() {
|
|||
|
||||
const { data: searchedIssues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery, undefined, 10),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery, limit: 10 }),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery, limit: 10, includeRoutineExecutions: true }),
|
||||
enabled: !!selectedCompanyId && open && searchQuery.length > 0,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
getApprovalsForTab,
|
||||
getInboxWorkItems,
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getInboxSearchFallbackIssues,
|
||||
getInboxSearchSupplementIssues,
|
||||
getRecentTouchedIssues,
|
||||
getUnreadTouchedIssues,
|
||||
groupInboxWorkItems,
|
||||
|
|
@ -612,12 +612,12 @@ describe("inbox helpers", () => {
|
|||
).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("uses remote issue results only when local inbox search has no matches", () => {
|
||||
it("adds remote issue results that are not already present in inbox search results", () => {
|
||||
const remoteMatch = makeIssue("remote-match", false);
|
||||
remoteMatch.status = "in_progress";
|
||||
|
||||
expect(
|
||||
getInboxSearchFallbackIssues({
|
||||
getInboxSearchSupplementIssues({
|
||||
query: "pull/3303",
|
||||
filteredWorkItems: [],
|
||||
archivedSearchIssues: [],
|
||||
|
|
@ -635,9 +635,9 @@ describe("inbox helpers", () => {
|
|||
).toEqual(["remote-match"]);
|
||||
|
||||
expect(
|
||||
getInboxSearchFallbackIssues({
|
||||
getInboxSearchSupplementIssues({
|
||||
query: "pull/3303",
|
||||
filteredWorkItems: [{ kind: "issue", timestamp: 1, issue: makeIssue("local", false) }],
|
||||
filteredWorkItems: [{ kind: "issue", timestamp: 1, issue: makeIssue("remote-match", false) }],
|
||||
archivedSearchIssues: [],
|
||||
remoteIssues: [remoteMatch],
|
||||
issueFilters: {
|
||||
|
|
@ -653,10 +653,10 @@ describe("inbox helpers", () => {
|
|||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
getInboxSearchFallbackIssues({
|
||||
getInboxSearchSupplementIssues({
|
||||
query: "pull/3303",
|
||||
filteredWorkItems: [],
|
||||
archivedSearchIssues: [makeIssue("archived", false)],
|
||||
archivedSearchIssues: [makeIssue("remote-match", false)],
|
||||
remoteIssues: [remoteMatch],
|
||||
issueFilters: {
|
||||
statuses: [],
|
||||
|
|
|
|||
|
|
@ -371,7 +371,7 @@ export function getArchivedInboxSearchIssues({
|
|||
.sort(sortIssuesByMostRecentActivity);
|
||||
}
|
||||
|
||||
export function getInboxSearchFallbackIssues({
|
||||
export function getInboxSearchSupplementIssues({
|
||||
query,
|
||||
filteredWorkItems,
|
||||
archivedSearchIssues,
|
||||
|
|
@ -390,9 +390,14 @@ export function getInboxSearchFallbackIssues({
|
|||
}): Issue[] {
|
||||
const normalizedQuery = query.trim();
|
||||
if (!normalizedQuery) return [];
|
||||
if (filteredWorkItems.length > 0) return [];
|
||||
if (archivedSearchIssues.length > 0) return [];
|
||||
return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter);
|
||||
const visibleIssueIds = new Set([
|
||||
...filteredWorkItems
|
||||
.filter((item): item is Extract<InboxWorkItem, { kind: "issue" }> => item.kind === "issue")
|
||||
.map((item) => item.issue.id),
|
||||
...archivedSearchIssues.map((issue) => issue.id),
|
||||
]);
|
||||
return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter)
|
||||
.filter((issue) => !visibleIssueIds.has(issue.id));
|
||||
}
|
||||
|
||||
export function resolveIssueWorkspaceName(
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ import {
|
|||
getArchivedInboxSearchIssues,
|
||||
getInboxWorkItems,
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getInboxSearchFallbackIssues,
|
||||
getInboxSearchSupplementIssues,
|
||||
getLatestFailedRunsByAgent,
|
||||
matchesInboxIssueSearch,
|
||||
getRecentTouchedIssues,
|
||||
|
|
@ -1022,15 +1022,13 @@ export function Inbox() {
|
|||
visibleTouchedIssues,
|
||||
],
|
||||
);
|
||||
const shouldUseIssueSearchFallback =
|
||||
const shouldUseIssueSearchSupplement =
|
||||
!!selectedCompanyId
|
||||
&& normalizedSearchQuery.length > 0
|
||||
&& filteredWorkItems.length === 0
|
||||
&& archivedSearchIssues.length === 0;
|
||||
&& normalizedSearchQuery.length > 0;
|
||||
const { data: remoteIssueSearchResults = [] } = useQuery({
|
||||
queryKey: [
|
||||
...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25),
|
||||
"inbox-fallback",
|
||||
"inbox-supplement",
|
||||
issueFilters,
|
||||
],
|
||||
queryFn: () =>
|
||||
|
|
@ -1039,12 +1037,12 @@ export function Inbox() {
|
|||
limit: 25,
|
||||
includeRoutineExecutions: true,
|
||||
}),
|
||||
enabled: shouldUseIssueSearchFallback,
|
||||
enabled: shouldUseIssueSearchSupplement,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
const issueSearchFallbackResults = useMemo(
|
||||
const issueSearchSupplementResults = useMemo(
|
||||
() =>
|
||||
getInboxSearchFallbackIssues({
|
||||
getInboxSearchSupplementIssues({
|
||||
query: normalizedSearchQuery,
|
||||
filteredWorkItems,
|
||||
archivedSearchIssues,
|
||||
|
|
@ -1064,10 +1062,13 @@ export function Inbox() {
|
|||
);
|
||||
const effectiveWorkItems = useMemo(
|
||||
() =>
|
||||
issueSearchFallbackResults.length > 0
|
||||
? getInboxWorkItems({ issues: issueSearchFallbackResults, approvals: [] })
|
||||
issueSearchSupplementResults.length > 0
|
||||
? [
|
||||
...filteredWorkItems,
|
||||
...getInboxWorkItems({ issues: issueSearchSupplementResults, approvals: [] }),
|
||||
]
|
||||
: filteredWorkItems,
|
||||
[filteredWorkItems, issueSearchFallbackResults],
|
||||
[filteredWorkItems, issueSearchSupplementResults],
|
||||
);
|
||||
const archivedSearchIssueIds = useMemo(
|
||||
() => new Set(archivedSearchIssues.map((issue) => issue.id)),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue