From c069d9db03c889cf6487c7c25e3056e8f1c4e4e9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:08:40 +0000 Subject: [PATCH 1/4] fix(mcp): Rewrite AND/OR in search_issues queries before calling Sentry The issues endpoint rejects AND/OR ("Boolean statements containing OR or AND are not supported"), but the search_issues repair prompt listed them as valid syntax and told the agent to keep explicit tokens. Without an agent provider, queries went to the API unchecked. Move the CLI's query sanitizer into toolkit-core as rewriteSearchQuery and run it in search_issues after the optional agent repair. It strips AND, turns same-key OR into in-lists, and throws a UserInputError with alternatives for anything it can't rewrite. The CLI keeps a thin wrapper for its stderr warning and ValidationError. A new issueSearch option covers the most common failure, is:X OR is:Y. is: rejects in-lists, but the status and substatus filters it maps to accept them, so it rewrites to status:[...] or substatus:[...]. Substatus lists must share one parent status, because the endpoint adds a status filter for the first substatus only. It also unwraps parentheses that reduce to plain terms, since issue search silently matches nothing for grouped queries. Fix the agent prompt and tool description to match. Co-Authored-By: Burak Yigit Kaya <126780+BYK@users.noreply.github.com> Co-Authored-By: Andrew Liu --- packages/cli/AGENTS.md | 2 +- packages/cli/src/lib/search-query.ts | 996 +------------- packages/mcp-core/src/skillDefinitions.json | 6 +- packages/mcp-core/src/toolDefinitions.json | 2 +- .../src/tools/catalog/search-issues.test.ts | 70 + .../src/tools/catalog/search-issues.ts | 34 + .../src/tools/support/search-issues/config.ts | 10 +- packages/toolkit-core/README.md | 7 + packages/toolkit-core/package.json | 4 + .../toolkit-core/src/search-query.test.ts | 140 ++ packages/toolkit-core/src/search-query.ts | 1162 +++++++++++++++++ 11 files changed, 1461 insertions(+), 972 deletions(-) create mode 100644 packages/toolkit-core/src/search-query.test.ts create mode 100644 packages/toolkit-core/src/search-query.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index ac6537f4d..193c3e50b 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -718,7 +718,7 @@ Before creating a new `src/lib/*.ts` utility file, check whether existing shared | Table/markdown output | `src/lib/formatters/` directory | | Pagination | `src/lib/db/pagination.ts`, `src/lib/list-command.ts` | | Error classes | `src/lib/errors.ts` (never create ad-hoc error types) | -| Search query building | `src/lib/search-query.ts`, `src/lib/arg-parsing.ts` | +| Search query building | `src/lib/search-query.ts` (wraps `@sentry/toolkit-core/search-query`), `src/lib/arg-parsing.ts` | If an existing module covers ≥80% of what you need, extend it with new exported functions rather than creating a new file. New files are appropriate when the domain is genuinely new (e.g., `replay-search.ts` for replay-specific field resolution). diff --git a/packages/cli/src/lib/search-query.ts b/packages/cli/src/lib/search-query.ts index f99562737..c07aa9db4 100644 --- a/packages/cli/src/lib/search-query.ts +++ b/packages/cli/src/lib/search-query.ts @@ -1,569 +1,33 @@ /** - * Sentry search query sanitization. + * Sentry search query sanitization for `--query` flags. * - * Repairs common `--query` mistakes across commands. Endpoints that support - * boolean syntax opt in to preserving it; other endpoints use these rewrites: - * - * - **AND**: Sentry search implicitly ANDs space-separated terms, so - * explicit `AND` is redundant. Stripped with a warning. - * - **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. - * - * Boolean rewriting uses the Sentry frontend's search grammar through - * `@sentry/toolkit-core/search-syntax`. Its token tree is flattened into - * the {@link SearchNode} list below, which classifies every term - * structurally — comparison operators, in-list values, paren groups, - * etc. — so the rewriting logic doesn't need regex-based reject-lists. - * - * @see {@link https://github.com/getsentry/sentry/blob/master/static/app/components/searchSyntax/grammar.pegjs | Canonical Sentry PEG grammar} + * The rewrite rules live in `@sentry/toolkit-core/search-query`, shared with + * the MCP server. This wrapper adds the CLI's reporting: one stderr warning + * per rewritten query, and {@link ValidationError} when a query can't be + * rewritten. */ import { - type FilterToken, - FilterType, - type ParseResultToken, - parseSearch, - TermOperator, - Token, -} from "@sentry/toolkit-core/search-syntax"; + rewriteSearchQuery, + type SearchQueryOptions, + SearchQueryError, +} from "@sentry/toolkit-core/search-query"; import { ValidationError } from "./errors.js"; import { logger } from "./logger.js"; -const log = logger.withTag("search-query"); - -/** - * Keys that do not support in-list syntax in Sentry search. - * - * - `is`: Explicitly documented as unsupported by Sentry docs - * - `has`: Uses `search_value` not `text_in_list` in the PEG grammar - */ -const INVALID_INLIST_KEYS = new Set(["is", "has"]); - -/** - * Filter keys whose values scope *which* issues match but which the Sentry - * issues-list endpoint does NOT apply to its per-group seen-stats - * (`count`, `userCount`, `firstSeen`, `lastSeen`). - * - * A query filtering on one of these returns the groups matching the filter, - * but the displayed counts remain period-wide totals across every value of the - * key — e.g. `release:"X"` restricts the result set yet the counts still cover - * all releases. `issue list` requests the endpoint's query-scoped `filtered` - * counts instead when one of these keys is present. See getsentry/toolkit#1583. - */ -const COUNT_UNSCOPED_FILTER_KEYS = new Set(["release"]); - -// --------------------------------------------------------------------------- -// Search nodes -// --------------------------------------------------------------------------- - -/** A text filter: key:value */ -type TextFilter = { - type: "text_filter"; - negated: boolean; - key: string; - value: string; -}; - -/** An in-list filter: key:[val1,val2] */ -type TextInFilter = { - type: "text_in_filter"; - negated: boolean; - key: string; - values: string[]; -}; - -/** A regex filter: key://pattern// */ -type RegexFilter = { - type: "regex_filter"; - negated: boolean; - key: string; - value: string; -}; - -/** A comparison filter: key:>value, key:<=value */ -type ComparisonFilter = { - type: "comparison_filter"; - negated: boolean; - key: string; - op: string; - value: string; -}; - -/** Free text (no key: prefix) */ -type FreeText = { - type: "free_text"; - value: string; - quoted: boolean; -}; - -/** A boolean operator (AND / OR) */ -type BooleanOp = { - type: "boolean_op"; - op: "AND" | "OR"; -}; - -/** A parenthesized group */ -type ParenGroup = { - type: "paren_group"; - inner: SearchNode[]; - raw: string; -}; - -/** - * A filter the rewriter must pass through untouched, such as an empty value - * or a list with an empty item. - */ -type OpaqueTerm = { - type: "opaque"; - raw: string; -}; - -/** One term of a search query. */ -export type SearchNode = - | TextFilter - | TextInFilter - | RegexFilter - | ComparisonFilter - | FreeText - | BooleanOp - | ParenGroup - | OpaqueTerm; - -const COMPARISON_PREFIX_RE = /^(>=|<=|!=|>|<|=)/; - -/** - * Filter types whose key shape also has an in-list rule in Sentry's grammar - * (`text_in_filter` / `numeric_in_filter`). Aggregate (`count():10`), - * array-includes (`foo[*]:a`), `has:`/`is:`, boolean, date, duration and - * size filters have no in-list form, so merging them would build a query - * the API rejects. - */ -const LISTABLE_FILTER_TYPES: ReadonlySet = new Set([ - FilterType.TEXT, - FilterType.NUMERIC, -]); -const FREE_TEXT_SEPARATOR_RE = /[ \t]+/; - -type Located = { - location: { start: { offset: number }; end: { offset: number } }; -}; - -/** The exact query text a token was parsed from. */ -function sourceOf(query: string, token: Located): string { - return query.slice(token.location.start.offset, token.location.end.offset); -} - -/** Flatten one filter token into a {@link SearchNode}. */ -function filterToNode(query: string, token: FilterToken): SearchNode { - const key = sourceOf(query, token.key); - // Everything after the `:` that follows the key, operator included. - const value = query.slice( - token.key.location.end.offset + 1, - token.location.end.offset, - ); - const { negated } = token; - - if (token.operator === TermOperator.MATCHES) { - return { type: "regex_filter", negated, key, value }; - } - - if ( - token.value.type === Token.VALUE_TEXT_LIST || - token.value.type === Token.VALUE_NUMBER_LIST - ) { - const values: string[] = []; - for (const item of token.value.items) { - if (!item.value || token.operator !== TermOperator.DEFAULT) { - return { type: "opaque", raw: sourceOf(query, token) }; - } - values.push(sourceOf(query, item.value)); - } - return { type: "text_in_filter", negated, key, values }; - } - - const comparison = COMPARISON_PREFIX_RE.exec(value)?.[1]; - if (comparison) { - return { - type: "comparison_filter", - negated, - key, - op: comparison, - value: value.slice(comparison.length), - }; - } - - if (!value || !LISTABLE_FILTER_TYPES.has(token.filter)) { - return { type: "opaque", raw: sourceOf(query, token) }; - } - - return { type: "text_filter", negated, key, value }; -} - -/** - * Flatten the shared parser's token tree into {@link SearchNode}s. Returns - * `null` for unbalanced parentheses, which the rewriter can't reason about. - */ -function toSearchNodes( - query: string, - tokens: ParseResultToken[], -): SearchNode[] | null { - const nodes: SearchNode[] = []; - for (const token of tokens) { - switch (token.type) { - case Token.SPACES: - break; - case Token.LOGIC_BOOLEAN: - nodes.push({ type: "boolean_op", op: token.value }); - break; - case Token.LOGIC_GROUP: { - const inner = toSearchNodes(query, token.inner); - if (!inner) { - return null; - } - nodes.push({ type: "paren_group", inner, raw: sourceOf(query, token) }); - break; - } - case Token.FREE_TEXT: { - const text = sourceOf(query, token).trim(); - if (token.quoted) { - nodes.push({ type: "free_text", value: text, quoted: true }); - break; - } - // The grammar folds consecutive words into one free-text token; the - // rewriter treats each word as its own term. - for (const word of text.split(FREE_TEXT_SEPARATOR_RE)) { - if (word) { - nodes.push({ type: "free_text", value: word, quoted: false }); - } - } - break; - } - case Token.FILTER: - nodes.push(filterToNode(query, token)); - break; - default: - return null; - } - } - return nodes; -} - -/** Parse a query into {@link SearchNode}s, or `null` if it can't be. */ -function parseSearchNodes(query: string): SearchNode[] | null { - // Regex filters are parsed as such on every dataset so the rewriter never - // merges a `key://pattern//` value into an in-list. - const tokens = parseSearch(query, { config: { allowRegex: true } }); - return tokens ? toSearchNodes(query, tokens) : null; -} - -// --------------------------------------------------------------------------- -// AST → string serialization -// --------------------------------------------------------------------------- - -/** Serialize a single AST node back to query string form. */ -function serializeNode(node: SearchNode): string { - switch (node.type) { - case "boolean_op": { - return node.op; - } - case "free_text": { - return node.value; - } - case "paren_group": { - return node.raw; - } - case "text_filter": { - const prefix = node.negated ? "!" : ""; - return `${prefix}${node.key}:${node.value}`; - } - case "text_in_filter": { - const prefix = node.negated ? "!" : ""; - return `${prefix}${node.key}:[${node.values.join(",")}]`; - } - case "regex_filter": { - const prefix = node.negated ? "!" : ""; - return `${prefix}${node.key}:${node.value}`; - } - case "comparison_filter": { - const prefix = node.negated ? "!" : ""; - return `${prefix}${node.key}:${node.op}${node.value}`; - } - case "opaque": { - return node.raw; - } - default: { - return ""; - } - } -} - -/** Serialize a list of AST nodes back to a query string. */ -function serializeNodes(nodes: SearchNode[]): string { - return nodes.map(serializeNode).join(" "); -} - -// --------------------------------------------------------------------------- -// OR → in-list rewriting -// --------------------------------------------------------------------------- - -/** - * Check whether an AST node can participate in an in-list merge. - * - * Only `text_filter` and `text_in_filter` nodes with valid keys (not - * `is:`/`has:`), no negation, and no wildcards are eligible. - * Comparison filters, free text, paren groups, and boolean ops cannot. - */ -function isMergeableFilter( - node: SearchNode, -): node is - | (SearchNode & { type: "text_filter" }) - | (SearchNode & { type: "text_in_filter" }) { - if (node.type === "text_filter") { - if (node.negated) { - return false; - } - if (INVALID_INLIST_KEYS.has(node.key.toLowerCase())) { - return false; - } - return !node.value.includes("*"); - } - if (node.type === "text_in_filter") { - if (node.negated) { - return false; - } - if (INVALID_INLIST_KEYS.has(node.key.toLowerCase())) { - return false; - } - // No wildcards in any existing in-list values - return !node.values.some((v) => v.includes("*")); - } - return false; -} - -/** Extract all values from a mergeable filter node. */ -function valuesFromNode( - node: SearchNode & { type: "text_filter" | "text_in_filter" }, -): string[] { - if (node.type === "text_in_filter") { - return node.values; - } - return [node.value]; -} - -/** - * Try to merge a group of AST nodes (OR-connected) into a single - * in-list filter node. Returns the merged node or `null`. - */ -function tryMergeGroup(group: SearchNode[]): SearchNode | null { - // All must be mergeable filters - for (const node of group) { - if (!isMergeableFilter(node)) { - return null; - } - } - - const filters = group as Array< - SearchNode & { type: "text_filter" | "text_in_filter" } - >; - const first = filters[0]; - if (!first) { - return null; - } - - // All must have the same key (case-insensitive) - const keyLower = first.key.toLowerCase(); - for (const f of filters) { - if (f.key.toLowerCase() !== keyLower) { - return null; - } - } - - // Collect and flatten all values - const allValues: string[] = []; - for (const f of filters) { - allValues.push(...valuesFromNode(f)); - } - - return { - type: "text_in_filter", - negated: false, - key: first.key, - values: allValues, - }; -} - -/** - * Walk the AST node list and attempt to rewrite all OR groups to - * in-list syntax. Returns the rewritten node list, or `null` if - * any OR group cannot be merged. - */ -/** - * Merge an OR group into a single node. Single-element groups pass through. - * Returns `null` if the group cannot be merged. - */ -function mergeOrGroup(group: SearchNode[]): SearchNode | null { - if (group.length === 1) { - return group[0] ?? null; - } - return tryMergeGroup(group); -} - -function tryRewriteOr(nodes: SearchNode[]): SearchNode[] | null { - const result: SearchNode[] = []; - let i = 0; - - while (i < nodes.length) { - const current = nodes[i] as SearchNode; - - // Skip stray OR tokens - if (isOrNode(nodes, i)) { - i += 1; - continue; - } - - // Check if next node is OR → collect and merge the chain - if (isOrNode(nodes, i + 1)) { - const [group, nextIndex] = collectOrChain(nodes, i); - const merged = mergeOrGroup(group); - if (!merged) { - return null; - } - result.push(merged); - i = nextIndex; - } else { - result.push(current); - i += 1; - } - } - - // All-OR input (e.g. "OR OR OR") produces an empty result — treat as unmergeable - if (result.length === 0) { - return null; - } - - return result; -} - -/** Check whether the node at index `j` is an OR operator. */ -function isOrNode(nodes: SearchNode[], j: number): boolean { - const node = nodes[j]; - return node?.type === "boolean_op" && node.op === "OR"; -} - -/** - * Collect an OR-chain starting at `start`. Returns the group of - * non-OR nodes and the index past the end of the chain. - */ -function collectOrChain( - nodes: SearchNode[], - start: number, -): [SearchNode[], number] { - const group = [nodes[start] as SearchNode]; - let j = start + 1; - - while (isOrNode(nodes, j)) { - j += 1; // skip OR - // Skip consecutive ORs - while (isOrNode(nodes, j)) { - j += 1; - } - if (j < nodes.length) { - group.push(nodes[j] as SearchNode); - j += 1; - } - } - - return [group, j]; -} - -// --------------------------------------------------------------------------- -// Boolean operator scanning (recursive into paren groups) -// --------------------------------------------------------------------------- - -/** - * Check whether any paren group (at any nesting depth) contains an OR - * operator. These are opaque — their `raw` text can't be rewritten — - * so any OR inside must be rejected even if top-level OR is rewritable. - */ -function hasOrInParenGroups(nodes: SearchNode[]): boolean { - for (const node of nodes) { - if (node.type === "paren_group" && hasOrInNodes(node.inner)) { - return true; - } - } - return false; -} - -/** Check whether any node (recursively) contains an OR operator. */ -function hasOrInNodes(nodes: SearchNode[]): boolean { - for (const node of nodes) { - if (node.type === "boolean_op" && node.op === "OR") { - return true; - } - if (node.type === "paren_group" && hasOrInNodes(node.inner)) { - return true; - } - } - return false; -} +export { + __testing, + SEARCH_SYNTAX_REFERENCE, + unscopedCountFilterKeys, +} from "@sentry/toolkit-core/search-query"; -/** - * Non-recursive scan — only checks top-level nodes, not paren group contents. - * Used to distinguish "OR at top level" from "OR only inside paren groups". - */ -function scanBooleanOpsFlat(nodes: SearchNode[]): { - hasOr: boolean; - hasAnd: boolean; -} { - let hasOr = false; - let hasAnd = false; - for (const node of nodes) { - if (node.type === "boolean_op") { - if (node.op === "OR") { - hasOr = true; - } else { - hasAnd = true; - } - } - } - return { hasOr, hasAnd }; -} - -/** - * Remove AND boolean_op nodes from a flat node list. - * Does NOT recurse into paren groups — those are opaque. - */ -function stripAndNodes(nodes: SearchNode[]): SearchNode[] { - return nodes.filter((n) => !(n.type === "boolean_op" && n.op === "AND")); -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -type QuerySyntaxOptions = { - /** Events queries support boolean operators; issue search still needs rewrites. */ - allowBoolean?: boolean; -}; +const log = logger.withTag("search-query"); /** * Sanitize a `--query` value before sending to the Sentry API. * - * Repairs common syntax mistakes, preserving boolean operators when the - * endpoint supports them. Otherwise parses a PEG grammar and rewrites operators: - * - * - **AND**: Stripped (implicit in Sentry search). Warns via stderr. - * - **OR**: Attempted rewrite to in-list syntax (`key:[val1,val2]`). - * Succeeds when all OR operands share the same valid qualifier key. - * Throws {@link ValidationError} when the rewrite is not possible. - * - * Paren groups, quoted values, and comparison operators are handled - * correctly by the grammar — no regex-based reject-lists needed. + * See {@link rewriteSearchQuery} for the rewrite rules. Warns once on stderr + * when the query changed, quoting the query that will actually run. * * Accepts `undefined` for convenience — commands can pass `flags.query` * directly without a conditional guard. @@ -575,434 +39,36 @@ type QuerySyntaxOptions = { */ export function sanitizeQuery( query: string, - options?: QuerySyntaxOptions, + options?: SearchQueryOptions, ): string; export function sanitizeQuery( query: undefined, - options?: QuerySyntaxOptions, + options?: SearchQueryOptions, ): undefined; export function sanitizeQuery( query: string | undefined, - options?: QuerySyntaxOptions, + options?: SearchQueryOptions, ): string | undefined; export function sanitizeQuery( query: string | undefined, - options: QuerySyntaxOptions = {}, + options: SearchQueryOptions = {}, ): string | undefined { if (!query) { return query; } - // --- Layer 1: Pre-parse text normalization --- - // Run cheap text transforms on every query BEFORE PEG parsing. - // 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); - - if (options.allowBoolean) { - warnRunningQuery(notes, withNumericProject); - return withNumericProject; - } - - const nodes = parseSearchNodes(withNumericProject); - if (!nodes) { - // PEG parse still failed after normalization — pass through to the - // 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"); - warnRunningQuery(notes, withNumericProject); - return 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, - // because the paren group's raw text would pass the OR through to the API. - if (hasOrInParenGroups(nodes)) { - throw new ValidationError( - "Could not rewrite OR inside parenthesized group.\n\n" + - "Sentry search does not support boolean operators. " + - "Remove the parentheses and use in-list syntax instead:\n" + - " (level:error OR level:warning) → level:[error,warning]\n\n" + - "Search syntax: https://docs.sentry.io/concepts/search/", - "query", - ); - } - - const { hasOr, hasAnd } = scanBooleanOpsFlat(nodes); - - if (hasOr) { - // Strip AND nodes before OR rewrite - const withoutAnd = hasAnd ? stripAndNodes(nodes) : nodes; - const result = handleOr(withoutAnd, hasAnd, notes); - warnRunningQuery(notes, result); - return result; - } - - if (hasAnd) { - const sanitized = serializeNodes(stripAndNodes(nodes)); - 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, - notes: string[], -): string { - const rewritten = tryRewriteOr(nodes); - if (rewritten) { - notes.push("Rewrote OR using in-list syntax: key:[val1,val2]."); - if (hasAnd) { - notes.push("Also removed explicit AND (implicit in Sentry search)."); - } - return serializeNodes(rewritten); - } - - throw new ValidationError( - "Could not rewrite OR into Sentry search syntax.\n\n" + - "OR can be auto-rewritten when all terms share the same qualifier key:\n" + - " level:error OR level:warning → level:[error,warning]\n\n" + - "Patterns that cannot be rewritten:\n" + - " - Free-text terms without a key (error OR timeout)\n" + - " - Different keys (level:error OR assigned:me)\n" + - " - is: or has: qualifiers (not supported with in-list)\n" + - " - Negated qualifiers (!key:val1 OR !key:val2)\n" + - " - Comparison values (age:>24h OR age:>7d)\n" + - " - Regex filters (use alternation instead: key://(a|b)//)\n" + - " - Parenthesized groups\n\n" + - "Alternatives:\n" + - ' - Write in-list syntax directly: --query "key:[val1,val2]"\n' + - " - Run separate queries for each term\n\n" + - "Search syntax: https://docs.sentry.io/concepts/search/", - "query", - ); -} - -/** - * Collect the count-unscoped filter keys (see {@link COUNT_UNSCOPED_FILTER_KEYS}) - * present in a query, recursing into parenthesized groups. - */ -function collectUnscopedCountKeys( - nodes: SearchNode[], - found: Set, -): void { - for (const node of nodes) { - if (node.type === "paren_group") { - collectUnscopedCountKeys(node.inner, found); - continue; - } - if ( - node.type === "text_filter" || - node.type === "text_in_filter" || - node.type === "comparison_filter" || - node.type === "regex_filter" - ) { - const key = node.key.toLowerCase(); - if (COUNT_UNSCOPED_FILTER_KEYS.has(key)) { - found.add(key); - } + let result: ReturnType; + try { + result = rewriteSearchQuery(query, options); + } catch (error) { + if (error instanceof SearchQueryError) { + throw new ValidationError(error.message, "query"); } + throw error; } -} -/** - * Return the filter keys in `query` whose values restrict which issues match - * but are NOT reflected in the issues-list endpoint's seen-stats - * (`count`, `userCount`, `firstSeen`, `lastSeen`). See getsentry/cli#1518. - * - * Used by `issue list` to decide when to request query-scoped counts. - * Returns an empty array when the query is absent, unparseable, or contains - * no such keys. - * - * @param query - Raw `--query` value, or `undefined` - * @returns Lower-cased matching keys (e.g. `["release"]`), deduplicated - */ -export function unscopedCountFilterKeys(query: string | undefined): string[] { - if (!query) { - return []; - } - const nodes = parseSearchNodes(normalizeQuery(query)); - if (!nodes) { - // Unparseable query has no keys we can detect; the API will reject it with - // a proper 400. Nothing actionable here — just skip the count-scope check. - log.debug("Skipping count-scope check for unparseable query"); - return []; + if (result.notes.length > 0) { + log.warn(`${result.notes.join(" ")}\nRunning query: "${result.query}"`); } - const found = new Set(); - collectUnscopedCountKeys(nodes, found); - return [...found]; + return result.query; } - -// --------------------------------------------------------------------------- -// Search syntax reference -// --------------------------------------------------------------------------- - -/** - * Compact search syntax reference for JSON output. - * - * Gives agents and power users a machine-readable summary of Sentry's - * search syntax without needing to consult external docs. Derived from the - * PEG grammar at: - * https://github.com/getsentry/sentry/blob/master/static/app/components/searchSyntax/grammar.pegjs - * - * Injected into `--json` envelopes when the result set is empty — that's - * when users/agents most likely need query help (bad query, wrong syntax). - */ -export const SEARCH_SYNTAX_REFERENCE = { - _type: "sentry_search_syntax", - docs: "https://docs.sentry.io/concepts/search/", - grammar: - "https://github.com/getsentry/sentry/blob/master/static/app/components/searchSyntax/grammar.pegjs", - behavior: "Terms are space-separated and implicitly ANDed.", - operators: { - and: "NOT supported — implicit (space-separated terms are all required)", - or: "NOT supported — use key:[val1,val2] in-list syntax instead", - not: "!key:value (prefix with !)", - comparison: [">=", "<=", ">", "<", "=", "!="], - wildcard: "* in values (e.g., message:*timeout*)", - inList: "key:[val1,val2] — matches any value in the list", - regex: - "key://pattern// (logs only, string attributes) — RE2, unanchored, case-sensitive ((?i) to ignore case), max 64 chars, never quoted; ends at the first // followed by whitespace, ) or end of query; negate with !key://pattern//", - }, - filterTypes: [ - "text (key:value)", - "text_in (key:[val1,val2])", - "numeric (key:>100, key:<=50)", - "boolean (key:true, key:false)", - "date (key:>2024-01-01)", - "relative_date (key:-24h, key:+7d)", - "duration (key:>1s, key:<500ms)", - "has (has:key — not null check)", - "is (is:unresolved, is:resolved, is:ignored)", - ], - commonFilters: [ - "is:unresolved", - "is:resolved", - "is:ignored", - "assigned:me", - "assigned:[me,none]", - "has:user", - "level:error", - "level:warning", - "!browser:Chrome", - "firstSeen:-24h", - "lastSeen:-1h", - "age:-7d", - "times_seen:>100", - ], -}; - -// --------------------------------------------------------------------------- -// Query normalization pipeline -// --------------------------------------------------------------------------- - -/** - * In-list filter with wrong closing delimiter `)`. - * Matches `key:[a,b,)` — captures the inner values. Does NOT match `[a,b,]` - * (handled separately by {@link stripTrailingListCommas} via balanced brackets). - */ -const MALFORMED_IN_LIST_RE = /\[([^[\]]*),\s*\)(?=\s|$)/g; - -/** Trailing comma at end of captured group content */ -const TRAILING_COMMA_RE = /,\s*$/; - -/** Balanced `[...]` block — used to skip well-formed in-list filters */ -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 / preserved segments. - * - * Matches double-quoted strings (including escaped quotes inside them) and - * `key://pattern//` regex values, which end at the first `//` followed by - * whitespace, `)`, or end of query. Between matches is unquoted text that - * can be safely normalized. - */ -const REGEX_FILTER_KEY_SOURCE = String.raw`(?:(?:tags|flags)\[[\w.:-]+(?:\[\*\])?(?: *, *(?:string|number|boolean|array))?\]|"[\w.:-]+"|[\w.[\]-]+)(?:\[\*\])?`; -const PRESERVED_SEGMENT_RE = new RegExp( - String.raw`"(?:[^"\\]|\\.)*"|(?<=(?:^|[\s()"\]])!?${REGEX_FILTER_KEY_SOURCE}:)\/\/(?:(?!\/\/(?:[\t\n )]|$))[^\n])+\/\/(?=[\t\n )]|$)`, - "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. - * - * Runs on every query BEFORE PEG parsing. Each pass is a small, focused - * transform that fixes a common agent/user mistake. The pipeline is ordered - * from most common to least common pattern. - * - * Quoted regions (`"..."`) and regex values (`key://pattern//`) are - * preserved verbatim — only the remaining text is normalized. This prevents - * `message:"error [500,] found"` and `message://[a-z,]+//` from being - * corrupted. - * - * Returns the original query unchanged if no repairs were applicable. - */ -function normalizeQuery(query: string): string { - return transformUnquoted(query, (segment) => { - let q = segment; - - // 1. Fix mismatched closing delimiters: `[a,b,)` → `[a,b]` - // The `)` is a common typo/autocomplete artifact. - q = fixMismatchedBrackets(q); - - // 2. Strip trailing commas in in-list: `[a,b,]` → `[a,b]` - q = stripTrailingListCommas(q); - - // Future passes can be added here (e.g., date normalization) - - return q; - }); -} - -/** - * Apply a transform function only to the unquoted segments of a query. - * - * Splits the query at double-quoted and regex-value boundaries, applies `fn` - * to each unquoted segment, and re-assembles with the preserved segments - * untouched. - */ -function transformUnquoted( - query: string, - fn: (unquoted: string) => string, -): string { - if (!(query.includes('"') || query.includes("//"))) { - return fn(query); - } - - const parts: string[] = []; - let lastIndex = 0; - - // Reset the regex state for each call (global regex) - PRESERVED_SEGMENT_RE.lastIndex = 0; - let match = PRESERVED_SEGMENT_RE.exec(query); - - while (match !== null) { - if (match.index > lastIndex) { - parts.push(fn(query.slice(lastIndex, match.index))); - } - parts.push(match[0]); - lastIndex = match.index + match[0].length; - match = PRESERVED_SEGMENT_RE.exec(query); - } - - if (lastIndex < query.length) { - parts.push(fn(query.slice(lastIndex))); - } - - return parts.join(""); -} - -/** - * Fix mismatched closing delimiters in in-list filters. - * - * `key:[a,b,)` → `key:[a,b]` — the `)` after `[` is clearly meant to be `]`. - * Only replaces `)` that follows a `[...` opener without an intervening `]`. - */ -function fixMismatchedBrackets(query: string): string { - return query.replace( - MALFORMED_IN_LIST_RE, - (_match, inner: string) => `[${inner.replace(TRAILING_COMMA_RE, "")}]`, - ); -} - -/** - * Strip trailing commas inside in-list filters. - * - * `key:[a,b,]` → `key:[a,b]` — valid PEG syntax but some APIs reject it. - * Only operates on balanced `[...]` blocks to avoid cross-filter corruption. - */ -function stripTrailingListCommas(query: string): string { - return query.replace(BALANCED_BRACKET_RE, (match) => - match.replace(TRAILING_LIST_COMMA_RE, "]"), - ); -} - -// --------------------------------------------------------------------------- -// Test exports -// --------------------------------------------------------------------------- - -/** - * @internal Exported for testing only. Not part of the public API. - */ -export const __testing = { - parseSearchNodes, - isMergeableFilter, - tryMergeGroup, - tryRewriteOr, - serializeNode, - serializeNodes, - normalizeQuery, - transformUnquoted, -}; diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 8a9bda721..1f3a1d245 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -209,7 +209,7 @@ }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nNo AND/OR or parentheses: spaces mean AND. For OR on one field use field:[a,b]; for several statuses use status:[resolved,ignored] or substatus:[regressed,escalating].\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", "requiredScopes": ["event:read"] }, { @@ -304,7 +304,7 @@ }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nNo AND/OR or parentheses: spaces mean AND. For OR on one field use field:[a,b]; for several statuses use status:[resolved,ignored] or substatus:[regressed,escalating].\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", "requiredScopes": ["event:read"] }, { @@ -470,7 +470,7 @@ }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nNo AND/OR or parentheses: spaces mean AND. For OR on one field use field:[a,b]; for several statuses use status:[resolved,ignored] or substatus:[regressed,escalating].\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", "requiredScopes": ["event:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index c5113dba6..50d8ec298 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -7019,7 +7019,7 @@ }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nNo AND/OR or parentheses: spaces mean AND. For OR on one field use field:[a,b]; for several statuses use status:[resolved,ignored] or substatus:[regressed,escalating].\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", "inputSchema": { "type": "object", "properties": { diff --git a/packages/mcp-core/src/tools/catalog/search-issues.test.ts b/packages/mcp-core/src/tools/catalog/search-issues.test.ts index 70f03852a..73c740fd0 100644 --- a/packages/mcp-core/src/tools/catalog/search-issues.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-issues.test.ts @@ -797,6 +797,76 @@ describe("search_issues", () => { expect(result).toContain("unresolved"); }); + it("rewrites unsupported OR and parentheses before calling the API (no agent)", async () => { + process.env.OPENAI_API_KEY = ""; + process.env.ANTHROPIC_API_KEY = ""; + process.env.OPENROUTER_API_KEY = ""; + + let sentQuery: string | null = null; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/*/issues/", + ({ request }) => { + sentQuery = new URL(request.url).searchParams.get("query"); + return HttpResponse.json([]); + }, + ), + ); + + const result = await searchIssues.handler( + { + organizationSlug: "test-org", + query: "is:unresolved (is:regressed OR is:escalating)", + sort: "date", + projectSlugOrId: null, + regionUrl: null, + limit: 10, + period: "30d", + includeExplanation: false, + }, + mockContext, + ); + + expect(mockGenerateText).not.toHaveBeenCalled(); + expect(sentQuery).toBe("is:unresolved substatus:[regressed,escalating]"); + expect(result).toContain( + "Ran `is:unresolved substatus:[regressed,escalating]`.", + ); + }); + + it("rejects agent output that issue search can't express", async () => { + mockGenerateText.mockResolvedValue( + mockAIResponse("level:error OR assigned:me", "date"), + ); + + const listIssues = vi.fn(() => HttpResponse.json([])); + mswServer.use( + http.get("https://sentry.io/api/0/organizations/*/issues/", listIssues), + ); + + await expect( + searchIssues.handler( + { + organizationSlug: "test-org", + query: "errors or anything assigned to me", + sort: "date", + projectSlugOrId: null, + regionUrl: null, + limit: 10, + period: "30d", + includeExplanation: false, + }, + mockContext, + ), + ).rejects.toMatchObject({ + name: "UserInputError", + message: expect.stringContaining( + "Sentry query: level:error OR assigned:me", + ), + }); + expect(listIssues).not.toHaveBeenCalled(); + }); + it("should validate project slug format", async () => { await expect( searchIssues.handler( diff --git a/packages/mcp-core/src/tools/catalog/search-issues.ts b/packages/mcp-core/src/tools/catalog/search-issues.ts index e89249ae2..93e8acc5f 100644 --- a/packages/mcp-core/src/tools/catalog/search-issues.ts +++ b/packages/mcp-core/src/tools/catalog/search-issues.ts @@ -1,6 +1,12 @@ import { getActiveSpan } from "@sentry/core"; +import { + type RewrittenSearchQuery, + rewriteSearchQuery, + SearchQueryError, +} from "@sentry/toolkit-core/search-query"; import { z } from "zod"; import { SEARCH_ISSUES_PERIOD_VALUES } from "../../constants"; +import { UserInputError } from "../../errors"; import { hasAgentProvider } from "../../internal/agents/provider-factory"; import { withProviderFallback } from "../../internal/agents/provider-fallback"; import { apiServiceFromContext } from "../../internal/tool-helpers/api"; @@ -17,6 +23,25 @@ import { const ProjectSlugOrIdSchema = z.string().trim().superRefine(validateSlugOrId); +/** + * Rewrite a query into syntax the issues endpoint accepts. + * + * The endpoint rejects AND/OR and has no grouping. Runs after the optional + * agent repair, so it also covers sessions without an agent provider. + */ +function rewriteIssueQuery(query: string): RewrittenSearchQuery { + try { + return rewriteSearchQuery(query, { issueSearch: true }); + } catch (error) { + if (error instanceof SearchQueryError) { + throw new UserInputError(`${error.message}\n\nSentry query: ${query}`, { + cause: error, + }); + } + throw error; + } +} + function buildIssueSearchRepairPrompt(params: { query: string; sort: "date" | "freq" | "new" | "user" | "recommended"; @@ -58,6 +83,8 @@ export default defineTool({ "- environment:production", "- userCount:>100", "", + "No AND/OR or parentheses: spaces mean AND. For OR on one field use field:[a,b]; for several statuses use status:[resolved,ignored] or substatus:[regressed,escalating].", + "", "DO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors", "DO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces", "DO NOT USE FOR details about a specific issue → use get_sentry_resource", @@ -177,6 +204,9 @@ export default defineTool({ sort = params.sort; } + const rewritten = rewriteIssueQuery(query); + query = rewritten.query; + const issues = await apiService.listIssues({ organizationSlug: params.organizationSlug, projectId, @@ -240,6 +270,10 @@ export default defineTool({ }); } + if (rewritten.notes.length > 0) { + output += `\n\n**Query rewritten:** ${rewritten.notes.join(" ")} Ran \`${query}\`.`; + } + return output; }, }); diff --git a/packages/mcp-core/src/tools/support/search-issues/config.ts b/packages/mcp-core/src/tools/support/search-issues/config.ts index 2427f89a2..4dfef46a4 100644 --- a/packages/mcp-core/src/tools/support/search-issues/config.ts +++ b/packages/mcp-core/src/tools/support/search-issues/config.ts @@ -8,7 +8,7 @@ PRIMARY INVARIANT: Preserve every valid explicit Sentry search token exactly as written. DEFINITIONS: -- Explicit token: field:value, field:>value, field:value, field:, <, >=, <= -- Boolean operators: AND, OR, NOT (or !) +- Space-separated terms are ANDed. Issue search rejects AND and OR, and has no parentheses. +- Negation: prefix a filter with !, such as !is:resolved. NOT is not an operator; it is searched as text. +- OR on one field: use an in-list, such as level:[error,warning] +- is: does not accept in-lists. For several statuses use status:[resolved,ignored]. For several substatuses of the same status use substatus:[regressed,escalating]. - Field values with spaces need quotes: environment:"dev server" BUILT-IN FIELDS: - is: Issue status and inbox/substatus filters Values: unresolved, resolved, ignored, archived, escalating, new, ongoing, regressed, assigned, unassigned, for_review, linked, unlinked Use is:for_review for issues in the review queue. +- status: Issue status, for in-lists only (unresolved, resolved, ignored) +- substatus: Issue substatus, for in-lists only. Unresolved: new, ongoing, regressed, escalating. Ignored: archived_until_escalating, archived_until_condition_met, archived_forever. Never mix the two groups in one list. - level: Severity level (error, warning, info, debug, fatal) IMPORTANT: Almost NEVER use this field. Terms like "critical", "important", "severe" refer to IMPACT not level. Only use if user explicitly says "error level", "warning level", etc. diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index fdb1d9ab9..1919c322e 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -30,6 +30,13 @@ returns the frontend's token tree, minus validation: `TokenConverter` never marks tokens invalid or fills `parsed` values. A product that needs those subclasses `TokenConverter` and passes it as `converter`. +`search-query` uses that parser to rewrite queries the API would reject: +explicit `AND` is dropped, and `OR` on one key becomes an in-list. With +`issueSearch: true` it also turns `is:` OR groups into `status:[…]` or +`substatus:[…]` and unwraps parentheses, which the issues endpoint doesn't +support. It returns the rewrite notes and throws `SearchQueryError` for +anything it can't rewrite; each product decides how to report them. + The parser is compiled ahead of time into `src/generated/` and committed, since this package has no build step. Its types live in `grammar/search-syntax.d.ts`. After editing either file, run diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index 8e726ca52..72ae157d4 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -47,6 +47,10 @@ "types": "./src/resource-identity.ts", "default": "./src/resource-identity.ts" }, + "./search-query": { + "types": "./src/search-query.ts", + "default": "./src/search-query.ts" + }, "./search-syntax": { "types": "./src/search-syntax.ts", "default": "./src/search-syntax.ts" diff --git a/packages/toolkit-core/src/search-query.test.ts b/packages/toolkit-core/src/search-query.test.ts new file mode 100644 index 000000000..af2f3486f --- /dev/null +++ b/packages/toolkit-core/src/search-query.test.ts @@ -0,0 +1,140 @@ +/** + * Issue-search rewrites in `rewriteSearchQuery`. + * + * The general AND/OR rules are covered through the CLI wrapper in + * `packages/cli/test/lib/search-query*.test.ts`. These tests cover what + * `issueSearch: true` adds: `is:` OR groups and paren unwrapping. + */ + +import { describe, expect, it } from "vitest"; +import { rewriteSearchQuery, SearchQueryError } from "./search-query.js"; + +function rewriteIssues(query: string): string { + return rewriteSearchQuery(query, { issueSearch: true }).query; +} + +describe("rewriteSearchQuery: return shape", () => { + it("returns no notes when the query is unchanged", () => { + expect(rewriteSearchQuery("is:unresolved level:error")).toEqual({ + query: "is:unresolved level:error", + notes: [], + }); + }); + + it("returns a note per rewrite", () => { + const result = rewriteSearchQuery("level:error AND project:123"); + expect(result.query).toBe("level:error project_id:123"); + expect(result.notes).toHaveLength(2); + }); + + it("throws SearchQueryError when OR can't be rewritten", () => { + expect(() => rewriteSearchQuery("error OR timeout")).toThrow( + SearchQueryError, + ); + }); +}); + +describe("rewriteSearchQuery: is: OR in issue search", () => { + it("rewrites statuses to a status in-list", () => { + expect(rewriteIssues("is:unresolved OR is:resolved OR is:ignored")).toBe( + "status:[unresolved,resolved,ignored]", + ); + }); + + it("rewrites substatuses of one status to a substatus in-list", () => { + expect(rewriteIssues("is:regressed OR is:escalating")).toBe( + "substatus:[regressed,escalating]", + ); + expect( + rewriteIssues("is:archived_forever OR is:archived_until_escalating"), + ).toBe("substatus:[archived_forever,archived_until_escalating]"); + }); + + it("keeps surrounding filters", () => { + expect( + rewriteIssues("is:unresolved is:regressed OR is:escalating level:error"), + ).toBe("is:unresolved substatus:[regressed,escalating] level:error"); + }); + + it("drops duplicate values", () => { + expect(rewriteIssues("is:new OR is:new")).toBe("substatus:[new]"); + }); + + it.each([ + // The endpoint adds a status filter for the first substatus only. + ["substatuses of different statuses", "is:ongoing OR is:archived_forever"], + ["a status mixed with a substatus", "is:resolved OR is:regressed"], + ["flags", "is:assigned OR is:unassigned"], + ["negated is:", "!is:resolved OR !is:ignored"], + ["is: mixed with another key", "is:resolved OR level:error"], + ])("throws for %s", (_label, query) => { + expect(() => rewriteIssues(query)).toThrow(SearchQueryError); + }); + + it("explains the status and substatus rewrites when it throws", () => { + expect(() => rewriteIssues("is:resolved OR is:regressed")).toThrow( + /status:\[resolved,ignored\][\s\S]*substatus:\[regressed,escalating\]/, + ); + }); + + it("leaves is: OR alone outside issue search", () => { + expect(() => rewriteSearchQuery("is:regressed OR is:escalating")).toThrow( + SearchQueryError, + ); + }); +}); + +describe("rewriteSearchQuery: parentheses in issue search", () => { + it("unwraps a group that reduces to one in-list", () => { + expect(rewriteIssues("is:unresolved (is:regressed OR is:escalating)")).toBe( + "is:unresolved substatus:[regressed,escalating]", + ); + expect(rewriteIssues("(level:error OR level:warning) is:unresolved")).toBe( + "level:[error,warning] is:unresolved", + ); + }); + + it("unwraps a group without OR", () => { + expect(rewriteIssues("(is:unresolved)")).toBe("is:unresolved"); + expect(rewriteIssues("(is:unresolved AND level:error) timeout")).toBe( + "is:unresolved level:error timeout", + ); + }); + + it("unwraps nested groups", () => { + expect(rewriteIssues("((level:error OR level:warning))")).toBe( + "level:[error,warning]", + ); + }); + + it("merges a single-term group into a surrounding OR", () => { + expect(rewriteIssues("(level:error) OR level:warning")).toBe( + "level:[error,warning]", + ); + }); + + it("notes the unwrap", () => { + expect( + rewriteSearchQuery("(is:unresolved)", { issueSearch: true }), + ).toEqual({ + query: "is:unresolved", + notes: [ + "Issue search does not support parentheses — removed the grouping.", + ], + }); + }); + + it.each([ + ["OR across keys", "is:unresolved (level:error OR assigned:me)"], + [ + "a multi-term group inside OR", + "(is:unresolved level:error) OR level:warning", + ], + ])("throws for %s", (_label, query) => { + expect(() => rewriteIssues(query)).toThrow(SearchQueryError); + }); + + it("keeps parentheses outside issue search", () => { + expect(rewriteSearchQuery("(is:unresolved)").query).toBe("(is:unresolved)"); + }); +}); diff --git a/packages/toolkit-core/src/search-query.ts b/packages/toolkit-core/src/search-query.ts new file mode 100644 index 000000000..1cffbfca2 --- /dev/null +++ b/packages/toolkit-core/src/search-query.ts @@ -0,0 +1,1162 @@ +/** + * Sentry search query sanitization. + * + * Repairs common query mistakes before they reach the Sentry API. Shared by + * the CLI (`--query`) and the MCP server (`search_issues`). Endpoints that + * support boolean syntax opt in to preserving it; other endpoints use these + * rewrites: + * + * - **AND**: Sentry search implicitly ANDs space-separated terms, so + * explicit `AND` is redundant. Stripped with a warning. + * - **OR**: Attempted rewrite to in-list syntax (`key:[val1,val2]`) + * when all OR operands share the same qualifier key. Throws a + * {@link SearchQueryError} 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. + * + * Issue search (`issueSearch: true`) also handles `is:` and parentheses: + * + * - **`is:A OR is:B`**: `is:` rejects in-lists, but the statuses and + * substatuses it maps to don't. Rewritten to `status:[A,B]` or + * `substatus:[A,B]` when every value maps to the same field. + * - **Parentheses**: the issues endpoint has no grouping. A group that + * reduces to plain terms is unwrapped. + * + * Hosts own reporting: {@link rewriteSearchQuery} returns the rewrite notes, + * and the caller decides whether to log them or show them to a model. + * + * Boolean rewriting uses the Sentry frontend's search grammar through + * `./search-syntax.ts`. Its token tree is flattened into + * the {@link SearchNode} list below, which classifies every term + * structurally — comparison operators, in-list values, paren groups, + * etc. — so the rewriting logic doesn't need regex-based reject-lists. + * + * @see {@link https://github.com/getsentry/sentry/blob/master/static/app/components/searchSyntax/grammar.pegjs | Canonical Sentry PEG grammar} + */ + +import { + type FilterToken, + FilterType, + type ParseResultToken, + parseSearch, + TermOperator, + Token, +} from "./search-syntax.js"; + +/** A query that can't be rewritten into syntax the endpoint accepts. */ +export class SearchQueryError extends Error { + override name = "SearchQueryError"; +} + +/** + * Keys that do not support in-list syntax in Sentry search. + * + * - `is`: Explicitly documented as unsupported by Sentry docs + * - `has`: Uses `search_value` not `text_in_list` in the PEG grammar + */ +const INVALID_INLIST_KEYS = new Set(["is", "has"]); + +/** + * `is:` values that the issues endpoint translates to a `status` filter. + * From `STATUS_QUERY_CHOICES` in getsentry/sentry `src/sentry/models/group.py` + * (the deprecated `muted` alias is left out). + */ +const IS_STATUS_VALUES: ReadonlySet = new Set([ + "resolved", + "unresolved", + "ignored", + "archived", + "reprocessing", +]); + +/** + * `is:` values that the issues endpoint translates to a `substatus` filter, + * mapped to their parent status. From `SUBSTATUS_UPDATE_CHOICES` in + * getsentry/sentry `src/sentry/types/group.py`. + * + * The endpoint adds a status filter for the *first* substatus only, so a + * `substatus:[…]` list is only correct when every value has the same parent. + */ +const IS_SUBSTATUS_PARENT: ReadonlyMap = new Map([ + ["new", "unresolved"], + ["ongoing", "unresolved"], + ["regressed", "unresolved"], + ["escalating", "unresolved"], + ["archived_until_escalating", "ignored"], + ["archived_until_condition_met", "ignored"], + ["archived_forever", "ignored"], +]); + +/** + * Filter keys whose values scope *which* issues match but which the Sentry + * issues-list endpoint does NOT apply to its per-group seen-stats + * (`count`, `userCount`, `firstSeen`, `lastSeen`). + * + * A query filtering on one of these returns the groups matching the filter, + * but the displayed counts remain period-wide totals across every value of the + * key — e.g. `release:"X"` restricts the result set yet the counts still cover + * all releases. `issue list` requests the endpoint's query-scoped `filtered` + * counts instead when one of these keys is present. See getsentry/toolkit#1583. + */ +const COUNT_UNSCOPED_FILTER_KEYS = new Set(["release"]); + +// --------------------------------------------------------------------------- +// Search nodes +// --------------------------------------------------------------------------- + +/** A text filter: key:value */ +type TextFilter = { + type: "text_filter"; + negated: boolean; + key: string; + value: string; +}; + +/** An in-list filter: key:[val1,val2] */ +type TextInFilter = { + type: "text_in_filter"; + negated: boolean; + key: string; + values: string[]; +}; + +/** A regex filter: key://pattern// */ +type RegexFilter = { + type: "regex_filter"; + negated: boolean; + key: string; + value: string; +}; + +/** A comparison filter: key:>value, key:<=value */ +type ComparisonFilter = { + type: "comparison_filter"; + negated: boolean; + key: string; + op: string; + value: string; +}; + +/** An issue-search `is:value` filter */ +type IsFilter = { + type: "is_filter"; + negated: boolean; + value: string; +}; + +/** Free text (no key: prefix) */ +type FreeText = { + type: "free_text"; + value: string; + quoted: boolean; +}; + +/** A boolean operator (AND / OR) */ +type BooleanOp = { + type: "boolean_op"; + op: "AND" | "OR"; +}; + +/** A parenthesized group */ +type ParenGroup = { + type: "paren_group"; + inner: SearchNode[]; + raw: string; +}; + +/** + * A filter the rewriter must pass through untouched, such as an empty value + * or a list with an empty item. + */ +type OpaqueTerm = { + type: "opaque"; + raw: string; +}; + +/** One term of a search query. */ +export type SearchNode = + | TextFilter + | TextInFilter + | RegexFilter + | ComparisonFilter + | IsFilter + | FreeText + | BooleanOp + | ParenGroup + | OpaqueTerm; + +const COMPARISON_PREFIX_RE = /^(>=|<=|!=|>|<|=)/; + +/** + * Filter types whose key shape also has an in-list rule in Sentry's grammar + * (`text_in_filter` / `numeric_in_filter`). Aggregate (`count():10`), + * array-includes (`foo[*]:a`), `has:`/`is:`, boolean, date, duration and + * size filters have no in-list form, so merging them would build a query + * the API rejects. + */ +const LISTABLE_FILTER_TYPES: ReadonlySet = new Set([ + FilterType.TEXT, + FilterType.NUMERIC, +]); +const FREE_TEXT_SEPARATOR_RE = /[ \t]+/; + +type Located = { + location: { start: { offset: number }; end: { offset: number } }; +}; + +/** The exact query text a token was parsed from. */ +function sourceOf(query: string, token: Located): string { + return query.slice(token.location.start.offset, token.location.end.offset); +} + +/** Flatten one filter token into a {@link SearchNode}. */ +function filterToNode(query: string, token: FilterToken): SearchNode { + const key = sourceOf(query, token.key); + // Everything after the `:` that follows the key, operator included. + const value = query.slice( + token.key.location.end.offset + 1, + token.location.end.offset, + ); + const { negated } = token; + + if (token.operator === TermOperator.MATCHES) { + return { type: "regex_filter", negated, key, value }; + } + + if (token.filter === FilterType.IS && value) { + return { type: "is_filter", negated, value }; + } + + if ( + token.value.type === Token.VALUE_TEXT_LIST || + token.value.type === Token.VALUE_NUMBER_LIST + ) { + const values: string[] = []; + for (const item of token.value.items) { + if (!item.value || token.operator !== TermOperator.DEFAULT) { + return { type: "opaque", raw: sourceOf(query, token) }; + } + values.push(sourceOf(query, item.value)); + } + return { type: "text_in_filter", negated, key, values }; + } + + const comparison = COMPARISON_PREFIX_RE.exec(value)?.[1]; + if (comparison) { + return { + type: "comparison_filter", + negated, + key, + op: comparison, + value: value.slice(comparison.length), + }; + } + + if (!value || !LISTABLE_FILTER_TYPES.has(token.filter)) { + return { type: "opaque", raw: sourceOf(query, token) }; + } + + return { type: "text_filter", negated, key, value }; +} + +/** + * Flatten the shared parser's token tree into {@link SearchNode}s. Returns + * `null` for unbalanced parentheses, which the rewriter can't reason about. + */ +function toSearchNodes( + query: string, + tokens: ParseResultToken[], +): SearchNode[] | null { + const nodes: SearchNode[] = []; + for (const token of tokens) { + switch (token.type) { + case Token.SPACES: + break; + case Token.LOGIC_BOOLEAN: + nodes.push({ type: "boolean_op", op: token.value }); + break; + case Token.LOGIC_GROUP: { + const inner = toSearchNodes(query, token.inner); + if (!inner) { + return null; + } + nodes.push({ type: "paren_group", inner, raw: sourceOf(query, token) }); + break; + } + case Token.FREE_TEXT: { + const text = sourceOf(query, token).trim(); + if (token.quoted) { + nodes.push({ type: "free_text", value: text, quoted: true }); + break; + } + // The grammar folds consecutive words into one free-text token; the + // rewriter treats each word as its own term. + for (const word of text.split(FREE_TEXT_SEPARATOR_RE)) { + if (word) { + nodes.push({ type: "free_text", value: word, quoted: false }); + } + } + break; + } + case Token.FILTER: + nodes.push(filterToNode(query, token)); + break; + default: + return null; + } + } + return nodes; +} + +/** Parse a query into {@link SearchNode}s, or `null` if it can't be. */ +function parseSearchNodes(query: string): SearchNode[] | null { + // Regex filters are parsed as such on every dataset so the rewriter never + // merges a `key://pattern//` value into an in-list. + const tokens = parseSearch(query, { config: { allowRegex: true } }); + return tokens ? toSearchNodes(query, tokens) : null; +} + +// --------------------------------------------------------------------------- +// AST → string serialization +// --------------------------------------------------------------------------- + +/** Serialize a single AST node back to query string form. */ +function serializeNode(node: SearchNode): string { + switch (node.type) { + case "boolean_op": { + return node.op; + } + case "free_text": { + return node.value; + } + case "paren_group": { + return node.raw; + } + case "text_filter": { + const prefix = node.negated ? "!" : ""; + return `${prefix}${node.key}:${node.value}`; + } + case "text_in_filter": { + const prefix = node.negated ? "!" : ""; + return `${prefix}${node.key}:[${node.values.join(",")}]`; + } + case "regex_filter": { + const prefix = node.negated ? "!" : ""; + return `${prefix}${node.key}:${node.value}`; + } + case "comparison_filter": { + const prefix = node.negated ? "!" : ""; + return `${prefix}${node.key}:${node.op}${node.value}`; + } + case "is_filter": { + const prefix = node.negated ? "!" : ""; + return `${prefix}is:${node.value}`; + } + case "opaque": { + return node.raw; + } + default: { + return ""; + } + } +} + +/** Serialize a list of AST nodes back to a query string. */ +function serializeNodes(nodes: SearchNode[]): string { + return nodes.map(serializeNode).join(" "); +} + +// --------------------------------------------------------------------------- +// OR → in-list rewriting +// --------------------------------------------------------------------------- + +/** + * Check whether an AST node can participate in an in-list merge. + * + * Only `text_filter` and `text_in_filter` nodes with valid keys (not + * `is:`/`has:`), no negation, and no wildcards are eligible. + * Comparison filters, free text, paren groups, and boolean ops cannot. + */ +function isMergeableFilter( + node: SearchNode, +): node is + | (SearchNode & { type: "text_filter" }) + | (SearchNode & { type: "text_in_filter" }) { + if (node.type === "text_filter") { + if (node.negated) { + return false; + } + if (INVALID_INLIST_KEYS.has(node.key.toLowerCase())) { + return false; + } + return !node.value.includes("*"); + } + if (node.type === "text_in_filter") { + if (node.negated) { + return false; + } + if (INVALID_INLIST_KEYS.has(node.key.toLowerCase())) { + return false; + } + // No wildcards in any existing in-list values + return !node.values.some((v) => v.includes("*")); + } + return false; +} + +/** Extract all values from a mergeable filter node. */ +function valuesFromNode( + node: SearchNode & { type: "text_filter" | "text_in_filter" }, +): string[] { + if (node.type === "text_in_filter") { + return node.values; + } + return [node.value]; +} + +/** + * Try to merge a group of AST nodes (OR-connected) into a single + * in-list filter node. Returns the merged node or `null`. + */ +function tryMergeGroup( + group: SearchNode[], + issueSearch: boolean, +): SearchNode | null { + if (issueSearch) { + const merged = tryMergeIsGroup(group); + if (merged) { + return merged; + } + } + + // All must be mergeable filters + for (const node of group) { + if (!isMergeableFilter(node)) { + return null; + } + } + + const filters = group as Array< + SearchNode & { type: "text_filter" | "text_in_filter" } + >; + const first = filters[0]; + if (!first) { + return null; + } + + // All must have the same key (case-insensitive) + const keyLower = first.key.toLowerCase(); + for (const f of filters) { + if (f.key.toLowerCase() !== keyLower) { + return null; + } + } + + // Collect and flatten all values + const allValues: string[] = []; + for (const f of filters) { + allValues.push(...valuesFromNode(f)); + } + + return { + type: "text_in_filter", + negated: false, + key: first.key, + values: allValues, + }; +} + +/** + * Merge an OR group of `is:` filters into a `status` or `substatus` in-list. + * + * `is:` rejects in-lists, but the issues endpoint translates each `is:` + * value to a `status` or `substatus` filter, and those accept lists. Returns + * `null` unless every operand is a non-negated `is:` whose value maps to the + * same field (and, for substatuses, the same parent status). + */ +function tryMergeIsGroup(group: SearchNode[]): SearchNode | null { + const values: string[] = []; + for (const node of group) { + if (node.type !== "is_filter" || node.negated) { + return null; + } + if (!values.includes(node.value)) { + values.push(node.value); + } + } + + if (values.every((value) => IS_STATUS_VALUES.has(value))) { + return { type: "text_in_filter", negated: false, key: "status", values }; + } + + const parents = new Set( + values.map((value) => IS_SUBSTATUS_PARENT.get(value)), + ); + if (parents.size === 1 && !parents.has(undefined)) { + return { type: "text_in_filter", negated: false, key: "substatus", values }; + } + + return null; +} + +/** + * Merge an OR group into a single node. Single-element groups pass through. + * Returns `null` if the group cannot be merged. + */ +function mergeOrGroup( + group: SearchNode[], + issueSearch: boolean, +): SearchNode | null { + if (group.length === 1) { + return group[0] ?? null; + } + return tryMergeGroup(group, issueSearch); +} + +/** + * Walk the AST node list and attempt to rewrite all OR groups to + * in-list syntax. Returns the rewritten node list, or `null` if + * any OR group cannot be merged. + */ +function tryRewriteOr( + nodes: SearchNode[], + issueSearch: boolean, +): SearchNode[] | null { + const result: SearchNode[] = []; + let i = 0; + + while (i < nodes.length) { + const current = nodes[i] as SearchNode; + + // Skip stray OR tokens + if (isOrNode(nodes, i)) { + i += 1; + continue; + } + + // Check if next node is OR → collect and merge the chain + if (isOrNode(nodes, i + 1)) { + const [group, nextIndex] = collectOrChain(nodes, i); + const merged = mergeOrGroup(group, issueSearch); + if (!merged) { + return null; + } + result.push(merged); + i = nextIndex; + } else { + result.push(current); + i += 1; + } + } + + // All-OR input (e.g. "OR OR OR") produces an empty result — treat as unmergeable + if (result.length === 0) { + return null; + } + + return result; +} + +/** Check whether the node at index `j` is an OR operator. */ +function isOrNode(nodes: SearchNode[], j: number): boolean { + const node = nodes[j]; + return node?.type === "boolean_op" && node.op === "OR"; +} + +/** + * Collect an OR-chain starting at `start`. Returns the group of + * non-OR nodes and the index past the end of the chain. + */ +function collectOrChain( + nodes: SearchNode[], + start: number, +): [SearchNode[], number] { + const group = [nodes[start] as SearchNode]; + let j = start + 1; + + while (isOrNode(nodes, j)) { + j += 1; // skip OR + // Skip consecutive ORs + while (isOrNode(nodes, j)) { + j += 1; + } + if (j < nodes.length) { + group.push(nodes[j] as SearchNode); + j += 1; + } + } + + return [group, j]; +} + +// --------------------------------------------------------------------------- +// Boolean operator scanning (recursive into paren groups) +// --------------------------------------------------------------------------- + +/** + * Check whether any paren group (at any nesting depth) contains an OR + * operator. These are opaque — their `raw` text can't be rewritten — + * so any OR inside must be rejected even if top-level OR is rewritable. + */ +function hasOrInParenGroups(nodes: SearchNode[]): boolean { + for (const node of nodes) { + if (node.type === "paren_group" && hasOrInNodes(node.inner)) { + return true; + } + } + return false; +} + +/** Check whether any node (recursively) contains an OR operator. */ +function hasOrInNodes(nodes: SearchNode[]): boolean { + for (const node of nodes) { + if (node.type === "boolean_op" && node.op === "OR") { + return true; + } + if (node.type === "paren_group" && hasOrInNodes(node.inner)) { + return true; + } + } + return false; +} + +/** + * Non-recursive scan — only checks top-level nodes, not paren group contents. + * Used to distinguish "OR at top level" from "OR only inside paren groups". + */ +function scanBooleanOpsFlat(nodes: SearchNode[]): { + hasOr: boolean; + hasAnd: boolean; +} { + let hasOr = false; + let hasAnd = false; + for (const node of nodes) { + if (node.type === "boolean_op") { + if (node.op === "OR") { + hasOr = true; + } else { + hasAnd = true; + } + } + } + return { hasOr, hasAnd }; +} + +/** + * Remove AND boolean_op nodes from a flat node list. + * Does NOT recurse into paren groups — those are opaque. + */ +function stripAndNodes(nodes: SearchNode[]): SearchNode[] { + return nodes.filter((n) => !(n.type === "boolean_op" && n.op === "AND")); +} + +// --------------------------------------------------------------------------- +// Paren group unwrapping (issue search) +// --------------------------------------------------------------------------- + +/** + * Unwrap paren groups that reduce to plain terms. + * + * The issues endpoint has no grouping, so `is:unresolved (is:regressed OR + * is:escalating)` must become `is:unresolved substatus:[regressed,escalating]`. + * A group is unwrapped when its contents, after stripping AND and rewriting + * OR, are plain terms. Its terms are spliced into the surrounding implicit + * AND, unless an OR touches the group, in which case it is only unwrapped + * when it reduced to a single term. Any other group is left as written. + */ +function unwrapParenGroups(nodes: SearchNode[]): { + nodes: SearchNode[]; + changed: boolean; +} { + const result: SearchNode[] = []; + let changed = false; + + nodes.forEach((node, i) => { + if (node.type !== "paren_group") { + result.push(node); + return; + } + + const inner = unwrapParenGroups(stripAndNodes(node.inner)).nodes; + const terms = hasOrInNodes(inner) ? tryRewriteOr(inner, true) : inner; + const touchesOr = isOrNode(nodes, i - 1) || isOrNode(nodes, i + 1); + if ( + !terms || + terms.length === 0 || + terms.some((term) => term.type === "paren_group") || + (touchesOr && terms.length > 1) + ) { + result.push(node); + return; + } + + result.push(...terms); + changed = true; + }); + + return { nodes: changed ? result : nodes, changed }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Options for {@link rewriteSearchQuery}. */ +export type SearchQueryOptions = { + /** Events queries support boolean operators; issue search still needs rewrites. */ + allowBoolean?: boolean; + /** + * The query targets the issues endpoint: rewrite `is:` OR groups to + * `status`/`substatus` in-lists and unwrap parentheses. + */ + issueSearch?: boolean; +}; + +/** A rewritten query and the reasons it changed. */ +export type RewrittenSearchQuery = { + /** The query to send to the API. */ + query: string; + /** One note per rewrite. Empty when the query is unchanged. */ + notes: string[]; +}; + +/** + * Rewrite a search query into syntax the target endpoint accepts. + * + * Repairs common syntax mistakes, preserving boolean operators when the + * endpoint supports them. Otherwise parses a PEG grammar and rewrites operators: + * + * - **AND**: Stripped (implicit in Sentry search). + * - **OR**: Attempted rewrite to in-list syntax (`key:[val1,val2]`). + * Succeeds when all OR operands share the same valid qualifier key. + * Throws {@link SearchQueryError} when the rewrite is not possible. + * + * Paren groups, quoted values, and comparison operators are handled + * correctly by the grammar — no regex-based reject-lists needed. + * + * @param query - Raw search query + * @param options - Endpoint capabilities; see {@link SearchQueryOptions} + * @returns The query to send, plus one note per rewrite + * @throws {SearchQueryError} When OR cannot be rewritten to in-list syntax + */ +export function rewriteSearchQuery( + query: string, + options: SearchQueryOptions = {}, +): RewrittenSearchQuery { + // --- Layer 1: Pre-parse text normalization --- + // Run cheap text transforms on every query BEFORE PEG parsing. + // 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); + + if (options.allowBoolean) { + return { query: withNumericProject, notes }; + } + + let nodes = parseSearchNodes(withNumericProject); + if (!nodes) { + // PEG parse still failed after normalization — pass through to the + // API which returns a proper 400 with actionable details. The text + // rewrites already ran, so say so: the 400 will quote them. + return { query: withNumericProject, notes }; + } + + const issueSearch = options.issueSearch === true; + let unwrapped = false; + if (issueSearch) { + ({ nodes, changed: unwrapped } = unwrapParenGroups(nodes)); + if (unwrapped) { + notes.push( + "Issue search does not support parentheses — removed the grouping.", + ); + } + } + + // 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, + // because the paren group's raw text would pass the OR through to the API. + if (hasOrInParenGroups(nodes)) { + throw new SearchQueryError( + "Could not rewrite OR inside parenthesized group.\n\n" + + "Sentry search does not support boolean operators. " + + "Remove the parentheses and use in-list syntax instead:\n" + + " (level:error OR level:warning) → level:[error,warning]\n\n" + + "Search syntax: https://docs.sentry.io/concepts/search/", + ); + } + + const { hasOr, hasAnd } = scanBooleanOpsFlat(nodes); + + if (hasOr) { + // Strip AND nodes before OR rewrite + const withoutAnd = hasAnd ? stripAndNodes(nodes) : nodes; + return { query: handleOr(withoutAnd, hasAnd, notes, issueSearch), notes }; + } + + if (hasAnd) { + notes.push( + "Sentry search implicitly ANDs terms — removed explicit AND operator.", + ); + return { query: serializeNodes(stripAndNodes(nodes)), notes }; + } + + if (unwrapped) { + return { query: serializeNodes(nodes), notes }; + } + + return { query: withNumericProject, notes }; +} + +/** 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; +} + +/** + * Handle the OR rewrite path — extracted to keep `rewriteSearchQuery` under + * the cognitive complexity limit. + */ +function handleOr( + nodes: SearchNode[], + hasAnd: boolean, + notes: string[], + issueSearch: boolean, +): string { + const rewritten = tryRewriteOr(nodes, issueSearch); + if (rewritten) { + notes.push("Rewrote OR using in-list syntax: key:[val1,val2]."); + if (hasAnd) { + notes.push("Also removed explicit AND (implicit in Sentry search)."); + } + return serializeNodes(rewritten); + } + + const isRewrites = issueSearch + ? " is:resolved OR is:ignored → status:[resolved,ignored]\n" + + " is:regressed OR is:escalating → substatus:[regressed,escalating]\n" + : ""; + const isLimits = issueSearch + ? " - is: values that mix statuses and substatuses, substatuses of different statuses, or flags like is:assigned\n" + : " - is: or has: qualifiers (not supported with in-list)\n"; + + throw new SearchQueryError( + "Could not rewrite OR into Sentry search syntax.\n\n" + + "OR can be auto-rewritten when all terms share the same qualifier key:\n" + + " level:error OR level:warning → level:[error,warning]\n" + + isRewrites + + "\nPatterns that cannot be rewritten:\n" + + " - Free-text terms without a key (error OR timeout)\n" + + " - Different keys (level:error OR assigned:me)\n" + + isLimits + + " - Negated qualifiers (!key:val1 OR !key:val2)\n" + + " - Comparison values (age:>24h OR age:>7d)\n" + + " - Regex filters (use alternation instead: key://(a|b)//)\n" + + " - Parenthesized groups\n\n" + + "Alternatives:\n" + + " - Write in-list syntax directly: key:[val1,val2]\n" + + " - Run separate queries for each term\n\n" + + "Search syntax: https://docs.sentry.io/concepts/search/", + ); +} + +/** + * Collect the count-unscoped filter keys (see {@link COUNT_UNSCOPED_FILTER_KEYS}) + * present in a query, recursing into parenthesized groups. + */ +function collectUnscopedCountKeys( + nodes: SearchNode[], + found: Set, +): void { + for (const node of nodes) { + if (node.type === "paren_group") { + collectUnscopedCountKeys(node.inner, found); + continue; + } + if ( + node.type === "text_filter" || + node.type === "text_in_filter" || + node.type === "comparison_filter" || + node.type === "regex_filter" + ) { + const key = node.key.toLowerCase(); + if (COUNT_UNSCOPED_FILTER_KEYS.has(key)) { + found.add(key); + } + } + } +} + +/** + * Return the filter keys in `query` whose values restrict which issues match + * but are NOT reflected in the issues-list endpoint's seen-stats + * (`count`, `userCount`, `firstSeen`, `lastSeen`). See getsentry/cli#1518. + * + * Used by `issue list` to decide when to request query-scoped counts. + * Returns an empty array when the query is absent, unparseable, or contains + * no such keys. + * + * @param query - Raw search query, or `undefined` + * @returns Lower-cased matching keys (e.g. `["release"]`), deduplicated + */ +export function unscopedCountFilterKeys(query: string | undefined): string[] { + if (!query) { + return []; + } + const nodes = parseSearchNodes(normalizeQuery(query)); + if (!nodes) { + // Unparseable query has no keys we can detect; the API will reject it with + // a proper 400. Nothing actionable here — just skip the count-scope check. + return []; + } + const found = new Set(); + collectUnscopedCountKeys(nodes, found); + return [...found]; +} + +// --------------------------------------------------------------------------- +// Search syntax reference +// --------------------------------------------------------------------------- + +/** + * Compact search syntax reference for JSON output. + * + * Gives agents and power users a machine-readable summary of Sentry's + * search syntax without needing to consult external docs. Derived from the + * PEG grammar at: + * https://github.com/getsentry/sentry/blob/master/static/app/components/searchSyntax/grammar.pegjs + * + * Injected into `--json` envelopes when the result set is empty — that's + * when users/agents most likely need query help (bad query, wrong syntax). + */ +export const SEARCH_SYNTAX_REFERENCE = { + _type: "sentry_search_syntax", + docs: "https://docs.sentry.io/concepts/search/", + grammar: + "https://github.com/getsentry/sentry/blob/master/static/app/components/searchSyntax/grammar.pegjs", + behavior: "Terms are space-separated and implicitly ANDed.", + operators: { + and: "NOT supported — implicit (space-separated terms are all required)", + or: "NOT supported — use key:[val1,val2] in-list syntax instead", + not: "!key:value (prefix with !)", + comparison: [">=", "<=", ">", "<", "=", "!="], + wildcard: "* in values (e.g., message:*timeout*)", + inList: "key:[val1,val2] — matches any value in the list", + regex: + "key://pattern// (logs only, string attributes) — RE2, unanchored, case-sensitive ((?i) to ignore case), max 64 chars, never quoted; ends at the first // followed by whitespace, ) or end of query; negate with !key://pattern//", + }, + filterTypes: [ + "text (key:value)", + "text_in (key:[val1,val2])", + "numeric (key:>100, key:<=50)", + "boolean (key:true, key:false)", + "date (key:>2024-01-01)", + "relative_date (key:-24h, key:+7d)", + "duration (key:>1s, key:<500ms)", + "has (has:key — not null check)", + "is (is:unresolved, is:resolved, is:ignored)", + ], + commonFilters: [ + "is:unresolved", + "is:resolved", + "is:ignored", + "assigned:me", + "assigned:[me,none]", + "has:user", + "level:error", + "level:warning", + "!browser:Chrome", + "firstSeen:-24h", + "lastSeen:-1h", + "age:-7d", + "times_seen:>100", + ], +}; + +// --------------------------------------------------------------------------- +// Query normalization pipeline +// --------------------------------------------------------------------------- + +/** + * In-list filter with wrong closing delimiter `)`. + * Matches `key:[a,b,)` — captures the inner values. Does NOT match `[a,b,]` + * (handled separately by {@link stripTrailingListCommas} via balanced brackets). + */ +const MALFORMED_IN_LIST_RE = /\[([^[\]]*),\s*\)(?=\s|$)/g; + +/** Trailing comma at end of captured group content */ +const TRAILING_COMMA_RE = /,\s*$/; + +/** Balanced `[...]` block — used to skip well-formed in-list filters */ +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 / preserved segments. + * + * Matches double-quoted strings (including escaped quotes inside them) and + * `key://pattern//` regex values, which end at the first `//` followed by + * whitespace, `)`, or end of query. Between matches is unquoted text that + * can be safely normalized. + */ +const REGEX_FILTER_KEY_SOURCE = String.raw`(?:(?:tags|flags)\[[\w.:-]+(?:\[\*\])?(?: *, *(?:string|number|boolean|array))?\]|"[\w.:-]+"|[\w.[\]-]+)(?:\[\*\])?`; +const PRESERVED_SEGMENT_RE = new RegExp( + String.raw`"(?:[^"\\]|\\.)*"|(?<=(?:^|[\s()"\]])!?${REGEX_FILTER_KEY_SOURCE}:)\/\/(?:(?!\/\/(?:[\t\n )]|$))[^\n])+\/\/(?=[\t\n )]|$)`, + "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. + * + * Runs on every query BEFORE PEG parsing. Each pass is a small, focused + * transform that fixes a common agent/user mistake. The pipeline is ordered + * from most common to least common pattern. + * + * Quoted regions (`"..."`) and regex values (`key://pattern//`) are + * preserved verbatim — only the remaining text is normalized. This prevents + * `message:"error [500,] found"` and `message://[a-z,]+//` from being + * corrupted. + * + * Returns the original query unchanged if no repairs were applicable. + */ +function normalizeQuery(query: string): string { + return transformUnquoted(query, (segment) => { + let q = segment; + + // 1. Fix mismatched closing delimiters: `[a,b,)` → `[a,b]` + // The `)` is a common typo/autocomplete artifact. + q = fixMismatchedBrackets(q); + + // 2. Strip trailing commas in in-list: `[a,b,]` → `[a,b]` + q = stripTrailingListCommas(q); + + // Future passes can be added here (e.g., date normalization) + + return q; + }); +} + +/** + * Apply a transform function only to the unquoted segments of a query. + * + * Splits the query at double-quoted and regex-value boundaries, applies `fn` + * to each unquoted segment, and re-assembles with the preserved segments + * untouched. + */ +function transformUnquoted( + query: string, + fn: (unquoted: string) => string, +): string { + if (!(query.includes('"') || query.includes("//"))) { + return fn(query); + } + + const parts: string[] = []; + let lastIndex = 0; + + // Reset the regex state for each call (global regex) + PRESERVED_SEGMENT_RE.lastIndex = 0; + let match = PRESERVED_SEGMENT_RE.exec(query); + + while (match !== null) { + if (match.index > lastIndex) { + parts.push(fn(query.slice(lastIndex, match.index))); + } + parts.push(match[0]); + lastIndex = match.index + match[0].length; + match = PRESERVED_SEGMENT_RE.exec(query); + } + + if (lastIndex < query.length) { + parts.push(fn(query.slice(lastIndex))); + } + + return parts.join(""); +} + +/** + * Fix mismatched closing delimiters in in-list filters. + * + * `key:[a,b,)` → `key:[a,b]` — the `)` after `[` is clearly meant to be `]`. + * Only replaces `)` that follows a `[...` opener without an intervening `]`. + */ +function fixMismatchedBrackets(query: string): string { + return query.replace( + MALFORMED_IN_LIST_RE, + (_match, inner: string) => `[${inner.replace(TRAILING_COMMA_RE, "")}]`, + ); +} + +/** + * Strip trailing commas inside in-list filters. + * + * `key:[a,b,]` → `key:[a,b]` — valid PEG syntax but some APIs reject it. + * Only operates on balanced `[...]` blocks to avoid cross-filter corruption. + */ +function stripTrailingListCommas(query: string): string { + return query.replace(BALANCED_BRACKET_RE, (match) => + match.replace(TRAILING_LIST_COMMA_RE, "]"), + ); +} + +// --------------------------------------------------------------------------- +// Test exports +// --------------------------------------------------------------------------- + +/** + * @internal Exported for testing only. Not part of the public API. + */ +export const __testing = { + parseSearchNodes, + isMergeableFilter, + tryMergeGroup, + tryRewriteOr, + serializeNode, + serializeNodes, + normalizeQuery, + transformUnquoted, +}; From 30163769577d022b0d564cc72539743bd1d117ed Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:20:58 +0000 Subject: [PATCH 2/4] fix(core): Merge rewritten is: groups into a surrounding is: OR (is:unresolved OR is:resolved) OR is:ignored threw, because the paren group became status:[...] first and the is: merge only accepted is: operands. Accept status/substatus in-lists as operands when the merged field matches. --- .../toolkit-core/src/search-query.test.ts | 15 +++++++ packages/toolkit-core/src/search-query.ts | 43 +++++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/packages/toolkit-core/src/search-query.test.ts b/packages/toolkit-core/src/search-query.test.ts index af2f3486f..b21951402 100644 --- a/packages/toolkit-core/src/search-query.test.ts +++ b/packages/toolkit-core/src/search-query.test.ts @@ -107,6 +107,21 @@ describe("rewriteSearchQuery: parentheses in issue search", () => { ); }); + it("merges a rewritten is: group into a surrounding is: OR", () => { + expect(rewriteIssues("(is:unresolved OR is:resolved) OR is:ignored")).toBe( + "status:[unresolved,resolved,ignored]", + ); + expect(rewriteIssues("is:new OR (is:regressed OR is:escalating)")).toBe( + "substatus:[new,regressed,escalating]", + ); + }); + + it("keeps a rewritten group's field when merging", () => { + expect(() => + rewriteIssues("(is:regressed OR is:escalating) OR is:resolved"), + ).toThrow(SearchQueryError); + }); + it("merges a single-term group into a surrounding OR", () => { expect(rewriteIssues("(level:error) OR level:warning")).toBe( "level:[error,warning]", diff --git a/packages/toolkit-core/src/search-query.ts b/packages/toolkit-core/src/search-query.ts index 1cffbfca2..4b0336a63 100644 --- a/packages/toolkit-core/src/search-query.ts +++ b/packages/toolkit-core/src/search-query.ts @@ -474,30 +474,55 @@ function tryMergeGroup( * `is:` rejects in-lists, but the issues endpoint translates each `is:` * value to a `status` or `substatus` filter, and those accept lists. Returns * `null` unless every operand is a non-negated `is:` whose value maps to the - * same field (and, for substatuses, the same parent status). + * same field (and, for substatuses, the same parent status). Operands may + * also be `status:[…]`/`substatus:[…]` in-lists, such as one already merged + * from a parenthesized group, as long as the result keeps their field. */ function tryMergeIsGroup(group: SearchNode[]): SearchNode | null { const values: string[] = []; + const listFields = new Set(); for (const node of group) { - if (node.type !== "is_filter" || node.negated) { - return null; - } - if (!values.includes(node.value)) { + if (node.type === "is_filter" && !node.negated) { values.push(node.value); + } else if ( + node.type === "text_in_filter" && + !node.negated && + (node.key === "status" || node.key === "substatus") + ) { + listFields.add(node.key); + values.push(...node.values); + } else { + return null; } } - if (values.every((value) => IS_STATUS_VALUES.has(value))) { - return { type: "text_in_filter", negated: false, key: "status", values }; + const uniqueValues = [...new Set(values)]; + const field = isListField(uniqueValues); + if (!field || [...listFields].some((listField) => listField !== field)) { + return null; } + return { + type: "text_in_filter", + negated: false, + key: field, + values: uniqueValues, + }; +} +/** + * The in-list field that can hold every value: `status` when all are + * statuses, `substatus` when all are substatuses of one status, else `null`. + */ +function isListField(values: string[]): "status" | "substatus" | null { + if (values.every((value) => IS_STATUS_VALUES.has(value))) { + return "status"; + } const parents = new Set( values.map((value) => IS_SUBSTATUS_PARENT.get(value)), ); if (parents.size === 1 && !parents.has(undefined)) { - return { type: "text_in_filter", negated: false, key: "substatus", values }; + return "substatus"; } - return null; } From 35e618e7d67a20d49d882709649f2a248d129f0c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:41:58 +0000 Subject: [PATCH 3/4] fix(core): Keep status OR merges behind the parent-status check substatus:[new] OR substatus:[archived_forever] failed the is: merge and fell through to the generic same-key merge, producing a cross-parent substatus list the endpoint silently narrows to the first value's status. In issue search, route every OR group with an is:, status:, or substatus: operand through the status merge only. --- .../toolkit-core/src/search-query.test.ts | 17 ++++++++++ packages/toolkit-core/src/search-query.ts | 31 +++++++++++++------ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/toolkit-core/src/search-query.test.ts b/packages/toolkit-core/src/search-query.test.ts index b21951402..b9f40a648 100644 --- a/packages/toolkit-core/src/search-query.test.ts +++ b/packages/toolkit-core/src/search-query.test.ts @@ -77,6 +77,23 @@ describe("rewriteSearchQuery: is: OR in issue search", () => { ); }); + it("merges explicit status and substatus filters with the same check", () => { + expect(rewriteIssues("substatus:new OR substatus:[ongoing]")).toBe( + "substatus:[new,ongoing]", + ); + expect(rewriteIssues("status:resolved OR is:ignored")).toBe( + "status:[resolved,ignored]", + ); + }); + + it.each([ + "substatus:[new] OR substatus:[archived_forever]", + "substatus:archived_forever OR is:new", + "status:resolved OR substatus:regressed", + ])("throws for %s instead of a cross-parent list", (query) => { + expect(() => rewriteIssues(query)).toThrow(SearchQueryError); + }); + it("leaves is: OR alone outside issue search", () => { expect(() => rewriteSearchQuery("is:regressed OR is:escalating")).toThrow( SearchQueryError, diff --git a/packages/toolkit-core/src/search-query.ts b/packages/toolkit-core/src/search-query.ts index 4b0336a63..f69d70110 100644 --- a/packages/toolkit-core/src/search-query.ts +++ b/packages/toolkit-core/src/search-query.ts @@ -424,11 +424,10 @@ function tryMergeGroup( group: SearchNode[], issueSearch: boolean, ): SearchNode | null { - if (issueSearch) { - const merged = tryMergeIsGroup(group); - if (merged) { - return merged; - } + // Status operands must pass the parent-status check; never fall back to + // the generic same-key merge for them. + if (issueSearch && group.some(isStatusOperand)) { + return tryMergeIsGroup(group); } // All must be mergeable filters @@ -468,6 +467,18 @@ function tryMergeGroup( }; } +/** Fields the issues endpoint fills from `is:` values. */ +const STATUS_FIELDS: ReadonlySet = new Set(["status", "substatus"]); + +/** Whether a node filters on issue status: `is:`, `status:`, or `substatus:`. */ +function isStatusOperand(node: SearchNode): boolean { + return ( + node.type === "is_filter" || + ((node.type === "text_filter" || node.type === "text_in_filter") && + STATUS_FIELDS.has(node.key)) + ); +} + /** * Merge an OR group of `is:` filters into a `status` or `substatus` in-list. * @@ -475,8 +486,8 @@ function tryMergeGroup( * value to a `status` or `substatus` filter, and those accept lists. Returns * `null` unless every operand is a non-negated `is:` whose value maps to the * same field (and, for substatuses, the same parent status). Operands may - * also be `status:[…]`/`substatus:[…]` in-lists, such as one already merged - * from a parenthesized group, as long as the result keeps their field. + * also be `status:`/`substatus:` filters or in-lists, such as one already + * merged from a parenthesized group, as long as the result keeps their field. */ function tryMergeIsGroup(group: SearchNode[]): SearchNode | null { const values: string[] = []; @@ -485,12 +496,12 @@ function tryMergeIsGroup(group: SearchNode[]): SearchNode | null { if (node.type === "is_filter" && !node.negated) { values.push(node.value); } else if ( - node.type === "text_in_filter" && + (node.type === "text_filter" || node.type === "text_in_filter") && !node.negated && - (node.key === "status" || node.key === "substatus") + STATUS_FIELDS.has(node.key) ) { listFields.add(node.key); - values.push(...node.values); + values.push(...valuesFromNode(node)); } else { return null; } From ed8bb74eb4cb24ff24c66af6ffa4620caa5337ac Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:47:19 +0000 Subject: [PATCH 4/4] fix(core): Match status keys case-insensitively in the status OR guard Substatus:[new] OR substatus:[archived_forever] skipped the parent-status check because the guard matched keys exactly, then the case-insensitive generic merge combined them. Lowercase keys in the guard to match. --- packages/toolkit-core/src/search-query.test.ts | 1 + packages/toolkit-core/src/search-query.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/toolkit-core/src/search-query.test.ts b/packages/toolkit-core/src/search-query.test.ts index b9f40a648..7c8ca4645 100644 --- a/packages/toolkit-core/src/search-query.test.ts +++ b/packages/toolkit-core/src/search-query.test.ts @@ -90,6 +90,7 @@ describe("rewriteSearchQuery: is: OR in issue search", () => { "substatus:[new] OR substatus:[archived_forever]", "substatus:archived_forever OR is:new", "status:resolved OR substatus:regressed", + "Substatus:[new] OR substatus:[archived_forever]", ])("throws for %s instead of a cross-parent list", (query) => { expect(() => rewriteIssues(query)).toThrow(SearchQueryError); }); diff --git a/packages/toolkit-core/src/search-query.ts b/packages/toolkit-core/src/search-query.ts index f69d70110..e392130a1 100644 --- a/packages/toolkit-core/src/search-query.ts +++ b/packages/toolkit-core/src/search-query.ts @@ -470,12 +470,15 @@ function tryMergeGroup( /** Fields the issues endpoint fills from `is:` values. */ const STATUS_FIELDS: ReadonlySet = new Set(["status", "substatus"]); -/** Whether a node filters on issue status: `is:`, `status:`, or `substatus:`. */ +/** + * Whether a node filters on issue status: `is:`, `status:`, or `substatus:`. + * Keys compare case-insensitively, like the generic OR merge. + */ function isStatusOperand(node: SearchNode): boolean { return ( node.type === "is_filter" || ((node.type === "text_filter" || node.type === "text_in_filter") && - STATUS_FIELDS.has(node.key)) + STATUS_FIELDS.has(node.key.toLowerCase())) ); } @@ -498,9 +501,9 @@ function tryMergeIsGroup(group: SearchNode[]): SearchNode | null { } else if ( (node.type === "text_filter" || node.type === "text_in_filter") && !node.negated && - STATUS_FIELDS.has(node.key) + STATUS_FIELDS.has(node.key.toLowerCase()) ) { - listFields.add(node.key); + listFields.add(node.key.toLowerCase()); values.push(...valuesFromNode(node)); } else { return null;