From 34c39b765e835feb5e269ba5808ad68b8a66b08f Mon Sep 17 00:00:00 2001 From: pseudoyu Date: Wed, 8 Apr 2026 00:35:27 +0800 Subject: [PATCH] feat(issues): add structured ticket search --- apps/web/app/(dashboard)/issues/page.test.tsx | 34 +- .../issues/components/issues-header.tsx | 69 ++- .../issues/components/issues-page.tsx | 209 +++++++- apps/web/features/issues/utils/search.test.ts | 134 ++++++ apps/web/features/issues/utils/search.ts | 454 ++++++++++++++++++ apps/web/shared/api/client.ts | 1 + apps/web/shared/types/api.ts | 1 + server/internal/handler/handler_test.go | 52 ++ server/internal/handler/issue.go | 38 +- server/pkg/db/generated/issue.sql.go | 60 +++ server/pkg/db/queries/issue.sql | 8 + 11 files changed, 1033 insertions(+), 27 deletions(-) create mode 100644 apps/web/features/issues/utils/search.test.ts create mode 100644 apps/web/features/issues/utils/search.ts diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index 86389051..e8d8400e 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue } from "@/shared/types"; @@ -8,6 +8,7 @@ import type { Issue } from "@/shared/types"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => "/issues", + useSearchParams: () => new URLSearchParams(), })); // Mock next/link @@ -353,4 +354,35 @@ describe("IssuesPage", () => { // Should still render the board/list view, not a "no issues" message expect(screen.queryByText("No matching issues")).not.toBeInTheDocument(); }); + + it("does not commit pinyin composition text before IME composition ends", () => { + mockStoreState.loading = false; + mockStoreState.issues = mockIssues; + + const replaceStateSpy = vi + .spyOn(window.history, "replaceState") + .mockImplementation(() => undefined); + + render(); + + const input = screen.getByLabelText("Search issues"); + + fireEvent.compositionStart(input); + fireEvent.change(input, { target: { value: "kaihui" } }); + + expect(input).toHaveValue("kaihui"); + expect(replaceStateSpy).not.toHaveBeenCalled(); + + fireEvent.change(input, { target: { value: "开会" } }); + fireEvent.compositionEnd(input, { data: "开会" }); + + expect(input).toHaveValue("开会"); + expect(replaceStateSpy).toHaveBeenCalledWith( + null, + "", + "/issues?q=%E5%BC%80%E4%BC%9A", + ); + + replaceStateSpy.mockRestore(); + }); }); diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx index cdd95147..babfa196 100644 --- a/apps/web/features/issues/components/issues-header.tsx +++ b/apps/web/features/issues/components/issues-header.tsx @@ -9,12 +9,15 @@ import { CircleDot, Columns3, Filter, + LoaderCircle, List, + Search, SignalHigh, SlidersHorizontal, User, UserMinus, UserPen, + X, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -35,6 +38,13 @@ import { PopoverTrigger, PopoverContent, } from "@/components/ui/popover"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, + InputGroupText, +} from "@/components/ui/input-group"; import { Switch } from "@/components/ui/switch"; import { ALL_STATUSES, @@ -275,7 +285,23 @@ function ActorSubContent({ // IssuesHeader // --------------------------------------------------------------------------- -export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { +export function IssuesHeader({ + scopedIssues, + searchQuery, + searchLoading, + resultCount, + onSearchQueryChange, + onSearchCompositionStart, + onSearchCompositionEnd, +}: { + scopedIssues: Issue[]; + searchQuery: string; + searchLoading: boolean; + resultCount: number; + onSearchQueryChange: (value: string) => void; + onSearchCompositionStart: () => void; + onSearchCompositionEnd: (value: string) => void; +}) { const scope = useIssuesScopeStore((s) => s.scope); const setScope = useIssuesScopeStore((s) => s.setScope); @@ -305,9 +331,9 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual"; return ( -
+
{/* Left: scope buttons */} -
+
{SCOPES.map((s) => ( +
+ + + + + onSearchQueryChange(event.target.value)} + onCompositionStart={onSearchCompositionStart} + onCompositionEnd={(event) => onSearchCompositionEnd(event.currentTarget.value)} + placeholder="Search issues, #123, status:done, assignee:alice" + aria-label="Search issues" + /> + + {searchLoading ? ( + + ) : searchQuery ? ( + + {resultCount} {resultCount === 1 ? "match" : "matches"} + + ) : null} + {searchQuery ? ( + onSearchQueryChange("")} + > + + + ) : null} + + +
+ {/* Right: filter + display + view toggle */} -
+
{/* Filter */} diff --git a/apps/web/features/issues/components/issues-page.tsx b/apps/web/features/issues/components/issues-page.tsx index 45165384..2f46c415 100644 --- a/apps/web/features/issues/components/issues-page.tsx +++ b/apps/web/features/issues/components/issues-page.tsx @@ -1,32 +1,56 @@ "use client"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; import { toast } from "sonner"; import { ChevronRight, ListTodo } from "lucide-react"; -import type { IssueStatus } from "@/shared/types"; +import type { Issue, IssueStatus } from "@/shared/types"; import { Skeleton } from "@/components/ui/skeleton"; import { useQuery } from "@tanstack/react-query"; import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store"; import { useIssuesScopeStore } from "@/features/issues/stores/issues-scope-store"; import { ViewStoreProvider } from "@/features/issues/stores/view-store-context"; import { filterIssues } from "@/features/issues/utils/filter"; -import { BOARD_STATUSES } from "@/features/issues/config"; +import { + filterIssuesBySearch, + getSearchConstrainedStatuses, + parseIssueSearch, +} from "@/features/issues/utils/search"; +import { ALL_STATUSES, BOARD_STATUSES } from "@/features/issues/config"; import { useWorkspaceStore } from "@/features/workspace"; import { WorkspaceAvatar } from "@/features/workspace"; import { useWorkspaceId } from "@core/hooks"; import { issueListOptions } from "@core/issues/queries"; import { useUpdateIssue } from "@core/issues/mutations"; +import { api } from "@/shared/api"; import { useIssueSelectionStore } from "@/features/issues/stores/selection-store"; import { IssuesHeader } from "./issues-header"; import { BoardView } from "./board-view"; import { ListView } from "./list-view"; import { BatchActionToolbar } from "./batch-action-toolbar"; +function mergeIssuesById(base: Issue[] | null, live: Issue[]): Issue[] { + const byId = new Map(); + + for (const issue of base ?? []) { + byId.set(issue.id, issue); + } + + for (const issue of live) { + byId.set(issue.id, issue); + } + + return Array.from(byId.values()); +} + export function IssuesPage() { const wsId = useWorkspaceId(); const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId)); - + const searchParams = useSearchParams(); + const urlSearchQuery = searchParams.get("q") ?? ""; const workspace = useWorkspaceStore((s) => s.workspace); + const members = useWorkspaceStore((s) => s.members); + const agents = useWorkspaceStore((s) => s.agents); const scope = useIssuesScopeStore((s) => s.scope); const viewMode = useIssueViewStore((s) => s.viewMode); const statusFilters = useIssueViewStore((s) => s.statusFilters); @@ -34,38 +58,171 @@ export function IssuesPage() { const assigneeFilters = useIssueViewStore((s) => s.assigneeFilters); const includeNoAssignee = useIssueViewStore((s) => s.includeNoAssignee); const creatorFilters = useIssueViewStore((s) => s.creatorFilters); + const [searchInputValue, setSearchInputValue] = useState(urlSearchQuery); + const [searchQuery, setSearchQueryState] = useState(urlSearchQuery); + const isSearchComposingRef = useRef(false); + const [searchPool, setSearchPool] = useState(null); + const [searchPoolWorkspaceId, setSearchPoolWorkspaceId] = useState(null); + const [searchLoading, setSearchLoading] = useState(false); + const deferredSearchQuery = useDeferredValue(searchQuery); + const hasActiveSearch = deferredSearchQuery.trim().length > 0; + const hasActiveFilters = + statusFilters.length > 0 || + priorityFilters.length > 0 || + assigneeFilters.length > 0 || + includeNoAssignee || + creatorFilters.length > 0; + + const replaceSearchUrl = useCallback((nextQuery: string) => { + const params = new URLSearchParams(window.location.search); + if (nextQuery.trim()) { + params.set("q", nextQuery); + } else { + params.delete("q"); + } + + const next = params.toString(); + window.history.replaceState(null, "", next ? `/issues?${next}` : "/issues"); + }, []); + + const applySearchQuery = useCallback((nextQuery: string) => { + setSearchQueryState(nextQuery); + replaceSearchUrl(nextQuery); + }, [replaceSearchUrl]); + + const handleSearchInputChange = useCallback((nextQuery: string) => { + setSearchInputValue(nextQuery); + if (!isSearchComposingRef.current) { + applySearchQuery(nextQuery); + } + }, [applySearchQuery]); + + const handleSearchCompositionStart = useCallback(() => { + isSearchComposingRef.current = true; + }, []); + + const handleSearchCompositionEnd = useCallback((nextQuery: string) => { + isSearchComposingRef.current = false; + setSearchInputValue(nextQuery); + applySearchQuery(nextQuery); + }, [applySearchQuery]); useEffect(() => { initFilterWorkspaceSync(); }, []); + useEffect(() => { + setSearchInputValue(urlSearchQuery); + setSearchQueryState(urlSearchQuery); + }, [urlSearchQuery]); + + useEffect(() => { + setSearchPool(null); + setSearchPoolWorkspaceId(null); + }, [workspace?.id]); + + useEffect(() => { + if (!hasActiveSearch || !workspace?.id) return; + if (searchPool && searchPoolWorkspaceId === workspace.id) return; + + let cancelled = false; + setSearchLoading(true); + + api.listIssues({ all: true }) + .then((res) => { + if (cancelled) return; + setSearchPool(res.issues); + setSearchPoolWorkspaceId(workspace.id); + }) + .catch((error) => { + if (cancelled) return; + console.error(error); + toast.error("Failed to search all issues"); + }) + .finally(() => { + if (!cancelled) setSearchLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [hasActiveSearch, searchPool, searchPoolWorkspaceId, workspace?.id]); + useEffect(() => { useIssueSelectionStore.getState().clear(); - }, [viewMode, scope]); + }, [viewMode, scope, deferredSearchQuery]); + + const parsedSearch = useMemo( + () => parseIssueSearch(deferredSearchQuery, { members, agents }), + [agents, deferredSearchQuery, members], + ); + + const searchableIssues = useMemo(() => { + if (!hasActiveSearch) return allIssues; + if (searchPoolWorkspaceId !== workspace?.id) return allIssues; + return mergeIssuesById(searchPool, allIssues); + }, [allIssues, hasActiveSearch, searchPool, searchPoolWorkspaceId, workspace?.id]); // Scope pre-filter: narrow by assignee type const scopedIssues = useMemo(() => { if (scope === "members") - return allIssues.filter((i) => i.assignee_type === "member"); + return searchableIssues.filter((i) => i.assignee_type === "member"); if (scope === "agents") - return allIssues.filter((i) => i.assignee_type === "agent"); - return allIssues; - }, [allIssues, scope]); + return searchableIssues.filter((i) => i.assignee_type === "agent"); + return searchableIssues; + }, [scope, searchableIssues]); + + const filteredIssues = useMemo( + () => + filterIssues(scopedIssues, { + statusFilters, + priorityFilters, + assigneeFilters, + includeNoAssignee, + creatorFilters, + }), + [ + assigneeFilters, + creatorFilters, + includeNoAssignee, + priorityFilters, + scopedIssues, + statusFilters, + ], + ); const issues = useMemo( - () => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }), - [scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], + () => filterIssuesBySearch(filteredIssues, parsedSearch, { members, agents }), + [agents, filteredIssues, members, parsedSearch], ); const visibleStatuses = useMemo(() => { - if (statusFilters.length > 0) - return BOARD_STATUSES.filter((s) => statusFilters.includes(s)); + const explicitSearchStatuses = getSearchConstrainedStatuses(parsedSearch); + const explicitStatuses = + statusFilters.length > 0 || explicitSearchStatuses + ? ALL_STATUSES.filter((status) => { + if (statusFilters.length > 0 && !statusFilters.includes(status)) { + return false; + } + if (explicitSearchStatuses && !explicitSearchStatuses.includes(status)) { + return false; + } + return true; + }) + : null; + + if (explicitStatuses) return explicitStatuses; + if (hasActiveSearch) { + const resultStatuses = new Set(issues.map((issue) => issue.status)); + return ALL_STATUSES.filter((status) => resultStatuses.has(status)); + } return BOARD_STATUSES; - }, [statusFilters]); + }, [hasActiveSearch, issues, parsedSearch, statusFilters]); const hiddenStatuses = useMemo(() => { + if (hasActiveSearch) return []; return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s)); - }, [visibleStatuses]); + }, [hasActiveSearch, visibleStatuses]); const updateIssueMutation = useUpdateIssue(); const handleMoveIssue = useCallback( @@ -127,22 +284,38 @@ export function IssuesPage() {
{/* Header 2: Scope tabs + filters */} - + {/* Content: scrollable */} - {scopedIssues.length === 0 ? ( + {scopedIssues.length === 0 && !hasActiveSearch && !hasActiveFilters ? (

No issues yet

Create an issue to get started.

+ ) : issues.length === 0 ? ( +
+ +

No issues match this search

+

+ Try `#123`, `status:done`, `assignee:alice`, or looser keywords. +

+
) : (
{viewMode === "board" ? ( = {}): Issue { + return { + id: "i-1", + workspace_id: "ws-1", + number: 1, + identifier: "MUL-1", + title: "Test issue", + description: null, + status: "todo", + priority: "medium", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "u-1", + parent_issue_id: null, + position: 0, + due_date: null, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + ...overrides, + }; +} + +const members: Pick[] = [ + { user_id: "u-1", name: "Alice Chen" }, + { user_id: "u-2", name: "Bob Wong" }, +]; + +const agents: Pick[] = [ + { id: "a-1", name: "Fixer Bot" }, + { id: "a-2", name: "Review Agent" }, +]; + +const context = { + members, + agents, + now: new Date("2026-04-08T10:00:00Z"), +}; + +const issues: Issue[] = [ + makeIssue({ + id: "1", + number: 11, + identifier: "MUL-11", + title: "Fix login redirect loop", + description: "Users bounce back to the sign-in page.", + status: "todo", + priority: "high", + assignee_type: "member", + assignee_id: "u-1", + }), + makeIssue({ + id: "2", + number: 12, + identifier: "MUL-12", + title: "Improve issue search", + description: "Search title, description, and actor names.", + status: "in_progress", + priority: "urgent", + assignee_type: "agent", + assignee_id: "a-1", + creator_type: "agent", + creator_id: "a-2", + due_date: "2026-04-08T12:00:00Z", + }), + makeIssue({ + id: "3", + number: 18, + identifier: "MUL-18", + title: "Archive old tickets", + description: "", + status: "done", + priority: "low", + creator_id: "u-2", + due_date: "2026-04-07T09:00:00Z", + }), +]; + +describe("parseIssueSearch", () => { + it("extracts structured filters and quoted text", () => { + const parsed = parseIssueSearch( + 'status:todo priority:high assignee:"Alice" "redirect loop"', + context, + ); + + expect(parsed.statusFilters).toEqual(["todo"]); + expect(parsed.priorityFilters).toEqual(["high"]); + expect(parsed.assigneeFilters).toEqual([{ type: "member", id: "u-1" }]); + expect(parsed.textTerms).toEqual(["redirect loop"]); + }); + + it("recognizes issue numbers and lifecycle shortcuts", () => { + const parsed = parseIssueSearch("#18 is:closed", context); + + expect(parsed.issueNumber).toBe(18); + expect(parsed.lifecycle).toBe("closed"); + expect(getSearchConstrainedStatuses(parsed)).toEqual(["done", "cancelled"]); + }); +}); + +describe("filterIssuesBySearch", () => { + it("matches free text across title, description, identifier, and actor names", () => { + const parsed = parseIssueSearch("Fixer review MUL-12", context); + const result = filterIssuesBySearch(issues, parsed, context); + + expect(result.map((issue) => issue.id)).toEqual(["2"]); + }); + + it("filters by assignee, creator, and explicit status tokens", () => { + const parsed = parseIssueSearch("assignee:Fixer creator:Review status:in-progress", context); + const result = filterIssuesBySearch(issues, parsed, context); + + expect(result.map((issue) => issue.id)).toEqual(["2"]); + }); + + it("filters by due state and description presence", () => { + const todayParsed = parseIssueSearch("due:today has:description", context); + const overdueParsed = parseIssueSearch("due:overdue", context); + + expect(filterIssuesBySearch(issues, todayParsed, context).map((issue) => issue.id)).toEqual(["2"]); + expect(filterIssuesBySearch(issues, overdueParsed, context).map((issue) => issue.id)).toEqual(["3"]); + }); + + it("supports unassigned and closed search flows", () => { + const parsed = parseIssueSearch("is:closed is:unassigned", context); + const result = filterIssuesBySearch(issues, parsed, context); + + expect(result.map((issue) => issue.id)).toEqual(["3"]); + }); +}); diff --git a/apps/web/features/issues/utils/search.ts b/apps/web/features/issues/utils/search.ts new file mode 100644 index 00000000..a950b2ad --- /dev/null +++ b/apps/web/features/issues/utils/search.ts @@ -0,0 +1,454 @@ +import type { + Agent, + Issue, + IssuePriority, + IssueStatus, + MemberWithUser, +} from "@/shared/types"; +import { PRIORITY_CONFIG, STATUS_CONFIG, ALL_STATUSES } from "@/features/issues/config"; +import type { ActorFilterValue } from "@/features/issues/stores/view-store"; + +const CLOSED_STATUSES = new Set(["done", "cancelled"]); + +const TOKEN_REGEX = /"([^"]+)"|(\S+)/g; + +const STATUS_ALIASES: Record = { + backlog: "backlog", + todo: "todo", + "to do": "todo", + "in progress": "in_progress", + inprogress: "in_progress", + progress: "in_progress", + "in review": "in_review", + inreview: "in_review", + review: "in_review", + done: "done", + blocked: "blocked", + cancelled: "cancelled", + canceled: "cancelled", +}; + +const PRIORITY_ALIASES: Record = { + urgent: "urgent", + high: "high", + medium: "medium", + normal: "medium", + low: "low", + none: "none", + "no priority": "none", +}; + +export type IssueSearchLifecycle = "all" | "open" | "closed"; +export type IssueSearchDueState = "any" | "overdue" | "today" | "none" | "upcoming"; +export type IssueSearchAssigneeState = "any" | "assigned" | "unassigned"; + +export interface IssueSearchContext { + members: Pick[]; + agents: Pick[]; + now?: Date; +} + +export interface ParsedIssueSearch { + raw: string; + textTerms: string[]; + issueNumber: number | null; + statusFilters: IssueStatus[]; + priorityFilters: IssuePriority[]; + assigneeFilters: ActorFilterValue[]; + creatorFilters: ActorFilterValue[]; + assigneeState: IssueSearchAssigneeState; + lifecycle: IssueSearchLifecycle; + dueState: IssueSearchDueState; + hasDescription: boolean | null; + forceEmpty: boolean; +} + +function normalizeSearchText(value: string): string { + return value + .toLowerCase() + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function unwrapQuotedValue(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + +function addUniqueValue(items: T[], value: T) { + if (!items.includes(value)) items.push(value); +} + +function addUniqueActorFilters(target: ActorFilterValue[], next: ActorFilterValue[]) { + for (const actor of next) { + const exists = target.some( + (item) => item.type === actor.type && item.id === actor.id, + ); + if (!exists) target.push(actor); + } +} + +function parseIssueNumberToken(token: string): number | null { + const hashMatch = token.match(/^#(\d+)$/); + if (hashMatch) return Number(hashMatch[1]); + + const identifierMatch = token.match(/^[a-z][a-z0-9]*-(\d+)$/i); + if (identifierMatch) return Number(identifierMatch[1]); + + return null; +} + +function resolveStatus(value: string): IssueStatus | null { + return STATUS_ALIASES[normalizeSearchText(value)] ?? null; +} + +function resolvePriority(value: string): IssuePriority | null { + return PRIORITY_ALIASES[normalizeSearchText(value)] ?? null; +} + +function resolveActors( + value: string, + context: IssueSearchContext, +): ActorFilterValue[] { + const query = normalizeSearchText(value); + if (!query) return []; + + const matches: ActorFilterValue[] = []; + + for (const member of context.members) { + if (normalizeSearchText(member.name).includes(query)) { + matches.push({ type: "member", id: member.user_id }); + } + } + + for (const agent of context.agents) { + if (normalizeSearchText(agent.name).includes(query)) { + matches.push({ type: "agent", id: agent.id }); + } + } + + return matches; +} + +function getActorName( + type: Issue["creator_type"] | Issue["assignee_type"], + id: string | null, + context: IssueSearchContext, +): string { + if (!type || !id) return ""; + if (type === "member") { + return context.members.find((member) => member.user_id === id)?.name ?? ""; + } + return context.agents.find((agent) => agent.id === id)?.name ?? ""; +} + +function buildIssueHaystack(issue: Issue, context: IssueSearchContext): string { + const assigneeName = getActorName(issue.assignee_type, issue.assignee_id, context); + const creatorName = getActorName(issue.creator_type, issue.creator_id, context); + + return normalizeSearchText( + [ + issue.identifier, + `#${issue.number}`, + String(issue.number), + issue.title, + issue.description ?? "", + issue.status, + STATUS_CONFIG[issue.status].label, + issue.priority, + PRIORITY_CONFIG[issue.priority].label, + assigneeName, + creatorName, + ].join(" "), + ); +} + +function isSameLocalDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +export function tokenizeIssueSearch(query: string): string[] { + const tokens: string[] = []; + for (const match of query.matchAll(TOKEN_REGEX)) { + const value = (match[1] ?? match[2] ?? "").trim(); + if (value) tokens.push(value); + } + return tokens; +} + +export function parseIssueSearch( + query: string, + context: IssueSearchContext, +): ParsedIssueSearch { + const parsed: ParsedIssueSearch = { + raw: query, + textTerms: [], + issueNumber: null, + statusFilters: [], + priorityFilters: [], + assigneeFilters: [], + creatorFilters: [], + assigneeState: "any", + lifecycle: "all", + dueState: "any", + hasDescription: null, + forceEmpty: false, + }; + + for (const token of tokenizeIssueSearch(query)) { + const issueNumber = parseIssueNumberToken(token); + if (issueNumber !== null) { + if (parsed.issueNumber !== null && parsed.issueNumber !== issueNumber) { + parsed.forceEmpty = true; + } else { + parsed.issueNumber = issueNumber; + } + continue; + } + + if (token.startsWith("@")) { + const matches = resolveActors(token.slice(1), context); + if (matches.length === 0) { + parsed.forceEmpty = true; + } else { + addUniqueActorFilters(parsed.assigneeFilters, matches); + } + continue; + } + + const separatorIndex = token.indexOf(":"); + if (separatorIndex <= 0) { + parsed.textTerms.push(token); + continue; + } + + const key = token.slice(0, separatorIndex).toLowerCase(); + const rawValue = unwrapQuotedValue(token.slice(separatorIndex + 1)); + if (!rawValue) { + parsed.textTerms.push(token); + continue; + } + + switch (key) { + case "status": + case "state": { + const status = resolveStatus(rawValue); + if (!status) { + parsed.forceEmpty = true; + break; + } + addUniqueValue(parsed.statusFilters, status); + break; + } + case "priority": + case "p": { + const priority = resolvePriority(rawValue); + if (!priority) { + parsed.forceEmpty = true; + break; + } + addUniqueValue(parsed.priorityFilters, priority); + break; + } + case "assignee": + case "assigned": { + const normalizedValue = normalizeSearchText(rawValue); + if (normalizedValue === "none" || normalizedValue === "unassigned") { + parsed.assigneeState = "unassigned"; + break; + } + const matches = resolveActors(rawValue, context); + if (matches.length === 0) { + parsed.forceEmpty = true; + break; + } + addUniqueActorFilters(parsed.assigneeFilters, matches); + break; + } + case "creator": + case "author": + case "by": { + const matches = resolveActors(rawValue, context); + if (matches.length === 0) { + parsed.forceEmpty = true; + break; + } + addUniqueActorFilters(parsed.creatorFilters, matches); + break; + } + case "is": { + const normalizedValue = normalizeSearchText(rawValue); + if (normalizedValue === "open") { + parsed.lifecycle = "open"; + } else if (normalizedValue === "closed") { + parsed.lifecycle = "closed"; + } else if (normalizedValue === "assigned") { + parsed.assigneeState = "assigned"; + } else if (normalizedValue === "unassigned") { + parsed.assigneeState = "unassigned"; + } else { + parsed.forceEmpty = true; + } + break; + } + case "has": { + const normalizedValue = normalizeSearchText(rawValue); + if (normalizedValue === "description" || normalizedValue === "desc") { + parsed.hasDescription = true; + } else { + parsed.forceEmpty = true; + } + break; + } + case "due": { + const normalizedValue = normalizeSearchText(rawValue); + if ( + normalizedValue === "today" || + normalizedValue === "overdue" || + normalizedValue === "none" || + normalizedValue === "upcoming" + ) { + parsed.dueState = normalizedValue; + } else { + parsed.forceEmpty = true; + } + break; + } + default: + parsed.textTerms.push(token); + break; + } + } + + return parsed; +} + +export function getSearchConstrainedStatuses( + parsed: ParsedIssueSearch, +): IssueStatus[] | null { + if (parsed.statusFilters.length > 0) { + return ALL_STATUSES.filter((status) => parsed.statusFilters.includes(status)); + } + if (parsed.lifecycle === "open") { + return ALL_STATUSES.filter((status) => !CLOSED_STATUSES.has(status)); + } + if (parsed.lifecycle === "closed") { + return ALL_STATUSES.filter((status) => CLOSED_STATUSES.has(status)); + } + return null; +} + +export function filterIssuesBySearch( + issues: Issue[], + parsed: ParsedIssueSearch, + context: IssueSearchContext, +): Issue[] { + if (parsed.forceEmpty) return []; + + return issues.filter((issue) => { + if (parsed.issueNumber !== null && issue.number !== parsed.issueNumber) { + return false; + } + + if (parsed.lifecycle === "open" && CLOSED_STATUSES.has(issue.status)) { + return false; + } + + if (parsed.lifecycle === "closed" && !CLOSED_STATUSES.has(issue.status)) { + return false; + } + + if ( + parsed.statusFilters.length > 0 && + !parsed.statusFilters.includes(issue.status) + ) { + return false; + } + + if ( + parsed.priorityFilters.length > 0 && + !parsed.priorityFilters.includes(issue.priority) + ) { + return false; + } + + if (parsed.assigneeState === "assigned" && !issue.assignee_id) { + return false; + } + + if (parsed.assigneeState === "unassigned" && issue.assignee_id) { + return false; + } + + if (parsed.assigneeFilters.length > 0) { + if (!issue.assignee_type || !issue.assignee_id) return false; + const matchesAssignee = parsed.assigneeFilters.some( + (assignee) => + assignee.type === issue.assignee_type && + assignee.id === issue.assignee_id, + ); + if (!matchesAssignee) return false; + } + + if (parsed.creatorFilters.length > 0) { + const matchesCreator = parsed.creatorFilters.some( + (creator) => + creator.type === issue.creator_type && creator.id === issue.creator_id, + ); + if (!matchesCreator) return false; + } + + if ( + parsed.hasDescription === true && + (!issue.description || issue.description.trim().length === 0) + ) { + return false; + } + + if (parsed.dueState === "none" && issue.due_date) { + return false; + } + + if (parsed.dueState !== "any" && parsed.dueState !== "none") { + if (!issue.due_date) return false; + const dueDate = new Date(issue.due_date); + const now = parsedDateNow(context); + + if (parsed.dueState === "today" && !isSameLocalDay(dueDate, now)) { + return false; + } + + if (parsed.dueState === "overdue" && dueDate.getTime() >= now.getTime()) { + return false; + } + + if (parsed.dueState === "upcoming") { + if (dueDate.getTime() <= now.getTime() || isSameLocalDay(dueDate, now)) { + return false; + } + } + } + + if (parsed.textTerms.length === 0) return true; + + const haystack = buildIssueHaystack(issue, context); + return parsed.textTerms.every((term) => + haystack.includes(normalizeSearchText(term)), + ); + }); +} + +function parsedDateNow(context: IssueSearchContext): Date { + return context.now ? new Date(context.now) : new Date(); +} diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 2c3a4207..bf75f756 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -167,6 +167,7 @@ export class ApiClient { const search = new URLSearchParams(); if (params?.limit) search.set("limit", String(params.limit)); if (params?.offset) search.set("offset", String(params.offset)); + if (params?.all) search.set("all", "true"); const wsId = params?.workspace_id ?? this.workspaceId; if (wsId) search.set("workspace_id", wsId); if (params?.status) search.set("status", params.status); diff --git a/apps/web/shared/types/api.ts b/apps/web/shared/types/api.ts index 39e4d712..3be965d9 100644 --- a/apps/web/shared/types/api.ts +++ b/apps/web/shared/types/api.ts @@ -28,6 +28,7 @@ export interface UpdateIssueRequest { export interface ListIssuesParams { limit?: number; offset?: number; + all?: boolean; workspace_id?: string; status?: IssueStatus; priority?: IssuePriority; diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index 8b798bc9..f714ef56 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "os" + "strconv" "strings" "testing" @@ -299,6 +300,57 @@ func TestCommentCRUD(t *testing.T) { testHandler.DeleteIssue(w, req) } +func TestListIssuesAllIgnoresPagination(t *testing.T) { + createdIDs := make([]string, 0, 3) + + for i := 0; i < 3; i++ { + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "Searchable issue " + strconv.Itoa(i+1), + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var issue IssueResponse + json.NewDecoder(w.Body).Decode(&issue) + createdIDs = append(createdIDs, issue.ID) + } + + t.Cleanup(func() { + for _, issueID := range createdIDs { + w := httptest.NewRecorder() + req := newRequest("DELETE", "/api/issues/"+issueID, nil) + req = withURLParam(req, "id", issueID) + testHandler.DeleteIssue(w, req) + } + }) + + w := httptest.NewRecorder() + req := newRequest("GET", "/api/issues?workspace_id="+testWorkspaceID+"&all=true&limit=1", nil) + testHandler.ListIssues(w, req) + if w.Code != http.StatusOK { + t.Fatalf("ListIssues(all=true): expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var listResp struct { + Issues []IssueResponse `json:"issues"` + Total int `json:"total"` + } + if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil { + t.Fatalf("ListIssues(all=true): decode response: %v", err) + } + + if len(listResp.Issues) < 3 { + t.Fatalf("ListIssues(all=true): expected at least 3 issues, got %d", len(listResp.Issues)) + } + + if listResp.Total != len(listResp.Issues) { + t.Fatalf("ListIssues(all=true): expected total %d, got %d", len(listResp.Issues), listResp.Total) + } +} + func TestAgentCRUD(t *testing.T) { // List agents w := httptest.NewRecorder() diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index af7fc638..2dbb5444 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -103,6 +103,31 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { return } + if r.URL.Query().Get("all") == "true" { + issues, err := h.Queries.ListAllIssues(ctx, db.ListAllIssuesParams{ + WorkspaceID: wsUUID, + Status: statusFilterFromQuery(r), + Priority: priorityFilter, + AssigneeID: assigneeFilter, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list issues") + return + } + + prefix := h.getIssuePrefix(ctx, wsUUID) + resp := make([]IssueResponse, len(issues)) + for i, issue := range issues { + resp[i] = issueToResponse(issue, prefix) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "issues": resp, + "total": len(resp), + }) + return + } + limit := 100 offset := 0 if l := r.URL.Query().Get("limit"); l != "" { @@ -116,10 +141,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { } } - var statusFilter pgtype.Text - if s := r.URL.Query().Get("status"); s != "" { - statusFilter = pgtype.Text{String: s, Valid: true} - } + statusFilter := statusFilterFromQuery(r) issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{ WorkspaceID: wsUUID, @@ -157,6 +179,14 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { }) } +func statusFilterFromQuery(r *http.Request) pgtype.Text { + var statusFilter pgtype.Text + if s := r.URL.Query().Get("status"); s != "" { + statusFilter = pgtype.Text{String: s, Valid: true} + } + return statusFilter +} + func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") issue, ok := h.loadIssueForUser(w, r, id) diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index 97ec6788..f943ddef 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -216,6 +216,66 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa return i, err } +const listAllIssues = `-- name: ListAllIssues :many +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue +WHERE workspace_id = $1 + AND ($2::text IS NULL OR status = $2) + AND ($3::text IS NULL OR priority = $3) + AND ($4::uuid IS NULL OR assignee_id = $4) +ORDER BY position ASC, created_at DESC +` + +type ListAllIssuesParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + Status pgtype.Text `json:"status"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` +} + +func (q *Queries) ListAllIssues(ctx context.Context, arg ListAllIssuesParams) ([]Issue, error) { + rows, err := q.db.Query(ctx, listAllIssues, + arg.WorkspaceID, + arg.Status, + arg.Priority, + arg.AssigneeID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Issue{} + for rows.Next() { + var i Issue + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Title, + &i.Description, + &i.Status, + &i.Priority, + &i.AssigneeType, + &i.AssigneeID, + &i.CreatorType, + &i.CreatorID, + &i.ParentIssueID, + &i.AcceptanceCriteria, + &i.ContextRefs, + &i.Position, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.Number, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listIssues = `-- name: ListIssues :many SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue WHERE workspace_id = $1 diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index c8821ffb..662a45d2 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -7,6 +7,14 @@ WHERE workspace_id = $1 ORDER BY position ASC, created_at DESC LIMIT $2 OFFSET $3; +-- name: ListAllIssues :many +SELECT * FROM issue +WHERE workspace_id = $1 + AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) + AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) +ORDER BY position ASC, created_at DESC; + -- name: GetIssue :one SELECT * FROM issue WHERE id = $1;