From 9af240d43d25897824dc63a297f5464cef2a7b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 22 Sep 2026 16:09:33 +0200 Subject: [PATCH 1/2] fix(search): rewrite numeric project: filters to project_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue search treats project as a slug, so agents that paste project: hit a 400 (CLI-FA). Same auto-repair path as OR→in-list: rewrite digits to project_id, leave slugs and namespaced keys alone. Co-authored-by: Cursor --- packages/cli/src/lib/search-query.ts | 53 +++++++++++++++++--- packages/cli/test/lib/search-query.test.ts | 57 ++++++++++++++++++++++ 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/lib/search-query.ts b/packages/cli/src/lib/search-query.ts index 88196386de..9ef6ea85c8 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,21 +350,30 @@ 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}"` - ); + if (withNumericProject !== query) { + 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." + ); + } + notes.push(`Running query: "${withNumericProject}"`); + log.warn(notes.join(" ")); } // Check for OR inside paren groups first — these are opaque and can't @@ -394,7 +407,7 @@ export function sanitizeQuery(query: string | undefined): string | undefined { return sanitized; } - return normalized; + return withNumericProject; } /** @@ -511,6 +524,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 +541,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 05a37bdfe6..e27d3bf5c7 100644 --- a/packages/cli/test/lib/search-query.test.ts +++ b/packages/cli/test/lib/search-query.test.ts @@ -98,6 +98,63 @@ 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]" + ); + }); +}); + // --------------------------------------------------------------------------- // OR → in-list rewrites (successful) // --------------------------------------------------------------------------- From 7fab5dc7ee3d4442c59f6213795c4367031027d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 22 Sep 2026 18:23:46 +0200 Subject: [PATCH 2/2] fix(search): warn once with the final rewritten query (#1624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up to #1621. `sanitizeQuery` warned `Running query:` after the numeric `project:` rewrite, then again after OR/AND. Combined input logged an intermediate that never ran. All rewrites now finish first. One warning: reasons on the first line, `Running query:` on the second, quoting the query that is actually sent. Same for a failed OR: no `Running query:` before the `ValidationError`. Stacked on #1621 — merge that first. ### Before / after ```bash sentry issue list --json -q "project:123 OR project:456" ``` **Before** — two warnings, first one is a lie: ```text ⚠ `project` is the slug; numeric ids use project_id. Rewrote numeric project: filters. Running query: "project_id:123 OR project_id:456" ⚠ Rewrote OR using in-list syntax: key:[val1,val2]. Running query: "project_id:[123,456]" ``` **After** — one warning, final query on its own line: ```text ⚠ `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]" ``` ## Test plan - [ ] `project:123 OR project:456` → `project_id:[123,456]`, one warn, `Running query:` on the second line - [ ] `project:123 AND is:unresolved` → `project_id:123 is:unresolved`, same shape - [ ] `level:error OR assigned:me` throws with no `Running query:` - [ ] From `packages/cli`: `pnpm exec vitest run test/lib/search-query.test.ts test/lib/search-query.warn.test.ts` --------- Co-authored-by: Cursor --- packages/cli/src/lib/search-query.ts | 67 ++++++++++----- packages/cli/test/lib/search-query.test.ts | 6 ++ .../cli/test/lib/search-query.warn.test.ts | 86 +++++++++++++++++++ 3 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 packages/cli/test/lib/search-query.warn.test.ts diff --git a/packages/cli/src/lib/search-query.ts b/packages/cli/src/lib/search-query.ts index 9ef6ea85c8..2813f5a434 100644 --- a/packages/cli/src/lib/search-query.ts +++ b/packages/cli/src/lib/search-query.ts @@ -362,19 +362,7 @@ export function sanitizeQuery(query: string | undefined): string | undefined { return withNumericProject; } - if (withNumericProject !== query) { - 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." - ); - } - notes.push(`Running query: "${withNumericProject}"`); - log.warn(notes.join(" ")); - } + 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, @@ -395,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; } + 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( diff --git a/packages/cli/test/lib/search-query.test.ts b/packages/cli/test/lib/search-query.test.ts index e27d3bf5c7..6bf9d7abf2 100644 --- a/packages/cli/test/lib/search-query.test.ts +++ b/packages/cli/test/lib/search-query.test.ts @@ -153,6 +153,12 @@ describe("sanitizeQuery: numeric project:", () => { "project:[frontend,6442225]" ); }); + + test("rewrites numeric project: then OR in one step", () => { + expect(sanitizeQuery("project:123 OR project:456")).toBe( + "project_id:[123,456]" + ); + }); }); // --------------------------------------------------------------------------- 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 0000000000..26f49cadf1 --- /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); + }); +});