Merge pull request #495 from multica-ai/revert-477-feat/structured-ticket-search

Revert "feat(issues): add structured ticket search"
This commit is contained in:
LinYushen 2026-04-08 15:15:25 +08:00 committed by GitHub
commit 7d74b1f0b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 27 additions and 1033 deletions

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { 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,7 +8,6 @@ import type { Issue } from "@/shared/types";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => "/issues",
useSearchParams: () => new URLSearchParams(),
}));
// Mock next/link
@ -354,35 +353,4 @@ 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(<IssuesPage />);
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();
});
});

View file

@ -9,15 +9,12 @@ import {
CircleDot,
Columns3,
Filter,
LoaderCircle,
List,
Search,
SignalHigh,
SlidersHorizontal,
User,
UserMinus,
UserPen,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@ -38,13 +35,6 @@ 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,
@ -285,23 +275,7 @@ function ActorSubContent({
// IssuesHeader
// ---------------------------------------------------------------------------
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;
}) {
export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
const scope = useIssuesScopeStore((s) => s.scope);
const setScope = useIssuesScopeStore((s) => s.setScope);
@ -331,9 +305,9 @@ export function IssuesHeader({
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
return (
<div className="flex h-12 shrink-0 items-center gap-3 px-4">
<div className="flex h-12 shrink-0 items-center justify-between px-4">
{/* Left: scope buttons */}
<div className="flex shrink-0 items-center gap-1">
<div className="flex items-center gap-1">
{SCOPES.map((s) => (
<Tooltip key={s.value}>
<TooltipTrigger
@ -357,43 +331,8 @@ export function IssuesHeader({
))}
</div>
<div className="min-w-0 flex-1 lg:max-w-xl">
<InputGroup className="h-9 border-input/70 bg-background">
<InputGroupAddon>
<Search className="size-4" />
</InputGroupAddon>
<InputGroupInput
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
onCompositionStart={onSearchCompositionStart}
onCompositionEnd={(event) => onSearchCompositionEnd(event.currentTarget.value)}
placeholder="Search issues, #123, status:done, assignee:alice"
aria-label="Search issues"
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchLoading ? (
<LoaderCircle className="size-3.5 animate-spin text-muted-foreground" />
) : searchQuery ? (
<InputGroupText className="hidden text-xs md:flex">
{resultCount} {resultCount === 1 ? "match" : "matches"}
</InputGroupText>
) : null}
{searchQuery ? (
<InputGroupButton
size="icon-xs"
variant="ghost"
aria-label="Clear search"
onClick={() => onSearchQueryChange("")}
>
<X className="size-3.5" />
</InputGroupButton>
) : null}
</InputGroupAddon>
</InputGroup>
</div>
{/* Right: filter + display + view toggle */}
<div className="flex shrink-0 items-center gap-1">
<div className="flex items-center gap-1">
{/* Filter */}
<DropdownMenu>
<Tooltip>

View file

@ -1,56 +1,32 @@
"use client";
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo } from "react";
import { toast } from "sonner";
import { ChevronRight, ListTodo } from "lucide-react";
import type { Issue, IssueStatus } from "@/shared/types";
import type { 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 {
filterIssuesBySearch,
getSearchConstrainedStatuses,
parseIssueSearch,
} from "@/features/issues/utils/search";
import { ALL_STATUSES, BOARD_STATUSES } from "@/features/issues/config";
import { 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<string, Issue>();
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);
@ -58,171 +34,38 @@ 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<Issue[] | null>(null);
const [searchPoolWorkspaceId, setSearchPoolWorkspaceId] = useState<string | null>(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, 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]);
}, [viewMode, scope]);
// Scope pre-filter: narrow by assignee type
const scopedIssues = useMemo(() => {
if (scope === "members")
return searchableIssues.filter((i) => i.assignee_type === "member");
return allIssues.filter((i) => i.assignee_type === "member");
if (scope === "agents")
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,
],
);
return allIssues.filter((i) => i.assignee_type === "agent");
return allIssues;
}, [allIssues, scope]);
const issues = useMemo(
() => filterIssuesBySearch(filteredIssues, parsedSearch, { members, agents }),
[agents, filteredIssues, members, parsedSearch],
() => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
);
const visibleStatuses = useMemo(() => {
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));
}
if (statusFilters.length > 0)
return BOARD_STATUSES.filter((s) => statusFilters.includes(s));
return BOARD_STATUSES;
}, [hasActiveSearch, issues, parsedSearch, statusFilters]);
}, [statusFilters]);
const hiddenStatuses = useMemo(() => {
if (hasActiveSearch) return [];
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
}, [hasActiveSearch, visibleStatuses]);
}, [visibleStatuses]);
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
@ -284,38 +127,22 @@ export function IssuesPage() {
</div>
{/* Header 2: Scope tabs + filters */}
<IssuesHeader
scopedIssues={scopedIssues}
searchQuery={searchInputValue}
searchLoading={searchLoading}
resultCount={issues.length}
onSearchQueryChange={handleSearchInputChange}
onSearchCompositionStart={handleSearchCompositionStart}
onSearchCompositionEnd={handleSearchCompositionEnd}
/>
<IssuesHeader scopedIssues={scopedIssues} />
{/* Content: scrollable */}
<ViewStoreProvider store={useIssueViewStore}>
{scopedIssues.length === 0 && !hasActiveSearch && !hasActiveFilters ? (
{scopedIssues.length === 0 ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm">No issues yet</p>
<p className="text-xs">Create an issue to get started.</p>
</div>
) : issues.length === 0 ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm">No issues match this search</p>
<p className="text-xs">
Try `#123`, `status:done`, `assignee:alice`, or looser keywords.
</p>
</div>
) : (
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={issues}
allIssues={scopedIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}

View file

@ -1,134 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Agent, Issue, MemberWithUser } from "@/shared/types";
import { filterIssuesBySearch, getSearchConstrainedStatuses, parseIssueSearch } from "./search";
function makeIssue(overrides: Partial<Issue> = {}): 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<MemberWithUser, "user_id" | "name">[] = [
{ user_id: "u-1", name: "Alice Chen" },
{ user_id: "u-2", name: "Bob Wong" },
];
const agents: Pick<Agent, "id" | "name">[] = [
{ 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"]);
});
});

View file

@ -1,454 +0,0 @@
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<IssueStatus>(["done", "cancelled"]);
const TOKEN_REGEX = /"([^"]+)"|(\S+)/g;
const STATUS_ALIASES: Record<string, IssueStatus> = {
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<string, IssuePriority> = {
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<MemberWithUser, "user_id" | "name">[];
agents: Pick<Agent, "id" | "name">[];
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<T>(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();
}

View file

@ -167,7 +167,6 @@ 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);

View file

@ -28,7 +28,6 @@ export interface UpdateIssueRequest {
export interface ListIssuesParams {
limit?: number;
offset?: number;
all?: boolean;
workspace_id?: string;
status?: IssueStatus;
priority?: IssuePriority;

View file

@ -8,7 +8,6 @@ import (
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
@ -300,57 +299,6 @@ 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()

View file

@ -103,31 +103,6 @@ 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 != "" {
@ -141,7 +116,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
}
}
statusFilter := statusFilterFromQuery(r)
var statusFilter pgtype.Text
if s := r.URL.Query().Get("status"); s != "" {
statusFilter = pgtype.Text{String: s, Valid: true}
}
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
WorkspaceID: wsUUID,
@ -179,14 +157,6 @@ 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)

View file

@ -216,66 +216,6 @@ 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

View file

@ -7,14 +7,6 @@ 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;