diff --git a/DEVLOG.md b/DEVLOG.md index 731fc58c..2fe8df2d 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,29 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-16 — AXI-119 shared issue search semantics + +Implemented a shared issue-search parser and Prisma predicate for tRPC +`issue.list`/`issue.count`, MCP `issues.list`, and command-palette issue results. +Case-insensitive `KEY-N`, bare `N`, and `#N` are exact direct identifiers; full +keys remain workspace-bound. Ordinary text now covers title, description, +project key/name, label name, human assignee name/handle/email, and assigned +agent name/profile key while preserving tenant, API-key, lifecycle, archive, +saved-facet, and board filters. Status and priority remain explicit facets. + +Fixed tRPC issue-list cursor boundaries to skip the prior cursor row and emit +the last returned row as the next cursor, matching deterministic sort +tiebreakers without duplicate or skipped results. Updated Issues-page guidance, +MCP/tRPC/product docs, focused integration coverage, and Playwright coverage for +full-key/bare-number search, URL persistence, lifecycle scope, and list/Kanban +parity. No database extension, index, migration, notification change, or agent +wake path was added. + +Post-rebase verification on v0.27.1 passed lint (existing warnings only), +typecheck, all 119 local migrations, 1,425 Vitest tests with one intentional +skip, a fresh production E2E build, and both focused Issues Playwright journeys +against the dedicated `forge_e2e` database and Redis DB 15. + ## 2026-07-16 — Truthful Delivery provenance and GitHub relations AXI-117 audited production Ready to Close, Delivery, and native GitHub cards, diff --git a/docs/guide/issues.md b/docs/guide/issues.md index 19a05920..5ba8af3f 100644 --- a/docs/guide/issues.md +++ b/docs/guide/issues.md @@ -50,6 +50,26 @@ within each category, but the category set is fixed. | `DONE` | Shipped, closed positively | | `CANCELED` | Closed without shipping | +## Search + +Issue search is workspace-scoped and uses one contract across the Issues page, +relation and time-entry pickers, the command palette, and MCP `issues.list`. + +- `AXI-117`, `117`, and `#117` are direct identifiers. They match the exact + issue number; full keys are case-insensitive and the prefix must equal the + current workspace key. +- Other text searches the issue title and description plus operator-facing + metadata: project key/name, label name, human assignee name/handle/email, and + assigned-agent name/profile key. +- Status and priority remain facets, not free-text terms. Lifecycle scope, + archive mode, saved-view facets, board columns, and API-key narrowing still + apply to every result, including direct identifiers. + +Search does not inspect comments, attachments, audit text, AI reasoning, or +external-resource bodies. Forge currently uses ordinary indexed relational +filters and deterministic cursor sorting; the current data scale does not +justify a full-text or trigram extension. + ## The two assignment slots Forge keeps human and agent assignment **independent**. An issue can have: diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index ec32a9fd..8636c155 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -127,7 +127,7 @@ long tail without advertising every direct tool: | Tool | Summary | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `list` | Paged list with filters: `status`, `priority`, `projectId`, `assignedAgentId`, `labelIds[]`, `queued`. | +| `list` | Paged, cursor-stable list with lifecycle/scope filters and shared issue search semantics (below). | | `get` | Fetch by `id`. Optional `include` hydrates description / comments / attachments / relations / currentRun / labels in one round-trip. | | `create` | `{ title, description?, projectId?, priority?, statusId?, labelIds? }` → full issue. | | `queue` | Set `queued: true`. Dispatcher only sees queued + unassigned issues. | @@ -162,6 +162,15 @@ no argument at all — in which case it uses the calling key's `linkedAgentId`. Keys without a linked agent that omit the argument get `400`. +**`list` search** — pass `query` as `KEY-N`, bare `N`, or `#N` for an exact +direct lookup. Full keys are case-insensitive and must use the authenticated +workspace key. Other text searches title, description, project key/name, +label name, human assignee name/handle/email, and assigned-agent name/profile +key. Direct and text searches both compose with lifecycle, project, label, +initiative, cycle, priority, agent, and API-key narrowing. Status and priority +are facets rather than free-text fields. Comments, attachments, audit text, AI +reasoning, and external-resource bodies are intentionally excluded. + **`get` with `include`** — by default, `issues.get` returns the lean shape (status + project + assignees) for backward compat. Pass an `include` object to hydrate optional sections in one round-trip: diff --git a/docs/reference/trpc.md b/docs/reference/trpc.md index bce8086f..1758244f 100644 --- a/docs/reference/trpc.md +++ b/docs/reference/trpc.md @@ -62,6 +62,20 @@ error shapes all flow from this router definition. ## Notable procedures +### `issue.list` / `issue.count` search + +Both procedures share the same filter builder, including search, so counts and +rows cannot diverge. `query` recognizes case-insensitive `KEY-N`, bare `N`, and +`#N` as exact issue identifiers. A full key must match the active workspace. +Ordinary text searches title, description, project key/name, label name, human +assignee name/handle/email, and assigned-agent name/profile key. Existing +workspace, archive, lifecycle, saved-view, board-column, and API-key scope +clauses are composed with the search predicate rather than bypassed. + +Normal-text ordering retains the requested configured sort plus a unique `id` +tiebreaker. Cursor pages skip the cursor row and return the last emitted row as +the next cursor, preventing duplicates and gaps at tied sort values. + ### `agent.pipeline` Returns the operator's per-agent dashboard data: diff --git a/src/app/(app)/w/[slug]/issues/page.tsx b/src/app/(app)/w/[slug]/issues/page.tsx index 43b68f50..5fcf6c71 100644 --- a/src/app/(app)/w/[slug]/issues/page.tsx +++ b/src/app/(app)/w/[slug]/issues/page.tsx @@ -399,11 +399,12 @@ export default function IssuesPage() { onChange={(e) => setQuery(e.target.value)} placeholder={ showArchived - ? "Search archived issues…" + ? "Search archived issues by key, number, or metadata…" : scope === "open" - ? "Search open issues…" - : "Search all issues…" + ? "Search open issues by key, number, or metadata…" + : "Search all issues by key, number, or metadata…" } + title="Use KEY-N, N, or #N for an exact issue; otherwise search titles, descriptions, projects, labels, assignees, and agents." aria-label={ showArchived ? "Search archived issues" diff --git a/src/server/routers/__tests__/apiKey.test.ts b/src/server/routers/__tests__/apiKey.test.ts index 90aa5dfe..495bd075 100644 --- a/src/server/routers/__tests__/apiKey.test.ts +++ b/src/server/routers/__tests__/apiKey.test.ts @@ -269,7 +269,7 @@ describe("apiKey (access) router — narrowing", () => { data: { workspaceId: fixture.workspace.id, name: "bot", color: "#b0b" }, }); const scoped = await createIssue(fixture); - await createIssue(fixture); // unscoped + const otherIssue = await createIssue(fixture); // unscoped await prisma.issueLabel.create({ data: { issueId: scoped.id, labelId: label.id }, }); @@ -293,5 +293,10 @@ describe("apiKey (access) router — narrowing", () => { const ids = items.map((i) => i.id); expect(ids).toContain(scoped.id); expect(ids.length).toBe(1); + + const directAllowed = await caller.list({ query: String(scoped.number) }); + expect(directAllowed.items.map((issue) => issue.id)).toEqual([scoped.id]); + const directDenied = await caller.list({ query: String(otherIssue.number) }); + expect(directDenied.items).toEqual([]); }); }); diff --git a/src/server/routers/__tests__/command-palette.test.ts b/src/server/routers/__tests__/command-palette.test.ts new file mode 100644 index 00000000..f0af560f --- /dev/null +++ b/src/server/routers/__tests__/command-palette.test.ts @@ -0,0 +1,60 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { commandPaletteRouter } from "@/server/routers/command-palette"; +import { + buildContext, + createIssue, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "./helpers"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(disconnectPrisma); + +describe("command palette issue search", () => { + it("shares exact identifier and metadata semantics with issue.list", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "CP" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const project = await prisma.project.create({ + data: { + workspaceId: fixture.workspace.id, + key: "PAL", + name: "Palette Metadata", + createdById: fixture.user.id, + }, + }); + const issue = await createIssue(fixture, { title: "Palette target", projectId: project.id }); + const caller = commandPaletteRouter.createCaller(await buildContext(fixture)); + + for (const query of [ + `${fixture.workspace.key.toLowerCase()}-${issue.number}`, + String(issue.number), + `#${issue.number}`, + "Palette Metadata", + ]) { + const result = await caller.search({ + query, + workspaceId: fixture.workspace.id, + limit: 6, + }); + expect( + result.issues.map((row) => row.id), + query, + ).toEqual([issue.id]); + } + + const wrongKey = await caller.search({ + query: `WRONG-${issue.number}`, + workspaceId: fixture.workspace.id, + limit: 6, + }); + expect(wrongKey.issues).toEqual([]); + }); +}); diff --git a/src/server/routers/__tests__/issue.test.ts b/src/server/routers/__tests__/issue.test.ts index 054d6f46..234573f1 100644 --- a/src/server/routers/__tests__/issue.test.ts +++ b/src/server/routers/__tests__/issue.test.ts @@ -38,6 +38,174 @@ async function setup() { } describe("issueRouter — list filtering", () => { + it("searches exact identifiers without crossing workspace or lifecycle boundaries", async () => { + const { caller, fixture } = await setup(); + const other = await createWorkspaceFixture({ keyPrefix: "OTH" }); + fixtures.push(other); + const target = await createIssue(fixture, { title: "Direct target" }); + await createIssue(other, { title: "Same number elsewhere" }); + + for (const query of [ + `${fixture.workspace.key.toLowerCase()}-${target.number}`, + String(target.number), + `#${target.number}`, + ]) { + const result = await caller.list({ query, includeDone: true, limit: 50 }); + expect(result.items.map((row) => row.id)).toEqual([target.id]); + await expect(caller.count({ query, includeDone: true, limit: 50 })).resolves.toEqual({ + count: 1, + }); + } + + const wrongKey = await caller.list({ + query: `${other.workspace.key}-${target.number}`, + includeDone: true, + limit: 50, + }); + expect(wrongKey.items).toEqual([]); + + const done = await createIssue(fixture, { title: "Terminal direct", statusCategory: "DONE" }); + expect( + (await caller.list({ query: String(done.number), includeDone: false, limit: 50 })).items, + ).toEqual([]); + expect( + ( + await caller.list({ + query: String(done.number), + statusCategories: ["DONE"], + includeDone: false, + limit: 50, + }) + ).items.map((row) => row.id), + ).toEqual([done.id]); + const canceled = await createIssue(fixture, { + title: "Canceled direct", + statusCategory: "CANCELED", + }); + expect( + ( + await caller.list({ + query: `#${canceled.number}`, + statusCategories: ["CANCELED"], + includeDone: false, + limit: 50, + }) + ).items.map((row) => row.id), + ).toEqual([canceled.id]); + + await getPrisma().issue.update({ + where: { id: target.id }, + data: { deletedAt: new Date() }, + }); + expect( + (await caller.list({ query: String(target.number), archived: false, limit: 50 })).items, + ).toEqual([]); + expect( + ( + await caller.list({ + query: String(target.number), + archived: true, + includeDone: true, + limit: 50, + }) + ).items.map((row) => row.id), + ).toEqual([target.id]); + }); + + it("searches operator-facing issue metadata and composes with facets", async () => { + const { caller, fixture } = await setup(); + const prisma = getPrisma(); + await prisma.user.update({ + where: { id: fixture.secondUser.id }, + data: { name: "Search Person", handle: `search-person-${Date.now()}` }, + }); + const project = await prisma.project.create({ + data: { + workspaceId: fixture.workspace.id, + key: "META", + name: "Metadata Project", + createdById: fixture.user.id, + }, + }); + const label = await prisma.label.create({ + data: { workspaceId: fixture.workspace.id, name: "Search Label", color: "#123456" }, + }); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Search Agent", + profileKey: `search-agent-${Date.now()}`, + }, + }); + const issue = await createIssue(fixture, { title: "Title Needle", projectId: project.id }); + await prisma.issue.update({ + where: { id: issue.id }, + data: { description: "Description Needle", assignedAgentId: agent.id }, + }); + await prisma.issueAssignee.create({ + data: { issueId: issue.id, userId: fixture.secondUser.id }, + }); + await prisma.issueLabel.create({ data: { issueId: issue.id, labelId: label.id } }); + + for (const query of [ + "Title Needle", + "Description Needle", + "META", + "Metadata Project", + "Search Label", + "Search Person", + fixture.secondUser.email, + "Search Agent", + agent.profileKey, + ]) { + const result = await caller.list({ query, includeDone: false, limit: 50 }); + expect( + result.items.map((row) => row.id), + query, + ).toContain(issue.id); + await expect(caller.count({ query, includeDone: false, limit: 50 })).resolves.toEqual({ + count: 1, + }); + } + + const faceted = await caller.list({ + query: "Search Label", + projectIds: [project.id], + labelIds: [label.id], + statusCategories: ["TODO"], + includeDone: false, + limit: 50, + }); + expect(faceted.items.map((row) => row.id)).toEqual([issue.id]); + }); + + it("keeps ordinary-text sorting stable across cursor pages", async () => { + const { caller, fixture } = await setup(); + const created = []; + for (const title of ["Alpha page token", "Bravo page token", "Charlie page token"]) { + created.push(await createIssue(fixture, { title })); + } + const first = await caller.list({ + query: "page token", + includeDone: true, + sort: "title", + limit: 2, + }); + expect(first.items.map((row) => row.title)).toEqual(["Alpha page token", "Bravo page token"]); + expect(first.nextCursor).toBeTruthy(); + const second = await caller.list({ + query: "page token", + includeDone: true, + sort: "title", + cursor: first.nextCursor, + limit: 2, + }); + expect(second.items.map((row) => row.title)).toEqual(["Charlie page token"]); + expect(new Set([...first.items, ...second.items].map((row) => row.id))).toEqual( + new Set(created.map((row) => row.id)), + ); + }); + it("keeps terminal statuses reachable when explicitly filtered", async () => { const { caller, fixture } = await setup(); const prisma = getPrisma(); diff --git a/src/server/routers/command-palette.ts b/src/server/routers/command-palette.ts index 96dedf7b..e6c16f7e 100644 --- a/src/server/routers/command-palette.ts +++ b/src/server/routers/command-palette.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { Prisma } from "@prisma/client"; import { router, protectedProcedure } from "@/server/trpc"; +import { issueSearchWhere, parseIssueSearch } from "@/server/services/issue-search"; /** * Command palette search — fans out across every entity type the @@ -8,9 +9,9 @@ import { router, protectedProcedure } from "@/server/trpc"; * views / cycles / agents) and returns a per-type bucket so the * client can render section headers without re-grouping. * - * Matching: simple ILIKE on the relevant fields per type. Good enough - * for now; full-text + ranking comes later. Each bucket is capped at - * `limit` (default 6) so the UI doesn't have to truncate post-hoc. + * Matching: issues use the shared direct-identifier/metadata contract; + * other entities use simple ILIKE on their relevant fields. Each bucket + * is capped at `limit` (default 6) so the UI doesn't truncate post-hoc. * * Scoping: * - `workspaceId` provided → only that workspace. @@ -121,55 +122,33 @@ export const commandPaletteRouter = router({ ...(input.workspaceId ? { id: input.workspaceId } : {}), }; - // Issue queries are special: support `KEY-N` direct match on the - // composite (workspace.key + number). Most users will type "AXI-12" - // and expect to land on that issue, not a fuzzy title match. - const keyMatch = /^([A-Z]{1,8})-(\d+)$/i.exec(q); + const parsedIssueSearch = parseIssueSearch(q); const [issues, projects, initiatives, savedViews, cycles, agents, goals, crews, plans] = await Promise.all([ // ---- Issues ----------------------------------------------------- - keyMatch - ? ctx.db.issue.findMany({ - where: { - deletedAt: null, - workspace: workspaceGate, - AND: [ - { workspace: { key: keyMatch[1].toUpperCase() } }, - { number: parseInt(keyMatch[2], 10) }, - ], - }, - take: input.limit, - select: { - id: true, - number: true, - title: true, - projectId: true, - workspaceId: true, - workspace: { select: { slug: true, key: true } }, - project: { select: { key: true } }, - status: { select: { name: true, color: true } }, - }, - }) - : ctx.db.issue.findMany({ - where: { - deletedAt: null, - workspace: workspaceGate, - title: { contains: q, mode: "insensitive" }, - }, - take: input.limit, - orderBy: { updatedAt: "desc" }, - select: { - id: true, - number: true, - title: true, - projectId: true, - workspaceId: true, - workspace: { select: { slug: true, key: true } }, - project: { select: { key: true } }, - status: { select: { name: true, color: true } }, - }, - }), + ctx.db.issue.findMany({ + where: { + deletedAt: null, + workspace: workspaceGate, + AND: [issueSearchWhere(q)!], + }, + take: input.limit, + orderBy: + parsedIssueSearch.kind === "identifier" + ? [{ workspaceId: "asc" }, { number: "asc" }, { id: "asc" }] + : [{ updatedAt: "desc" }, { id: "desc" }], + select: { + id: true, + number: true, + title: true, + projectId: true, + workspaceId: true, + workspace: { select: { slug: true, key: true } }, + project: { select: { key: true } }, + status: { select: { name: true, color: true } }, + }, + }), // ---- Projects --------------------------------------------------- ctx.db.project.findMany({ where: { diff --git a/src/server/routers/issue.ts b/src/server/routers/issue.ts index 22064942..1651deaa 100644 --- a/src/server/routers/issue.ts +++ b/src/server/routers/issue.ts @@ -18,6 +18,7 @@ import { maybeAutoDispatch, recordManualDispatchReason } from "@/server/services import { maybeApplyAgentTemplate } from "@/server/services/agent-template"; import { triageIssue } from "@/server/services/ai-triage"; import { createIssueWithSideEffects } from "@/server/services/issue-create"; +import { issueSearchWhere } from "@/server/services/issue-search"; import { abandonRunsForAgentReassignment, finishRunsForIssue, @@ -399,14 +400,8 @@ async function buildIssueListWhere( OR: [{ projectId: null }, { project: { initiativeId: null } }], }); } - if (input.query) { - andClauses.push({ - OR: [ - { title: { contains: input.query, mode: "insensitive" } }, - { description: { contains: input.query, mode: "insensitive" } }, - ], - }); - } + const searchWhere = issueSearchWhere(input.query); + if (searchWhere) andClauses.push(searchWhere); // `unassigned` = no human assignees AND no agent. Implemented as a // single AND so it composes with explicit assigneeIds / assignedAgentId. @@ -575,6 +570,7 @@ export const issueRouter = router({ const rows = await ctx.db.issue.findMany({ where, take: input.limit + 1, + skip: input.cursor ? 1 : 0, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy, include: { @@ -603,7 +599,10 @@ export const issueRouter = router({ }, }); let nextCursor: string | undefined; - if (rows.length > input.limit) nextCursor = rows.pop()!.id; + if (rows.length > input.limit) { + rows.pop(); + nextCursor = rows.at(-1)?.id; + } const withFlags = await annotateUnblocked(ctx, rows); return { items: withFlags, nextCursor }; }), diff --git a/src/server/services/__tests__/mcp.test.ts b/src/server/services/__tests__/mcp.test.ts index 7aae4eac..2759e119 100644 --- a/src/server/services/__tests__/mcp.test.ts +++ b/src/server/services/__tests__/mcp.test.ts @@ -3100,6 +3100,79 @@ describe("mcp — Phase A: filter passthrough, generic update, labels", () => { expect(compound.map((i) => i.id)).toEqual([a.id]); }); + it("issues.list shares direct identifier and metadata search semantics", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "MSR" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const project = await prisma.project.create({ + data: { + workspaceId: fixture.workspace.id, + key: "SEARCH", + name: "MCP Search Project", + createdById: fixture.user.id, + }, + }); + const label = await prisma.label.create({ + data: { workspaceId: fixture.workspace.id, name: "MCP Search Label", color: "#765432" }, + }); + await prisma.user.update({ + where: { id: fixture.secondUser.id }, + data: { name: "MCP Search Person", handle: `mcp-search-${Date.now()}` }, + }); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "MCP Search Agent", + profileKey: `mcp-agent-${Date.now()}`, + }, + }); + const issue = await createIssue(fixture, { title: "MCP Search Title", projectId: project.id }); + const outside = await createIssue(fixture, { title: "Outside scoped issue" }); + await prisma.issue.update({ + where: { id: issue.id }, + data: { description: "MCP Search Description", assignedAgentId: agent.id }, + }); + await prisma.issueAssignee.create({ + data: { issueId: issue.id, userId: fixture.secondUser.id }, + }); + await prisma.issueLabel.create({ data: { issueId: issue.id, labelId: label.id } }); + const { ctx } = buildMcpCtx(fixture, { labelIds: [label.id] }); + + for (const query of [ + `${fixture.workspace.key.toLowerCase()}-${issue.number}`, + String(issue.number), + `#${issue.number}`, + "MCP Search Title", + "MCP Search Description", + "SEARCH", + "MCP Search Project", + "MCP Search Label", + "MCP Search Person", + fixture.secondUser.email, + "MCP Search Agent", + agent.profileKey, + ]) { + const result = dataOf<{ id: string }>( + await call("issues.list", { query, includeDone: true }, ctx), + ); + expect( + result.map((row) => row.id), + query, + ).toEqual([issue.id]); + } + + expect( + dataOf<{ id: string }>( + await call("issues.list", { query: String(outside.number), includeDone: true }, ctx), + ), + ).toEqual([]); + expect( + dataOf<{ id: string }>( + await call("issues.list", { query: `WRONG-${issue.number}`, includeDone: true }, ctx), + ), + ).toEqual([]); + }); + it("issues.list returns the Orca DTO, honors sort params, and paginates by cursor", async () => { const fixture = await createWorkspaceFixture({ keyPrefix: "ORC" }); fixtures.push(fixture); diff --git a/src/server/services/issue-search.ts b/src/server/services/issue-search.ts new file mode 100644 index 00000000..f5077dcc --- /dev/null +++ b/src/server/services/issue-search.ts @@ -0,0 +1,86 @@ +import type { Prisma } from "@prisma/client"; + +export type ParsedIssueSearch = + | { kind: "empty" } + | { kind: "identifier"; number: number; workspaceKey?: string } + | { kind: "text"; query: string }; + +/** + * Normalize operator-entered issue search without turning search into a + * separate indexing concern. Identifiers are deliberately exact: once a + * query looks like KEY-N, N, or #N, it never falls back to fuzzy text. + */ +export function parseIssueSearch(raw: string | null | undefined): ParsedIssueSearch { + const query = raw?.trim().replace(/\s+/g, " ") ?? ""; + if (!query) return { kind: "empty" }; + + const fullKey = /^([a-z][a-z0-9]{0,15})-(\d+)$/i.exec(query); + if (fullKey) { + const number = Number(fullKey[2]); + return { + kind: "identifier", + workspaceKey: fullKey[1].toUpperCase(), + number: Number.isSafeInteger(number) && number <= 2_147_483_647 ? number : -1, + }; + } + + const numberOnly = /^#?(\d+)$/.exec(query); + if (numberOnly) { + const number = Number(numberOnly[1]); + return { + kind: "identifier", + number: Number.isSafeInteger(number) && number <= 2_147_483_647 ? number : -1, + }; + } + + return { kind: "text", query }; +} + +/** + * Shared Prisma predicate for every operator-facing issue-search surface. + * Callers must compose this with their existing workspace, lifecycle, + * archive, facet, and API-key scope clauses. + */ +export function issueSearchWhere( + raw: string | null | undefined, +): Prisma.IssueWhereInput | undefined { + const parsed = parseIssueSearch(raw); + if (parsed.kind === "empty") return undefined; + + if (parsed.kind === "identifier") { + return { + number: parsed.number, + ...(parsed.workspaceKey + ? { + workspace: { + key: { equals: parsed.workspaceKey, mode: "insensitive" }, + }, + } + : {}), + }; + } + + const contains = { contains: parsed.query, mode: "insensitive" as const }; + return { + OR: [ + { title: contains }, + { description: contains }, + { project: { is: { OR: [{ key: contains }, { name: contains }] } } }, + { labels: { some: { label: { name: contains } } } }, + { + assignees: { + some: { + user: { + OR: [{ name: contains }, { handle: contains }, { email: contains }], + }, + }, + }, + }, + { + assignedAgent: { + is: { OR: [{ name: contains }, { profileKey: contains }] }, + }, + }, + ], + }; +} diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index 750fb311..10bf689f 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -49,6 +49,7 @@ import { setIssueAgentWakeTarget, } from "@/server/services/issue-watchers"; import { extractMentions } from "@/server/services/mentions"; +import { issueSearchWhere } from "@/server/services/issue-search"; import { abandonRunsForAgentReassignment, openOrTouchRun, @@ -1211,7 +1212,9 @@ export const mcpTools = { .string() .max(200) .optional() - .describe("Fulltext search on title + description (case-insensitive)"), + .describe( + "Exact KEY-N, N, or #N lookup; otherwise case-insensitive search across title, description, project, label, human assignee, and assigned-agent metadata.", + ), // Singleton filters projectId: z.string().cuid().optional(), statusId: z.string().cuid().optional(), @@ -1294,11 +1297,8 @@ export const mcpTools = { const keyWhere = buildKeyScopeWhere(scopeCtx(ctx), "issue"); const createdByViewerId = input.createdByViewer ? await resolveActorId(ctx) : null; - // Mirrors the tRPC `issue.list` where-construction (issue.ts:294-428). - // Kept inline rather than DRY'd because the tRPC procedure consumes - // the full session context (cursor, blocked-set helper bound to - // `ctx.db`) and the MCP path only needs the simpler subset. Future - // refactor: extract a shared `buildIssueListWhere(filter, scope)`. + // Mirrors the tRPC `issue.list` where-construction. Search itself is + // shared so identifiers and metadata never drift between UI and MCP. const andClauses: Array> = []; if (input.initiativeId === null || input.withoutInitiative === true) { @@ -1306,14 +1306,8 @@ export const mcpTools = { OR: [{ projectId: null }, { project: { initiativeId: null } }], }); } - if (input.query) { - andClauses.push({ - OR: [ - { title: { contains: input.query, mode: "insensitive" as const } }, - { description: { contains: input.query, mode: "insensitive" as const } }, - ], - }); - } + const searchWhere = issueSearchWhere(input.query); + if (searchWhere) andClauses.push(searchWhere); if (input.unassigned === true) { andClauses.push({ AND: [{ assignees: { none: {} } }, { assignedAgentId: null }], diff --git a/tests/e2e/issues-scope.spec.ts b/tests/e2e/issues-scope.spec.ts index e74d3716..19681455 100644 --- a/tests/e2e/issues-scope.spec.ts +++ b/tests/e2e/issues-scope.spec.ts @@ -1,8 +1,6 @@ import { expect, test } from "@playwright/test"; -test("lifecycle scope stays consistent when switching between list and kanban", async ({ - page, -}) => { +test("identifier search persists across lifecycle scope, list, and kanban", async ({ page }) => { await page.addInitScript(() => { localStorage.setItem("forge:view:issues", "list"); }); @@ -15,9 +13,13 @@ test("lifecycle scope stays consistent when switching between list and kanban", .filter({ hasText: "Webhook delivery retry backoff" }); await expect(openScope).toHaveAttribute("aria-pressed", "true"); - await page - .getByRole("textbox", { name: "Search open issues" }) - .fill("Webhook delivery retry backoff"); + const search = page.getByRole("textbox", { name: "Search open issues" }); + await expect(search).toHaveAttribute( + "placeholder", + "Search open issues by key, number, or metadata…", + ); + await search.fill("frg-13"); + await expect(page).toHaveURL(/(?:\?|&)q=frg-13(?:&|$)/); await expect(page.getByText("No issues match this view")).toBeVisible(); await expect(page.getByText("Webhook delivery retry backoff", { exact: true })).toHaveCount(0); @@ -29,6 +31,14 @@ test("lifecycle scope stays consistent when switching between list and kanban", await expect(page.getByRole("textbox", { name: "Search all issues" })).toBeVisible(); await expect(visibleCompletedIssue).toBeVisible(); + await page.reload(); + await expect(page.getByRole("textbox", { name: "Search all issues" })).toHaveValue("frg-13"); + await expect(visibleCompletedIssue).toBeVisible(); + + await page.getByRole("textbox", { name: "Search all issues" }).fill("13"); + await expect(page).toHaveURL(/(?:\?|&)q=13(?:&|$)/); + await expect(visibleCompletedIssue).toBeVisible(); + await page.getByRole("button", { name: "List", exact: true }).click(); await expect(visibleCompletedIssue).toBeVisible(); }); diff --git a/tests/unit/issue-search.test.ts b/tests/unit/issue-search.test.ts new file mode 100644 index 00000000..21d706bf --- /dev/null +++ b/tests/unit/issue-search.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { issueSearchWhere, parseIssueSearch } from "@/server/services/issue-search"; + +describe("issue search parser", () => { + it("normalizes direct identifiers", () => { + expect(parseIssueSearch(" axi-117 ")).toEqual({ + kind: "identifier", + workspaceKey: "AXI", + number: 117, + }); + expect(parseIssueSearch("117")).toEqual({ kind: "identifier", number: 117 }); + expect(parseIssueSearch("#117")).toEqual({ kind: "identifier", number: 117 }); + expect(parseIssueSearch("999999999999999999999")).toEqual({ + kind: "identifier", + number: -1, + }); + }); + + it("normalizes ordinary text and keeps identifiers exact", () => { + expect(parseIssueSearch(" release train ")).toEqual({ + kind: "text", + query: "release train", + }); + expect(issueSearchWhere("#42")).toEqual({ number: 42 }); + }); +});