Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 83 additions & 22 deletions packages/cli/src/lib/search-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<digits>`**: `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
Expand Down Expand Up @@ -346,21 +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);
const notes = preParseRewriteNotes(query, normalized, withNumericProject);

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);
} catch {
nodes = parse(withNumericProject);
} catch (err) {
// PEG parse still failed after normalization — pass through to the
// API which returns a proper 400 with actionable details.
return normalized;
}

if (normalized !== query) {
log.warn(
`Auto-repaired search query syntax. Running query: "${normalized}"`
);
// API which returns a proper 400 with actionable details. The text
// rewrites already ran, so say so: the 400 will quote them.
log.debug("Search query did not parse; sending as-is", err);
warnRunningQuery(notes, withNumericProject);
return withNumericProject;
}

// Check for OR inside paren groups first — these are opaque and can't
Expand All @@ -382,37 +384,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(
Comment thread
betegon marked this conversation as resolved.
Expand Down Expand Up @@ -511,6 +546,15 @@ const BALANCED_BRACKET_RE = /\[[^\]]*\]/g;
/** Trailing comma before closing bracket: `,]` */
const TRAILING_LIST_COMMA_RE = /,\s*\]$/;

/**
* `project:<digits>` 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.
*
Expand All @@ -519,6 +563,23 @@ const TRAILING_LIST_COMMA_RE = /,\s*\]$/;
*/
const QUOTED_SEGMENT_RE = /"(?:[^"\\]|\\.)*"/g;

/**
* Rewrite `project:<digits>` / `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.
Comment thread
betegon marked this conversation as resolved.
*
Expand Down
63 changes: 63 additions & 0 deletions packages/cli/test/lib/search-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,69 @@ describe("sanitizeQuery: AND", () => {
});
});

// ---------------------------------------------------------------------------
// project:<digits> → 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)
// ---------------------------------------------------------------------------
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/test/lib/search-query.warn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* 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);
});

test("still warns about the numeric rewrite when the query does not parse", () => {
// Unmatched paren → PEG parse fails → passthrough. The rewrite already
// happened, and the API 400 will quote project_id, so the user must
// be told.
expect(sanitizeQuery("project:123 ((( broken")).toBe(
"project_id:123 ((( broken"
);
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.",
'Running query: "project_id:123 ((( broken"',
]);
});

test("does not warn on unparseable passthrough with no rewrites", () => {
expect(sanitizeQuery("((( broken")).toBe("((( broken");
expect(runningQueries()).toHaveLength(0);
});
});
Loading