diff --git a/packages/cli/src/lib/search-query.ts b/packages/cli/src/lib/search-query.ts index 88196386d..2813f5a43 100644 --- a/packages/cli/src/lib/search-query.ts +++ b/packages/cli/src/lib/search-query.ts @@ -10,6 +10,10 @@ * - **OR**: Attempted rewrite to in-list syntax (`key:[val1,val2]`) * when all OR operands share the same qualifier key. Throws a * {@link ValidationError} when the rewrite is not possible. + * - **`project:`**: `project` is the slug. Numeric ids belong on + * `project_id`. Agents often paste `project:4511…` (CLI-FA). Rewritten + * with a warning. Slugs, `project_id:…`, and namespaced keys + * (`bolt.project_id`) are left alone. * * Parsing uses a pre-compiled PEG parser generated from * `script/search-query.pegjs` (a simplified version of Sentry's @@ -346,22 +350,19 @@ export function sanitizeQuery(query: string | undefined): string | undefined { // These fix common patterns that agents/users produce, regardless of // whether the PEG parser would accept them. const normalized = normalizeQuery(query); + const withNumericProject = rewriteNumericProjectFilters(normalized); let nodes: SearchNode[]; // biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { - nodes = parse(normalized); + nodes = parse(withNumericProject); } catch { // PEG parse still failed after normalization — pass through to the // API which returns a proper 400 with actionable details. - return normalized; + return withNumericProject; } - if (normalized !== query) { - log.warn( - `Auto-repaired search query syntax. Running query: "${normalized}"` - ); - } + const notes = preParseRewriteNotes(query, normalized, withNumericProject); // Check for OR inside paren groups first — these are opaque and can't // be rewritten. Must throw even if top-level OR would be rewritable, @@ -382,37 +383,70 @@ export function sanitizeQuery(query: string | undefined): string | undefined { if (hasOr) { // Strip AND nodes before OR rewrite const withoutAnd = hasAnd ? stripAndNodes(nodes) : nodes; - return handleOr(withoutAnd, hasAnd); + const result = handleOr(withoutAnd, hasAnd, notes); + warnRunningQuery(notes, result); + return result; } if (hasAnd) { const sanitized = serializeNodes(stripAndNodes(nodes)); - log.warn( - "Sentry search implicitly ANDs terms — removed explicit AND operator. " + - `Running query: "${sanitized}"` + notes.push( + "Sentry search implicitly ANDs terms — removed explicit AND operator." ); + warnRunningQuery(notes, sanitized); return sanitized; } - return normalized; + warnRunningQuery(notes, withNumericProject); + return withNumericProject; +} + +/** Notes from text-layer rewrites that run before PEG parse. */ +function preParseRewriteNotes( + query: string, + normalized: string, + withNumericProject: string +): string[] { + const notes: string[] = []; + if (normalized !== query) { + notes.push("Auto-repaired search query syntax."); + } + if (withNumericProject !== normalized) { + notes.push( + "`project` is the slug; numeric ids use project_id. Rewrote numeric project: filters." + ); + } + return notes; +} + +/** + * One warning after every successful rewrite. Reasons on the first + * line; the query that will actually be sent on the second. Skip if + * nothing changed. + */ +function warnRunningQuery(notes: string[], result: string): void { + if (notes.length === 0) { + return; + } + log.warn(`${notes.join(" ")}\nRunning query: "${result}"`); } /** * Handle the OR rewrite path — extracted to keep `sanitizeQuery` under * the cognitive complexity limit. */ -function handleOr(nodes: SearchNode[], hasAnd: boolean): string { +function handleOr( + nodes: SearchNode[], + hasAnd: boolean, + notes: string[] +): string { const rewritten = tryRewriteOr(nodes); if (rewritten) { - const result = serializeNodes(rewritten); - const notes: string[] = []; notes.push("Rewrote OR using in-list syntax: key:[val1,val2]."); if (hasAnd) { notes.push("Also removed explicit AND (implicit in Sentry search)."); } - notes.push(`Running query: "${result}"`); - log.warn(notes.join(" ")); - return result; + return serializeNodes(rewritten); } throw new ValidationError( @@ -511,6 +545,15 @@ const BALANCED_BRACKET_RE = /\[[^\]]*\]/g; /** Trailing comma before closing bracket: `,]` */ const TRAILING_LIST_COMMA_RE = /,\s*\]$/; +/** + * `project:` as its own filter — not `bolt.project`, not `project_id`. + * Issue search treats `project` as a slug and `project_id` as a numeric id. + */ +const PROJECT_NUMERIC_RE = /(^|\s)(!?)project:(\d+)(?=\s|$)/gi; + +/** `project:[123,456]` — every list value must be digits. */ +const PROJECT_NUMERIC_LIST_RE = /(^|\s)(!?)project:\[(\d+(?:\s*,\s*\d+)*)\]/gi; + /** * Pattern that splits a query into alternating unquoted / quoted segments. * @@ -519,6 +562,23 @@ const TRAILING_LIST_COMMA_RE = /,\s*\]$/; */ const QUOTED_SEGMENT_RE = /"(?:[^"\\]|\\.)*"/g; +/** + * Rewrite `project:` / `project:[digits,…]` to `project_id`. + * + * `project` is the slug; a numeric value is almost always a pasted Sentry + * project id (CLI-FA). Namespaced keys (`bolt.project:…`) and slugs are + * untouched. Quoted regions are preserved via {@link transformUnquoted}. + */ +function rewriteNumericProjectFilters(query: string): string { + return transformUnquoted(query, (segment) => { + PROJECT_NUMERIC_RE.lastIndex = 0; + PROJECT_NUMERIC_LIST_RE.lastIndex = 0; + return segment + .replace(PROJECT_NUMERIC_RE, "$1$2project_id:$3") + .replace(PROJECT_NUMERIC_LIST_RE, "$1$2project_id:[$3]"); + }); +} + /** * Normalize a search query by applying a pipeline of text repairs. * diff --git a/packages/cli/test/lib/search-query.test.ts b/packages/cli/test/lib/search-query.test.ts index 05a37bdfe..6bf9d7abf 100644 --- a/packages/cli/test/lib/search-query.test.ts +++ b/packages/cli/test/lib/search-query.test.ts @@ -98,6 +98,69 @@ describe("sanitizeQuery: AND", () => { }); }); +// --------------------------------------------------------------------------- +// project: → project_id +// --------------------------------------------------------------------------- + +describe("sanitizeQuery: numeric project:", () => { + test("rewrites a numeric project: filter to project_id", () => { + expect( + sanitizeQuery("project:4511730126487632 environment:vercel-production") + ).toBe("project_id:4511730126487632 environment:vercel-production"); + }); + + test("rewrites a numeric project: in-list", () => { + expect( + sanitizeQuery("is:unresolved project:[4505521413357568,6442225]") + ).toBe("is:unresolved project_id:[4505521413357568,6442225]"); + }); + + test("rewrites a negated numeric project: filter", () => { + expect(sanitizeQuery("!project:1423462 lastSeen:-1h")).toBe( + "!project_id:1423462 lastSeen:-1h" + ); + }); + + test("leaves project slugs alone", () => { + expect(sanitizeQuery("project:frontend is:unresolved")).toBe( + "project:frontend is:unresolved" + ); + }); + + test("leaves project_id numeric filters alone", () => { + expect(sanitizeQuery("project_id:4511730126487632")).toBe( + "project_id:4511730126487632" + ); + }); + + test("leaves namespaced project keys alone", () => { + expect(sanitizeQuery("bolt.project_id:70054175")).toBe( + "bolt.project_id:70054175" + ); + expect(sanitizeQuery("bolt.project:70054175")).toBe( + "bolt.project:70054175" + ); + }); + + test("does not rewrite a numeric id inside a quoted value", () => { + expect(sanitizeQuery('message:"project:4511730126487632"')).toBe( + 'message:"project:4511730126487632"' + ); + }); + + test("does not rewrite mixed slug/numeric in-lists", () => { + expect(sanitizeQuery("project:[frontend,6442225]")).toBe( + "project:[frontend,6442225]" + ); + }); + + test("rewrites numeric project: then OR in one step", () => { + expect(sanitizeQuery("project:123 OR project:456")).toBe( + "project_id:[123,456]" + ); + }); +}); + // --------------------------------------------------------------------------- // OR → in-list rewrites (successful) // --------------------------------------------------------------------------- diff --git a/packages/cli/test/lib/search-query.warn.test.ts b/packages/cli/test/lib/search-query.warn.test.ts new file mode 100644 index 000000000..26f49cadf --- /dev/null +++ b/packages/cli/test/lib/search-query.warn.test.ts @@ -0,0 +1,86 @@ +/** + * Warning copy for stacked search-query rewrites. + * + * `sanitizeQuery` must emit one warn: reasons, then a newline, then + * `Running query:` quoting the string that is actually sent. + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const { fakeLog } = vi.hoisted(() => { + const log = { + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + withTag() { + return log; + }, + }; + return { fakeLog: log }; +}); + +vi.mock("../../src/lib/logger.js", () => ({ + logger: fakeLog, +})); + +const { sanitizeQuery } = await import("../../src/lib/search-query.js"); + +function runningQueries(): string[] { + return fakeLog.warn.mock.calls + .map((call) => String(call[0])) + .filter((msg) => msg.includes("Running query:")); +} + +describe("sanitizeQuery: rewrite warnings", () => { + beforeEach(() => { + fakeLog.warn.mockClear(); + }); + + test("numeric project: plus OR warns once with the final in-list", () => { + expect(sanitizeQuery("project:123 OR project:456")).toBe( + "project_id:[123,456]" + ); + const warns = runningQueries(); + expect(warns).toHaveLength(1); + expect(warns[0].split("\n")).toEqual([ + "`project` is the slug; numeric ids use project_id. Rewrote numeric project: filters. Rewrote OR using in-list syntax: key:[val1,val2].", + 'Running query: "project_id:[123,456]"', + ]); + expect(warns[0]).not.toContain("project_id:123 OR project_id:456"); + }); + + test("numeric project: plus AND warns once with the stripped query", () => { + expect(sanitizeQuery("project:123 AND is:unresolved")).toBe( + "project_id:123 is:unresolved" + ); + const warns = runningQueries(); + expect(warns).toHaveLength(1); + expect(warns[0].split("\n")).toEqual([ + "`project` is the slug; numeric ids use project_id. Rewrote numeric project: filters. Sentry search implicitly ANDs terms — removed explicit AND operator.", + 'Running query: "project_id:123 is:unresolved"', + ]); + }); + + test("OR-only still warns once with the in-list", () => { + expect(sanitizeQuery("level:error OR level:warning")).toBe( + "level:[error,warning]" + ); + const warns = runningQueries(); + expect(warns).toHaveLength(1); + expect(warns[0].split("\n")).toEqual([ + "Rewrote OR using in-list syntax: key:[val1,val2].", + 'Running query: "level:[error,warning]"', + ]); + }); + + test("does not warn Running query: when OR rewrite fails", () => { + expect(() => sanitizeQuery("level:error OR assigned:me")).toThrow(); + expect(runningQueries()).toHaveLength(0); + }); + + test("does not warn Running query: when numeric rewrite is followed by a failed OR", () => { + expect(() => sanitizeQuery("project:123 OR assigned:me")).toThrow(); + expect(runningQueries()).toHaveLength(0); + }); +});