Skip to content

Commit 6f9d323

Browse files
betegoncursoragent
andauthored
fix(search): rewrite numeric project: filters to project_id (#1621)
## Summary Issue search treats `project` as a **slug**. Agents paste Sentry's numeric project id into `--query` and get a 400 (CLI-FA). `project_id` is the numeric field. `sanitizeQuery` now rewrites `project:<digits>` and all-numeric `project:[id,id]` to `project_id`, with a warning, same as the existing OR → in-list repair. Slugs (`project:frontend`), already-correct `project_id:…`, namespaced keys (`bolt.project_id`), quoted strings, and mixed in-lists are left alone. ### Before / after Command from CLI-FA: ```bash sentry issue list --json -q "project:4511730126487632 environment:vercel-production" ``` **Before** — 400, `ApiError`, grouped in CLI-FA. The API rejects a numeric value on the slug field: ```text Error: Failed to fetch issues from 1 project(s): Failed to list issues: 400 Bad Request Endpoint: /api/0/organizations/…/issues/ Invalid query: … Suggestions: • Check your --query syntax (Sentry search reference: …) • Try a shorter time range: --period 14d or --period 24h • Verify you have access to the target project: sentry project list <org>/ ``` **After** — rewrite in `sanitizeQuery`, warn, run the query, exit 0: ```text ⚠ `project` is the slug; numeric ids use project_id. Rewrote numeric project: filters. Running query: "project_id:4511730126487632 environment:vercel-production" ``` Stdout is the issue list JSON. Same rewrite for `project:[4505521413357568,6442225]` → `project_id:[…]`. ## Test plan - [ ] `sanitizeQuery("project:4511730126487632 environment:vercel-production")` → `project_id:4511730126487632 environment:vercel-production` - [ ] `project:[4505521413357568,6442225]` rewrites; `project:[frontend,6442225]` does not - [ ] `project:frontend`, `project_id:123`, `bolt.project_id:70054175`, `message:"project:123"` unchanged - [ ] From `packages/cli`: `pnpm exec vitest run test/lib/search-query.test.ts test/lib/search-query.property.test.ts` --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 596a6d5 commit 6f9d323

3 files changed

Lines changed: 252 additions & 22 deletions

File tree

‎packages/cli/src/lib/search-query.ts‎

Lines changed: 83 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
* - **OR**: Attempted rewrite to in-list syntax (`key:[val1,val2]`)
1111
* when all OR operands share the same qualifier key. Throws a
1212
* {@link ValidationError} when the rewrite is not possible.
13+
* - **`project:<digits>`**: `project` is the slug. Numeric ids belong on
14+
* `project_id`. Agents often paste `project:4511…` (CLI-FA). Rewritten
15+
* with a warning. Slugs, `project_id:…`, and namespaced keys
16+
* (`bolt.project_id`) are left alone.
1317
*
1418
* Parsing uses a pre-compiled PEG parser generated from
1519
* `script/search-query.pegjs` (a simplified version of Sentry's
@@ -346,21 +350,19 @@ export function sanitizeQuery(query: string | undefined): string | undefined {
346350
// These fix common patterns that agents/users produce, regardless of
347351
// whether the PEG parser would accept them.
348352
const normalized = normalizeQuery(query);
353+
const withNumericProject = rewriteNumericProjectFilters(normalized);
354+
const notes = preParseRewriteNotes(query, normalized, withNumericProject);
349355

350356
let nodes: SearchNode[];
351-
// biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
352357
try {
353-
nodes = parse(normalized);
354-
} catch {
358+
nodes = parse(withNumericProject);
359+
} catch (err) {
355360
// PEG parse still failed after normalization — pass through to the
356-
// API which returns a proper 400 with actionable details.
357-
return normalized;
358-
}
359-
360-
if (normalized !== query) {
361-
log.warn(
362-
`Auto-repaired search query syntax. Running query: "${normalized}"`
363-
);
361+
// API which returns a proper 400 with actionable details. The text
362+
// rewrites already ran, so say so: the 400 will quote them.
363+
log.debug("Search query did not parse; sending as-is", err);
364+
warnRunningQuery(notes, withNumericProject);
365+
return withNumericProject;
364366
}
365367

366368
// Check for OR inside paren groups first — these are opaque and can't
@@ -382,37 +384,70 @@ export function sanitizeQuery(query: string | undefined): string | undefined {
382384
if (hasOr) {
383385
// Strip AND nodes before OR rewrite
384386
const withoutAnd = hasAnd ? stripAndNodes(nodes) : nodes;
385-
return handleOr(withoutAnd, hasAnd);
387+
const result = handleOr(withoutAnd, hasAnd, notes);
388+
warnRunningQuery(notes, result);
389+
return result;
386390
}
387391

388392
if (hasAnd) {
389393
const sanitized = serializeNodes(stripAndNodes(nodes));
390-
log.warn(
391-
"Sentry search implicitly ANDs terms — removed explicit AND operator. " +
392-
`Running query: "${sanitized}"`
394+
notes.push(
395+
"Sentry search implicitly ANDs terms — removed explicit AND operator."
393396
);
397+
warnRunningQuery(notes, sanitized);
394398
return sanitized;
395399
}
396400

397-
return normalized;
401+
warnRunningQuery(notes, withNumericProject);
402+
return withNumericProject;
403+
}
404+
405+
/** Notes from text-layer rewrites that run before PEG parse. */
406+
function preParseRewriteNotes(
407+
query: string,
408+
normalized: string,
409+
withNumericProject: string
410+
): string[] {
411+
const notes: string[] = [];
412+
if (normalized !== query) {
413+
notes.push("Auto-repaired search query syntax.");
414+
}
415+
if (withNumericProject !== normalized) {
416+
notes.push(
417+
"`project` is the slug; numeric ids use project_id. Rewrote numeric project: filters."
418+
);
419+
}
420+
return notes;
421+
}
422+
423+
/**
424+
* One warning after every successful rewrite. Reasons on the first
425+
* line; the query that will actually be sent on the second. Skip if
426+
* nothing changed.
427+
*/
428+
function warnRunningQuery(notes: string[], result: string): void {
429+
if (notes.length === 0) {
430+
return;
431+
}
432+
log.warn(`${notes.join(" ")}\nRunning query: "${result}"`);
398433
}
399434

400435
/**
401436
* Handle the OR rewrite path — extracted to keep `sanitizeQuery` under
402437
* the cognitive complexity limit.
403438
*/
404-
function handleOr(nodes: SearchNode[], hasAnd: boolean): string {
439+
function handleOr(
440+
nodes: SearchNode[],
441+
hasAnd: boolean,
442+
notes: string[]
443+
): string {
405444
const rewritten = tryRewriteOr(nodes);
406445
if (rewritten) {
407-
const result = serializeNodes(rewritten);
408-
const notes: string[] = [];
409446
notes.push("Rewrote OR using in-list syntax: key:[val1,val2].");
410447
if (hasAnd) {
411448
notes.push("Also removed explicit AND (implicit in Sentry search).");
412449
}
413-
notes.push(`Running query: "${result}"`);
414-
log.warn(notes.join(" "));
415-
return result;
450+
return serializeNodes(rewritten);
416451
}
417452

418453
throw new ValidationError(
@@ -511,6 +546,15 @@ const BALANCED_BRACKET_RE = /\[[^\]]*\]/g;
511546
/** Trailing comma before closing bracket: `,]` */
512547
const TRAILING_LIST_COMMA_RE = /,\s*\]$/;
513548

549+
/**
550+
* `project:<digits>` as its own filter — not `bolt.project`, not `project_id`.
551+
* Issue search treats `project` as a slug and `project_id` as a numeric id.
552+
*/
553+
const PROJECT_NUMERIC_RE = /(^|\s)(!?)project:(\d+)(?=\s|$)/gi;
554+
555+
/** `project:[123,456]` — every list value must be digits. */
556+
const PROJECT_NUMERIC_LIST_RE = /(^|\s)(!?)project:\[(\d+(?:\s*,\s*\d+)*)\]/gi;
557+
514558
/**
515559
* Pattern that splits a query into alternating unquoted / quoted segments.
516560
*
@@ -519,6 +563,23 @@ const TRAILING_LIST_COMMA_RE = /,\s*\]$/;
519563
*/
520564
const QUOTED_SEGMENT_RE = /"(?:[^"\\]|\\.)*"/g;
521565

566+
/**
567+
* Rewrite `project:<digits>` / `project:[digits,…]` to `project_id`.
568+
*
569+
* `project` is the slug; a numeric value is almost always a pasted Sentry
570+
* project id (CLI-FA). Namespaced keys (`bolt.project:…`) and slugs are
571+
* untouched. Quoted regions are preserved via {@link transformUnquoted}.
572+
*/
573+
function rewriteNumericProjectFilters(query: string): string {
574+
return transformUnquoted(query, (segment) => {
575+
PROJECT_NUMERIC_RE.lastIndex = 0;
576+
PROJECT_NUMERIC_LIST_RE.lastIndex = 0;
577+
return segment
578+
.replace(PROJECT_NUMERIC_RE, "$1$2project_id:$3")
579+
.replace(PROJECT_NUMERIC_LIST_RE, "$1$2project_id:[$3]");
580+
});
581+
}
582+
522583
/**
523584
* Normalize a search query by applying a pipeline of text repairs.
524585
*

‎packages/cli/test/lib/search-query.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,69 @@ describe("sanitizeQuery: AND", () => {
9898
});
9999
});
100100

101+
// ---------------------------------------------------------------------------
102+
// project:<digits> → project_id
103+
// ---------------------------------------------------------------------------
104+
105+
describe("sanitizeQuery: numeric project:", () => {
106+
test("rewrites a numeric project: filter to project_id", () => {
107+
expect(
108+
sanitizeQuery("project:4511730126487632 environment:vercel-production")
109+
).toBe("project_id:4511730126487632 environment:vercel-production");
110+
});
111+
112+
test("rewrites a numeric project: in-list", () => {
113+
expect(
114+
sanitizeQuery("is:unresolved project:[4505521413357568,6442225]")
115+
).toBe("is:unresolved project_id:[4505521413357568,6442225]");
116+
});
117+
118+
test("rewrites a negated numeric project: filter", () => {
119+
expect(sanitizeQuery("!project:1423462 lastSeen:-1h")).toBe(
120+
"!project_id:1423462 lastSeen:-1h"
121+
);
122+
});
123+
124+
test("leaves project slugs alone", () => {
125+
expect(sanitizeQuery("project:frontend is:unresolved")).toBe(
126+
"project:frontend is:unresolved"
127+
);
128+
});
129+
130+
test("leaves project_id numeric filters alone", () => {
131+
expect(sanitizeQuery("project_id:4511730126487632")).toBe(
132+
"project_id:4511730126487632"
133+
);
134+
});
135+
136+
test("leaves namespaced project keys alone", () => {
137+
expect(sanitizeQuery("bolt.project_id:70054175")).toBe(
138+
"bolt.project_id:70054175"
139+
);
140+
expect(sanitizeQuery("bolt.project:70054175")).toBe(
141+
"bolt.project:70054175"
142+
);
143+
});
144+
145+
test("does not rewrite a numeric id inside a quoted value", () => {
146+
expect(sanitizeQuery('message:"project:4511730126487632"')).toBe(
147+
'message:"project:4511730126487632"'
148+
);
149+
});
150+
151+
test("does not rewrite mixed slug/numeric in-lists", () => {
152+
expect(sanitizeQuery("project:[frontend,6442225]")).toBe(
153+
"project:[frontend,6442225]"
154+
);
155+
});
156+
157+
test("rewrites numeric project: then OR in one step", () => {
158+
expect(sanitizeQuery("project:123 OR project:456")).toBe(
159+
"project_id:[123,456]"
160+
);
161+
});
162+
});
163+
101164
// ---------------------------------------------------------------------------
102165
// OR → in-list rewrites (successful)
103166
// ---------------------------------------------------------------------------
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Warning copy for stacked search-query rewrites.
3+
*
4+
* `sanitizeQuery` must emit one warn: reasons, then a newline, then
5+
* `Running query:` quoting the string that is actually sent.
6+
*/
7+
8+
import { beforeEach, describe, expect, test, vi } from "vitest";
9+
10+
const { fakeLog } = vi.hoisted(() => {
11+
const log = {
12+
warn: vi.fn(),
13+
debug: vi.fn(),
14+
info: vi.fn(),
15+
error: vi.fn(),
16+
withTag() {
17+
return log;
18+
},
19+
};
20+
return { fakeLog: log };
21+
});
22+
23+
vi.mock("../../src/lib/logger.js", () => ({
24+
logger: fakeLog,
25+
}));
26+
27+
const { sanitizeQuery } = await import("../../src/lib/search-query.js");
28+
29+
function runningQueries(): string[] {
30+
return fakeLog.warn.mock.calls
31+
.map((call) => String(call[0]))
32+
.filter((msg) => msg.includes("Running query:"));
33+
}
34+
35+
describe("sanitizeQuery: rewrite warnings", () => {
36+
beforeEach(() => {
37+
fakeLog.warn.mockClear();
38+
});
39+
40+
test("numeric project: plus OR warns once with the final in-list", () => {
41+
expect(sanitizeQuery("project:123 OR project:456")).toBe(
42+
"project_id:[123,456]"
43+
);
44+
const warns = runningQueries();
45+
expect(warns).toHaveLength(1);
46+
expect(warns[0].split("\n")).toEqual([
47+
"`project` is the slug; numeric ids use project_id. Rewrote numeric project: filters. Rewrote OR using in-list syntax: key:[val1,val2].",
48+
'Running query: "project_id:[123,456]"',
49+
]);
50+
expect(warns[0]).not.toContain("project_id:123 OR project_id:456");
51+
});
52+
53+
test("numeric project: plus AND warns once with the stripped query", () => {
54+
expect(sanitizeQuery("project:123 AND is:unresolved")).toBe(
55+
"project_id:123 is:unresolved"
56+
);
57+
const warns = runningQueries();
58+
expect(warns).toHaveLength(1);
59+
expect(warns[0].split("\n")).toEqual([
60+
"`project` is the slug; numeric ids use project_id. Rewrote numeric project: filters. Sentry search implicitly ANDs terms — removed explicit AND operator.",
61+
'Running query: "project_id:123 is:unresolved"',
62+
]);
63+
});
64+
65+
test("OR-only still warns once with the in-list", () => {
66+
expect(sanitizeQuery("level:error OR level:warning")).toBe(
67+
"level:[error,warning]"
68+
);
69+
const warns = runningQueries();
70+
expect(warns).toHaveLength(1);
71+
expect(warns[0].split("\n")).toEqual([
72+
"Rewrote OR using in-list syntax: key:[val1,val2].",
73+
'Running query: "level:[error,warning]"',
74+
]);
75+
});
76+
77+
test("does not warn Running query: when OR rewrite fails", () => {
78+
expect(() => sanitizeQuery("level:error OR assigned:me")).toThrow();
79+
expect(runningQueries()).toHaveLength(0);
80+
});
81+
82+
test("does not warn Running query: when numeric rewrite is followed by a failed OR", () => {
83+
expect(() => sanitizeQuery("project:123 OR assigned:me")).toThrow();
84+
expect(runningQueries()).toHaveLength(0);
85+
});
86+
87+
test("still warns about the numeric rewrite when the query does not parse", () => {
88+
// Unmatched paren → PEG parse fails → passthrough. The rewrite already
89+
// happened, and the API 400 will quote project_id, so the user must
90+
// be told.
91+
expect(sanitizeQuery("project:123 ((( broken")).toBe(
92+
"project_id:123 ((( broken"
93+
);
94+
const warns = runningQueries();
95+
expect(warns).toHaveLength(1);
96+
expect(warns[0].split("\n")).toEqual([
97+
"`project` is the slug; numeric ids use project_id. Rewrote numeric project: filters.",
98+
'Running query: "project_id:123 ((( broken"',
99+
]);
100+
});
101+
102+
test("does not warn on unparseable passthrough with no rewrites", () => {
103+
expect(sanitizeQuery("((( broken")).toBe("((( broken");
104+
expect(runningQueries()).toHaveLength(0);
105+
});
106+
});

0 commit comments

Comments
 (0)