diff --git a/AGENTS.md b/AGENTS.md index 141c3abc3..312842b30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -984,95 +984,135 @@ mock.module("./some-module", () => ({ ### Architecture - -* **API client wraps all errors as CliError subclasses — no raw exceptions escape**: The API client (src/lib/api-client.ts) wraps ALL errors as CliError subclasses (ApiError or AuthError) — no raw exceptions escape. Commands don't need try-catch for error display; the central handler in app.ts formats CliError cleanly. Only add try-catch when a command needs to handle errors specially (e.g., login continuing despite user-info fetch failure). + +* **commandPrefix on SentryContext enables command identity in buildCommand wrapper**: \`SentryContext.commandPrefix\` (optional \`readonly string\[]\`) is populated in \`forCommand()\` in \`context.ts\` — Stricli calls this with the full prefix (e.g., \`\["sentry", "issue", "list"]\`) before running the command. This enables the \`buildCommand\` wrapper to identify which command is executing for help recovery and telemetry. Previously, \`forCommand\` only set telemetry span names. - -* **Completion fast-path skips Sentry SDK via SENTRY\_CLI\_NO\_TELEMETRY and SQLite telemetry queue**: Shell completions (\`\_\_complete\`) set \`SENTRY\_CLI\_NO\_TELEMETRY=1\` in \`bin.ts\` before any imports, skipping \`createTracedDatabase\` wrapper and avoiding \`@sentry/node-core/light\` load (~85ms). Completion timing queued to \`completion\_telemetry\_queue\` SQLite table (~1ms). Normal runs drain via \`DELETE FROM ... RETURNING\` and emit as \`Sentry.metrics.distribution\`. Fast-path achieves ~60ms dev / ~140ms CI, 200ms e2e budget. + +* **Dashboard widget interval computed from terminal width and layout before API calls**: Dashboard chart widgets compute optimal \`interval\` before making API calls using terminal width and widget layout. Formula: \`colWidth = floor(layout.w / 6 \* termWidth)\`, \`chartWidth = colWidth - 4 - gutterW\` (~5-7), \`idealSeconds = periodSeconds / chartWidth\`. Snaps to nearest Sentry interval bucket (\`1m\`, \`5m\`, \`15m\`, \`30m\`, \`1h\`, \`4h\`, \`1d\`). Lives in \`computeOptimalInterval()\` in \`src/lib/api/dashboards.ts\`. \`periodToSeconds()\` parses \`"24h"\`, \`"7d"\` etc. The \`PERIOD\_RE\` regex is hoisted to module scope (Biome requires top-level regex). \`WidgetQueryParams\` gains optional \`interval?: string\` field; \`queryWidgetTimeseries\` uses \`params.interval ?? widget.interval\` for the API call. \`queryAllWidgets\` computes per-widget intervals using \`getTermWidth()\` logic (min 80, fallback 100). - -* **Craft artifact discovery uses commit SHA and workflow name, not tags**: Craft artifact discovery and release pipeline: Craft (\`getsentry/craft@v2\`) finds artifacts by commit SHA + workflow \*\*name\*\* (not tags). Jobs in \`ci.yml\` (named \`Build\`) auto-discovered; artifacts matching \`/^sentry-.\*$/\` picked up without \`.craft.yml\` changes. GitHub Releases are immutable post-publish — all assets (including delta patches) must be workflow artifacts before craft runs. Delta patches use TRDIFF10 (zig-bsdiff + zstd). \`generate-patches\` job runs on \`main\`/\`release/\*\*\` with \`continue-on-error\`. Nightly: old binary from GHCR via ORAS. Stable: \`gh release download\`. Binary download steps use \`sentry-\*-\*\` pattern to avoid matching \`sentry-patches\`. Bash glob loops need \`shopt -s nullglob\`. + +* **defaultCommand:help blocks Stricli fuzzy matching for top-level typos**: Fuzzy matching across CLI subsystems: (1) Stricli built-in Damerau-Levenshtein for subcommand/flag typos within known routes. (2) \`defaultCommand: "help"\` bypasses this for top-level typos — fixed by \`resolveCommandPath()\` in \`introspect.ts\` returning \`UnresolvedPath\` with suggestions via \`fuzzyMatch()\` from \`fuzzy.ts\` (up to 3). Covers \`sentry \\` and \`sentry help \\`. (3) \`fuzzyMatch()\` in \`complete.ts\` for tab-completion (Levenshtein+prefix+contains). (4) \`levenshtein()\` in \`platforms.ts\` for platform suggestions. (5) Plural alias detection in \`app.ts\`. JSON includes \`suggestions\` array. - -* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry API scoping: Events require org+project in URL path (\`/projects/{org}/{project}/events/{id}/\`). Issues use legacy global endpoint (\`/api/0/issues/{id}/\`) without org context. Traces need only org (\`/organizations/{org}/trace/{traceId}/\`). Two-step lookup for events: fetch issue → extract org/project from response → fetch event. Cross-project event search possible via Discover endpoint \`/organizations/{org}/events/\` with \`query=id:{eventId}\`. + +* **DSN org prefix normalization in arg-parsing.ts**: DSN org ID normalization has four code paths: (1) \`extractOrgIdFromHost\` in \`dsn/parser.ts\` strips \`o\` prefix during DSN parsing → bare \`"1081365"\`. (2) \`stripDsnOrgPrefix()\` strips \`o\` from user-typed inputs like \`o1081365/\`, applied in \`parseOrgProjectArg()\` and \`resolveEffectiveOrg()\`. (3) \`normalizeNumericOrg()\` in \`resolve-target.ts\` handles bare numeric IDs from cold-cache DSN detection — checks \`getOrgByNumericId()\` from DB cache, falls back to \`listOrganizationsUncached()\` to populate the mapping. Called from \`resolveOrg()\` step 4 (DSN auto-detect path). (4) Dashboard's \`resolveOrgFromTarget()\` pipes explicit org through \`resolveEffectiveOrg()\` for \`o\`-prefixed forms. Critical: many API endpoints reject numeric org IDs with 404/403 — always normalize to slugs before API calls. - -* **Sentry CLI authenticated fetch architecture with response caching**: \`createAuthenticatedFetch()\` wraps fetch with auth headers, 30s timeout, retry (max 2), 401 token refresh, and span tracing. Response caching via \`http-cache-semantics\` (RFC 7234) stored at \`~/.sentry/cache/responses/\`. Fallback TTL tiers: immutable (24hr), stable (5min), volatile (60s), no-cache (0). Only GET 2xx cached. \`--fresh\` and \`SENTRY\_NO\_CACHE=1\` bypass cache. Cache cleared on login/logout. + +* **GHCR versioned nightly tags for delta upgrade support**: GHCR nightly distribution uses three tag types: \`:nightly\` (rolling), \`:nightly-\\` (immutable), \`:patch-\\` (delta manifest). Delta patches use zig-bsdiff TRDIFF10 (zstd-compressed), ~50KB vs ~29MB full. Client bspatch via \`Bun.zstdDecompressSync()\`. N-1 patches only, full download fallback, SHA-256 verify, 60% size threshold. npm/Node excluded. Test mocks: use \`mockGhcrNightlyVersion()\` helper. - -* **Sentry CLI has two distribution channels with different runtimes**: Sentry CLI ships two ways: (1) Standalone binary via \`Bun.build()\` with \`compile: true\`. (2) npm package via esbuild producing CJS \`dist/bin.cjs\` for Node 22+, with Bun API polyfills from \`script/node-polyfills.ts\`. \`Bun.$\` has NO polyfill — use \`execSync\` instead. SDK is \`@sentry/node-core/light\` (not \`@sentry/bun\`). + +* **Issue list auto-pagination beyond API's 100-item cap**: Sentry API silently caps \`limit\` at 100 per request. \`listIssuesAllPages()\` auto-paginates using Link headers, bounded by MAX\_PAGINATION\_PAGES (50). \`API\_MAX\_PER\_PAGE\` constant is shared across all paginated consumers. \`--limit\` means total results everywhere (max 1000, default 25). Org-all mode uses \`fetchOrgAllIssues()\`; explicit \`--cursor\` does single-page fetch to preserve cursor chain. - -* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: Resolve-target cascade (src/lib/resolve-target.ts) has 5 priority levels: (1) Explicit CLI flags, (2) SENTRY\_ORG/SENTRY\_PROJECT env vars, (3) SQLite config defaults, (4) DSN auto-detection, (5) Directory name inference. SENTRY\_PROJECT supports combo notation \`org/project\` — when used, SENTRY\_ORG is ignored. If combo parse fails (e.g. \`org/\`), the entire value is discarded. The \`resolveFromEnvVars()\` helper is injected into all four resolution functions. + +* **resolveProjectBySlug carries full projectData to avoid redundant getProject calls**: \`resolveProjectBySlug()\` returns \`{ org, project, projectData: SentryProject }\` — the full project object from \`findProjectsBySlug()\`. \`ResolvedOrgProject\` and \`ResolvedTarget\` have optional \`projectData?\` (populated only in project-search path, not explicit/auto-detect). Downstream commands (\`project/view\`, \`project/delete\`, \`dashboard/create\`) use \`projectData\` when available to skip redundant \`getProject()\` API calls (~500-800ms savings). Pattern: \`resolved.projectData ?? await getProject(org, project)\` for callers that need both paths. + + +* **Self-hosted OAuth device flow requires Sentry 26.1.0+ and SENTRY\_CLIENT\_ID**: Self-hosted OAuth device flow requires Sentry 26.1.0+ and both \`SENTRY\_URL\` and \`SENTRY\_CLIENT\_ID\` env vars. Users must create a public OAuth app in Settings → Developer Settings. The client ID is NOT optional for self-hosted. Fallback for older instances: \`sentry auth login --token\`. \`getSentryUrl()\` and \`getClientId()\` in \`src/lib/oauth.ts\` read lazily (not at module load) so URL parsing from arguments can set \`SENTRY\_URL\` after import. + + +* **Sentry CLI markdown-first formatting pipeline replaces ad-hoc ANSI**: Formatters build CommonMark strings; \`renderMarkdown()\` renders to ANSI for TTY or raw markdown for non-TTY. Key helpers: \`colorTag()\`, \`mdKvTable()\`, \`mdRow()\`, \`mdTableHeader()\` (\`:\` suffix = right-aligned), \`renderTextTable()\`. \`isPlainOutput()\` checks \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`!isTTY\`. Batch path: \`formatXxxTable()\`. Streaming path: \`StreamingTable\` (TTY) or raw markdown rows (plain). Both share \`buildXxxRowCells()\`. + + +* **Sentry dashboard API rejects discover/transaction-like widget types — use spans**: The Sentry Dashboard API rejects \`widgetType: 'discover'\` and \`widgetType: 'transaction-like'\` as deprecated. Use \`widgetType: 'spans'\` for new widgets. The codebase splits types into \`WIDGET\_TYPES\` (active, for creation) and \`ALL\_WIDGET\_TYPES\` (including deprecated, for parsing server responses). \`DashboardWidgetInputSchema\` must use \`ALL\_WIDGET\_TYPES\` so editing existing widgets with deprecated types passes Zod validation. \`validateWidgetEnums()\` in \`resolve.ts\` rejects deprecated types for new widget creation — but accepts \`skipDeprecatedCheck: true\` for the edit path, where \`effectiveDataset\` may inherit a deprecated type from the existing widget. Cross-validation (display vs dataset compatibility) still runs on effective values. Tests must use \`error-events\` instead of \`discover\`; it shares \`DISCOVER\_AGGREGATE\_FUNCTIONS\` including \`failure\_rate\`. + + +* **Sentry issue stats field: time-series controlled by groupStatsPeriod**: Sentry issue stats and list table layout: \`stats\` key depends on \`groupStatsPeriod\` (\`""\`, \`"14d"\`, \`"24h"\`, \`"auto"\`); \`statsPeriod\` controls window. \*\*Critical\*\*: \`count\` is period-scoped — use \`lifetime.count\` for true total. Issue list uses \`groupStatsPeriod: 'auto'\` for sparklines. Columns: SHORT ID, ISSUE, SEEN, AGE, TREND, EVENTS, USERS, TRIAGE. TREND hidden < 100 cols. \`--compact\` tri-state: explicit overrides; \`undefined\` triggers \`shouldAutoCompact(rowCount)\` — compact if \`3N + 3 > termHeight\`. Height formula \`3N + 3\` (last row has no trailing separator). + + +* **Sentry trace-logs API is org-scoped, not project-scoped**: The Sentry trace-logs endpoint (\`/organizations/{org}/trace-logs/\`) is org-scoped, so \`trace logs\` uses \`resolveOrg()\` not \`resolveOrgAndProject()\`. The endpoint is PRIVATE in Sentry source, excluded from the public OpenAPI schema — \`@sentry/api\` has no generated types. The hand-written \`TraceLogSchema\` in \`src/types/sentry.ts\` is required until Sentry makes it public. + + +* **SKILL.md is fully generated — edit source files, not output**: The skill files under \`plugins/sentry-cli/skills/sentry-cli/\` (SKILL.md + references/\*.md) are fully generated by \`bun run generate:skill\` (script/generate-skill.ts). CI runs this after every push via a \`github-actions\[bot]\` commit, overwriting any manual edits. To change skill content, edit the \*\*sources\*\*: (1) \`docs/src/content/docs/agent-guidance.md\` — embedded into SKILL.md's Agent Guidance section with heading levels bumped. (2) \`src/commands/\*/\` flag \`brief\` strings — generate the reference file flag descriptions. (3) \`docs/src/content/docs/commands/\*.md\` — examples extracted per command via marked AST parsing. After editing sources, run \`bun run generate:skill\` locally and commit both source and generated files. CI's \`bun run check:skill\` fails if generated files are stale. + + +* **Stricli route errors are uninterceptable — only post-run detection works**: Stricli route errors, exit codes, and OutputError — error propagation gaps: (1) Route failures are uninterceptable — Stricli writes to stderr and returns \`ExitCode.UnknownCommand\` internally. Only post-\`run()\` \`process.exitCode\` check works. \`exceptionWhileRunningCommand\` only fires for errors in command \`func()\`. (2) \`ExitCode.UnknownCommand\` is \`-5\`. Bun reads \`251\` (unsigned byte), Node reads \`-5\` — compare both. (3) \`OutputError\` in \`handleOutputError\` calls \`process.exit()\` immediately, bypassing telemetry and \`exceptionWhileRunningCommand\`. Top-level typos via \`defaultCommand:help\` → \`OutputError\` → \`process.exit(1)\` skip all error reporting. + + +* **Three Sentry APIs for span custom attributes with different capabilities**: Three Sentry APIs for span data with different custom attribute support: (1) \`/trace/{traceId}/\` — hierarchical tree; \`additional\_attributes\` query param enumerates requested attributes. Returns \`measurements\` (web vitals, zero-filled on non-browser spans — \`filterSpanMeasurements()\` strips zeros in JSON). (2) \`/projects/{org}/{project}/trace-items/{itemId}/?trace\_id={id}\&item\_type=spans\` — single span full detail; returns ALL attributes as \`{name, type, value}\[]\` automatically. CLI's \`span view\` uses this via \`getSpanDetails()\`. (3) \`/events/?dataset=spans\&field=X\` — list/search; requires explicit \`field\` params. + + +* **Two independent Sentry capture sites with inconsistent filters**: \`exceptionWhileRunningCommand\` in \`app.ts:297-349\` is the primary capture point — Stricli calls it for errors from command \`func()\`, does NOT re-throw (except OutputError and AuthError). \`withTelemetry\` in \`telemetry.ts:148-164\` is the secondary capture point — catches errors that escape Stricli (re-thrown AuthError, OutputError, middleware errors). The gap: \`app.ts\` captures ALL non-OutputError/AuthError errors including expected user errors, while \`withTelemetry\` has \`isClientApiError\` filter. Since Stricli doesn't re-throw, most command errors never reach \`withTelemetry\` — its filters are mostly dead code for command errors. Fix telemetry noise in \`app.ts\`, not \`telemetry.ts\`. + + +* **withAuthGuard returns discriminated Result type, not fallback+onError**: \`withAuthGuard\(fn)\` in \`src/lib/errors.ts\` returns a discriminated Result: \`{ ok: true, value: T } | { ok: false, error: unknown }\`. AuthErrors always re-throw (triggers bin.ts auto-login). All other errors are captured. Callers inspect \`result.ok\` to degrade gracefully. Used across 12+ files. + + +* **withTracing sets span status based on exceptions only, not HTTP response codes**: The \`withTracing\`/\`withHttpSpan\` helpers in \`telemetry.ts\` set span status purely based on whether the callback throws: return → OK (code 1), throw → Error (code 2). Since \`createAuthenticatedFetch\` returns the Response object without throwing on 4xx (the \`response.ok\` check happens later in \`apiRequestToRegion\`), all 4xx HTTP spans were incorrectly marked "ok". Fixed by switching \`createAuthenticatedFetch\` to \`withTracingSpan\` to access the span directly, setting \`http.response.status\_code\` attribute and \`span.setStatus({ code: 2 })\` for non-ok responses. OAuth callers (\`oauth.ts\`) are unaffected — they throw inside the callback on non-ok responses, so \`withHttpSpan\` correctly marks those spans as errors. ### Decision - -* **Issue list global limit with fair per-project distribution and representation guarantees**: \`issue list --limit\` is a global total across all detected projects. \`fetchWithBudget\` Phase 1 divides evenly, Phase 2 redistributes surplus via cursor resume. \`trimWithProjectGuarantee\` ensures at least 1 issue per project before filling remaining slots. JSON output wraps in \`{ data, hasMore }\` with optional \`errors\` array. Compound cursor (pipe-separated) enables \`-c last\` for multi-target pagination, keyed by sorted target fingerprint. + +* **400 Bad Request from Sentry API indicates a CLI bug, not a user error**: The project convention is: 400 Bad Request = CLI bug (malformed request the CLI should never send), 401-499 = user error (wrong ID, no access, rate limited). \`exceptionWhileRunningCommand\` in \`app.ts:334\` calls \`Sentry.captureException()\` unconditionally for all errors except OutputError, re-thrown AuthError, and synonym matches. This means ContextError, ResolutionError, ValidationError, SeerError, and 401-499 ApiErrors are all captured as exceptions despite being expected user errors. The fix: add \`isExpectedUserError()\` guard before \`captureException\` that returns true for those types. Keep capturing 400 (CLI bug), 5xx (server error), and unknown errors. Record skipped errors as breadcrumbs for volume tracking. - -* **Sentry CLI config dir should stay at ~/.sentry/, not move to XDG**: Config dir stays at \`~/.sentry/\` (not XDG). The readonly DB errors on macOS are from \`sudo brew install\` creating root-owned files. Fixes: (1) bestEffort() makes setup steps non-fatal, (2) tryRepairReadonly() detects root-owned files and prints \`sudo chown\` instructions, (3) \`sentry cli fix\` handles ownership repair. Ownership must be checked BEFORE permissions — root-owned files cause chmod to EPERM. + +* **CLI UX philosophy: auto-recover when intent is clear, warn gently**: Core UX principle: don't fail or educate users with errors if their intent is clear. Do the intent and gently nudge them via \`log.warn()\` to stderr. Keep errors in Sentry telemetry for UX visibility and product decisions (e.g., SeerError kept for demand/upsell tracking). When asked to fix a Sentry issue, the goal is finding the underlying UX problem — not suppressing telemetry. Three recovery tiers: (1) auto-correct when semantics are identical (AND→space), (2) auto-recover with warning when match is unambiguous (fuzzy single match), (3) helpful error only when intent genuinely can't be fulfilled (OR operator). Model after \`gh\` CLI conventions. ### Gotcha - -* **Biome noExcessiveCognitiveComplexity max 15 requires extracting helpers from command handlers**: Biome lint rules that frequently trip developers in this codebase: (1) \`noExcessiveCognitiveComplexity\` max 15 — extract helpers from Stricli \`func()\` handlers to stay under limit; improves testability too. (2) \`useBlockStatements\` — always use braces, no braceless \`if/return/break\`. Nested ternaries banned (\`noNestedTernary\`) — use if/else or sequential assignments. (3) \`useAtIndex\` — use \`arr.at(-1)\` not \`arr\[arr.length - 1]\`. (4) \`noStaticOnlyClass\` — use branded instances instead of static-only classes. - - -* **brew is not in VALID\_METHODS but Homebrew formula passes --method brew**: Homebrew install: \`isHomebrewInstall()\` detects via Cellar realpath (checked before stored install info). Upgrade command tells users \`brew upgrade getsentry/tools/sentry\`. Formula runs \`sentry cli setup --method brew --no-modify-path\` as post\_install. Version pinning throws 'unsupported\_operation'. Uses .gz artifacts. Tap at getsentry/tools. + +* **Biome lint: Response.redirect() required, nested ternaries forbidden**: Biome lint rules that frequently trip up this codebase: (1) \`useResponseRedirect\`: use \`Response.redirect(url, status)\` not \`new Response\`. (2) \`noNestedTernary\`: use \`if/else\`. (3) \`noComputedPropertyAccess\`: use \`obj.property\` not \`obj\["property"]\`. (4) Max cognitive complexity 15 per function — extract helpers to stay under. - -* **Bun mock.module() leaks globally across test files in same process**: Bun's mock.module() replaces modules globally and leaks across test files in the same process. Solution: tests using mock.module() must run in a separate \`bun test\` invocation. In package.json, use \`bun run test:unit && bun run test:isolated\` instead of \`bun test\`. The \`test/isolated/\` directory exists for these tests. This was the root cause of ~100 test failures (getsentry/cli#258). + +* **Bugbot flags defensive null-checks as dead code — keep them with JSDoc justification**: Cursor Bugbot and Sentry Seer repeatedly flag two false positives: (1) defensive null-checks as "dead code" — keep them with JSDoc explaining why the guard exists for future safety, especially when removing would require \`!\` assertions banned by \`noNonNullAssertion\`. (2) stderr spinner output during \`--json\` mode — always a false positive since progress goes to stderr, JSON to stdout. Reply explaining the rationale and resolve. - -* **CI paths-filter for skill regeneration misses package.json version changes**: The \`check-generated\` job in \`.github/workflows/ci.yml\` gates on \`dorny/paths-filter\` with a \`skill\` filter matching \`src/\*\*\`, \`docs/\*\*\`, and generator scripts. But \`generate-skill.ts\` reads the version from \`package.json\` and embeds it in skill file frontmatter. Version bump commits (from Craft) only touch \`package.json\` and \`plugin.json\` — neither matches the filter, so skill regeneration is skipped and files go stale. Fix: add \`package.json\` to the \`skill\` paths-filter. The \`release/\*\*\` branch fallback doesn't help because the version bump lands on \`main\`. GitHub App token (sentry-release-bot) correctly triggers CI — the skip is purely from paths-filter mismatch. + +* **Bun mock.module for node:tty requires default export and class stubs**: Bun testing gotchas: (1) \`mock.module()\` for CJS built-ins requires a \`default\` re-export plus all named exports. Missing any causes \`SyntaxError: Export named 'X' not found\`. Always check the real module's full export list. (2) \`Bun.mmap()\` always opens with PROT\_WRITE — macOS SIGKILL on signed Mach-O, Linux ETXTBSY. Fix: use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` in bspatch.ts. (3) Wrap \`Bun.which()\` with optional \`pathEnv\` param for deterministic testing without mocks. - -* **Making clearAuth() async breaks model-based tests — use non-async Promise\ return instead**: Making \`clearAuth()\` \`async\` breaks fast-check model-based tests — real async yields during \`asyncModelRun\` cause \`createIsolatedDbContext\` cleanup to interleave. Fix: keep non-async, return \`clearResponseCache().catch(...)\` directly. Also: model-based tests need explicit timeouts (e.g., \`30\_000\`) — Bun's default 5s causes false failures during shrinking. + +* **CLI telemetry command tags use sentry. prefix with dots not bare names**: The \`buildCommand\` wrapper sets the \`command\` telemetry tag using the full Stricli command prefix joined with dots: \`sentry.issue.explain\`, \`sentry.issue.list\`, \`sentry.api\`, etc. — NOT bare names like \`issue.explain\`. When querying Sentry Discover or building dashboard widgets, always use the \`sentry.\` prefix. Verify actual tag values with a Discover query (\`field:command, count()\`, grouped by \`command\`) before assuming the format. - -* **Multiregion mock must include all control silo API routes**: When changing which Sentry API endpoint a function uses, mock routes must be updated in BOTH \`test/mocks/routes.ts\` (single-region) AND \`test/mocks/multiregion.ts\` \`createControlSiloRoutes()\`. Missing the multiregion mock causes 404s in multi-region test scenarios. + +* **Dashboard queryWidgetTable must guard sort param by dataset like queryWidgetTimeseries**: The Sentry events API \`sort\` parameter is only supported on the \`spans\` dataset. Passing \`sort\` to \`errors\` or \`discover\` datasets returns 400 Bad Request. In \`src/lib/api/dashboards.ts\`, \`queryWidgetTimeseries\` correctly guards this (line 387: \`if (dataset === 'spans')\`), but \`queryWidgetTable\` must also apply the same guard. Without it, any table/big\_number widget with \`orderby\` set on a non-spans dataset triggers a 400 that gets caught and silently displayed as a widget error. The fix: \`sort: dataset === 'spans' ? query?.orderby || undefined : undefined\`. - -* **Sentry /users/me/ endpoint returns 403 for OAuth tokens — use /auth/ instead**: The Sentry \`/users/me/\` endpoint returns 403 for OAuth tokens. Use \`/auth/\` instead — it works with ALL token types and lives on the control silo. In the CLI, \`getControlSiloUrl()\` handles routing correctly. \`SentryUserSchema\` (with \`.passthrough()\`) handles the \`/auth/\` response since it only requires \`id\`. + +* **Dashboard tracemetrics dataset uses comma-separated aggregate format**: SDK v10+ custom metrics (, , ) emit envelope items. Dashboard widgets for these MUST use with aggregate format — e.g., . The parameter must match the SDK emission exactly: if no unit specified, for memory metrics, for uptime. only supports , , , , display types — no or . Widgets with always require . Sort expressions must reference aggregates present in . - -* **Sentry chunk upload API returns camelCase not snake\_case**: The Sentry chunk upload options endpoint (\`/api/0/organizations/{org}/chunk-upload/\`) returns camelCase keys (\`chunkSize\`, \`chunksPerRequest\`, \`maxRequestSize\`, \`hashAlgorithm\`), NOT snake\_case. Zod schemas for these responses should use camelCase field names. This is an exception to the typical Sentry API convention of snake\_case. Verified by direct API query. The \`AssembleResponse\` also uses camelCase (\`missingChunks\`). + +* **isClientApiError treats 400 as user error contradicting project convention**: \`isClientApiError()\` was renamed to \`isUserApiError()\` in \`telemetry.ts\` and the boundary changed from \`>= 400\` to \`> 400\` (exclusive) to match the project convention that 400 = CLI bug. PR #729 merged. The function now correctly excludes 400 Bad Request from the "user error" classification, ensuring 400s are captured as Sentry exceptions while 401-499 are treated as expected user errors (wrong ID, no access, rate limited). Both call sites in \`withTelemetry\` were updated. - -* **Source Map v3 spec allows null entries in sources array**: The Source Map v3 spec allows \`null\` entries in the \`sources\` array, and bundlers like esbuild actually produce them. Any code iterating over \`sources\` and calling string methods (e.g., \`.replaceAll()\`) must guard against null: \`map.sources.map((s) => typeof s === "string" ? s.replaceAll("\\\\", "/") : s)\`. Without the guard, \`null.replaceAll()\` throws \`TypeError\`. This applies to \`src/lib/sourcemap/debug-id.ts\` and any future sourcemap manipulation code. + +* **Sentry backend /api/0/auth/ can return 400 despite successful token authentication**: The Sentry backend's \`AuthIndexEndpoint\` (\`GET /api/0/auth/\`) overrides \`authentication\_classes\` to only \`(QuietBasicAuthentication, SessionAuthentication)\`, excluding \`UserAuthTokenAuthentication\`. When the CLI sends \`Authorization: Bearer \\`: (1) \`QuietBasicAuthentication\` skips (not "Basic"), (2) \`SessionAuthentication\` skips (no cookie), (3) DRF sets \`AnonymousUser\`, (4) \`get()\` returns 400. The token DB lookups visible in traces are from Django middleware before DRF's pipeline — DRF doesn't carry them over (no \`\_\_from\_api\_client\_\_\` flag). Fix: add \`UserAuthTokenAuthentication\` first in the tuple. Secondary gotcha: org-scoped tokens would still fail because \`/api/0/auth/\` (\`sentry-api-0-auth\`) isn't in the org-endpoint allowlist checked by \`authenticate\_token()\`. This is a server-side bug, not a CLI bug. - -* **Stricli command context uses this.stdout not this.process.stdout**: In Stricli command \`func()\` handlers, use \`this.stdout\` and \`this.stderr\` directly — NOT \`this.process.stdout\`. The \`SentryContext\` interface has both \`process\` and \`stdout\`/\`stderr\` as separate top-level properties. Test mock contexts typically provide \`stdout\` but not a full \`process\` object, so \`this.process.stdout\` causes \`TypeError: undefined is not an object\` at runtime in tests even though TypeScript doesn't flag it. + +* **Sentry issue descriptions must not contain real org/project names (PII)**: Sentry issue events contain real organization and project slugs which are PII. When referencing Sentry issues in PR descriptions, commit messages, or code comments, always redact real org/project names with generic placeholders (e.g., \`'my-org'\`, \`'my-project'\`). Use \`\*\*\*\` or descriptive placeholders in issue titles. This applies to both automated tooling output and manual references. The user caught real names like \`d4swing\`, \`webscnd\`, \`heyinc\` leaking into a PR description. - -* **Stricli defaultCommand blends default command flags into route completions**: When a Stricli route map has \`defaultCommand\` set, requesting completions for that route (e.g. \`\["issues", ""]\`) returns both the subcommand names AND the default command's flags/positional completions. This means completion tests that compare against \`extractCommandTree()\` subcommand lists will fail for groups with defaultCommand, since the actual completions include extra entries like \`--limit\`, \`--query\`, etc. Solution: track \`hasDefaultCommand\` in the command tree and skip strict subcommand-matching assertions for those groups. + +* **Sentry issue list --query passes OR/AND operators to API causing 400**: Sentry issue search does NOT support AND/OR — disabled via \`SearchConfig.allow\_boolean=False\`. Backend returns 400. CLI's \`sanitizeQuery()\` auto-strips AND (case-insensitive, same semantics as implicit space-join) with \`log.warn()\`, throws \`ValidationError\` for OR (different semantics). Alternative: \`key:\[val1,val2]\` in-list syntax. The \`--json\` envelope includes \`\_searchSyntax\` with machine-readable query capabilities (operators, filter types, common filters, docs/grammar links) as an easter egg for agents. \`fullDescription\` and docs fragment include query syntax reference with examples. - -* **Upgrade command tests are flaky when run in full suite due to test ordering**: The \`upgradeCommand.func\` tests in \`test/commands/cli/upgrade.test.ts\` sometimes fail when run as part of the full \`bun run test:unit\` suite but pass consistently in isolation (\`bun test test/commands/cli/upgrade.test.ts\`). This is a test-ordering/state-leak issue, not a code bug. Don't chase these failures when your changes are unrelated to the upgrade command. + +* **spansIndexed is not a valid Sentry dataset — use spans**: The Sentry Events/Explore API accepts 5 dataset values: \`spans\`, \`transactions\`, \`logs\`, \`errors\`, \`discover\`. The name \`spansIndexed\` is invalid and returns a generic HTTP 500 "Internal error" with no helpful validation message. This trips up AI agents and users. Valid datasets are documented in \`src/lib/api/datasets.ts\` (\`EVENTS\_API\_DATASETS\` constant) and in \`docs/commands/api.md\`. ### Pattern - -* **Bun global installs use .bun path segment for detection**: Bun global package installs place scripts under \`~/.bun/install/global/node\_modules/\`. The \`.bun\` directory in the path distinguishes bun from npm/yarn installs. In \`detectPackageManagerFromPath()\`, check \`segments.includes('.bun')\` before the npm fallback. Detection order: \`.pnpm\` → pnpm, \`.bun\` → bun, other \`node\_modules\` → npm. Yarn classic uses same flat layout as npm so falls through to npm detection — this is acceptable because path-based detection is a \*\*fallback\*\* after subprocess calls (which correctly identify yarn). Path detection must NOT be authoritative/override stored DB info, only serve as fallback when subprocess detection fails (e.g., Windows where .cmd files cause ENOENT). + +* **--fields dual role: output filtering + API field selection for span list**: --fields dual role in span list: filters JSON output AND requests extra API fields. \`extractExtraApiFields()\` checks names against \`OUTPUT\_TO\_API\_FIELD\` mapping. Unknown names are treated as custom attributes added to the \`field\` API param. \`FIELD\_GROUP\_ALIASES\` supports shorthand expansion (e.g., \`gen\_ai\` → 4 fields). Extra fields survive Zod via \`SpanListItemSchema.passthrough()\` and are forwarded by \`spanListItemToFlatSpan()\`. \`formatSpanTable()\` dynamically adds columns. + + +* **--since is an alias for --period via shared PERIOD\_ALIASES**: \`PERIOD\_ALIASES\` in \`src/lib/list-command.ts\` maps both \`t\` and \`since\` to \`period\`. All commands using \`LIST\_PERIOD\_FLAG\` get \`--since\` as an alias for \`--period\` automatically via spread \`...PERIOD\_ALIASES\`. This was added because AI agents and humans naturally try \`--since 1h\` instead of \`--period 1h\`. + + +* **Branch naming and commit message conventions for Sentry CLI**: Branch naming: \`feat/\\` or \`fix/\-\\` (e.g., \`feat/ghcr-nightly-distribution\`, \`fix/268-limit-auto-pagination\`). Commit message format: \`type(scope): description (#issue)\` (e.g., \`fix(issue-list): auto-paginate --limit beyond 100 (#268)\`, \`feat(nightly): distribute via GHCR instead of GitHub Releases\`). Types seen: fix, refactor, meta, release, feat. PRs are created as drafts via \`gh pr create --draft\`. Implementation plans are attached to commits via \`git notes add\` rather than in PR body or commit message. + + +* **Codecov patch coverage only counts test:unit and test:isolated, not E2E**: CI coverage merges \`test:unit\` (\`test/lib test/commands test/types --coverage\`) and \`test:isolated\` (\`test/isolated --coverage\`) into \`coverage/merged.lcov\`. E2E tests (\`test/e2e\`) are NOT included in coverage reports. So func tests that spy on exports (e.g., \`spyOn(apiClient, 'getLogs')\`) give zero coverage to the mocked function's body. To cover \`api-client.ts\` function bodies in unit tests, mock \`globalThis.fetch\` + \`setOrgRegion()\` + \`setAuthToken()\` and call the real function. - -* **Non-essential DB cache writes should be guarded with try-catch**: Non-essential DB cache writes (e.g., \`setUserInfo()\`, \`setInstallInfo()\`) must be wrapped in try-catch so a broken/read-only DB doesn't crash a command whose primary operation succeeded. Pattern: \`try { setInstallInfo(...) } catch { log.debug(...) }\`. In login.ts, \`getCurrentUser()\` failure after token save must not block auth — log warning, continue. In upgrade.ts, \`setInstallInfo\` after legacy detection is guarded same way. Exception: \`getUserRegions()\` failure should \`clearAuth()\` and fail hard (indicates invalid token). This is enforced by BugBot reviews — any \`setInstallInfo\`/\`setUserInfo\` call outside setup.ts's \`bestEffort()\` wrapper needs its own try-catch. + +* **Issue list JSON envelope includes \_searchSyntax easter egg for agents**: When \`issue list --json\` is used and the result set is \*\*empty\*\*, the JSON envelope includes a \`\_searchSyntax\` field with machine-readable query capabilities: supported operators, filter types (\`key:value\`, \`key:\[v1,v2]\`, \`has:key\`, \`is:status\`), common filters, and links to Sentry's PEG grammar and search docs. This helps AI agents construct valid queries when they're stuck. When results are non-empty, \`\_searchSyntax\` is omitted to avoid JSON bloat. Implemented via \`jsonTransformIssueList\` which wraps \`jsonTransformListResult\` and conditionally merges the syntax object. Changed in PR #738 — previously emitted on every response. - -* **Sentry CLI command docs are auto-generated from Stricli route tree with CI freshness check**: Sentry CLI command docs are auto-generated from Stricli route tree: Docs in \`docs/src/content/docs/commands/\*.md\` and skill files in \`plugins/sentry-cli/skills/sentry-cli/references/\*.md\` are generated via \`bun run generate:docs\`. Content between \`\\` markers is regenerated; hand-written examples go in \`docs/src/fragments/commands/\`. CI checks \`check:command-docs\` and \`check:skill\` fail if stale. Run generators after changing command parameters/flags/docs. + +* **Pagination contextKey must include all query-varying parameters with escaping**: Pagination \`contextKey\` must encode every query-varying parameter (sort, query, period) with \`escapeContextKeyValue()\` (replaces \`|\` with \`%7C\`). Always provide a fallback before escaping since \`flags.period\` may be \`undefined\` in tests despite having a default: \`flags.period ? escapeContextKeyValue(flags.period) : "90d"\`. - -* **Stricli buildCommand output config injects json flag into func params**: When a Stricli command uses \`output: { json: true, human: formatFn }\`, \`--json\` and \`--fields\` flags are auto-injected. The \`func\` handler receives these in its first parameter — type explicitly (e.g., \`flags: { json?: boolean }\`) to access them. Commands with interactive side effects (browser prompts, QR codes) should check \`flags.json\` and skip them when true. + +* **PR review workflow: reply, resolve, amend, force-push**: PR review workflow: (1) Read unresolved threads via GraphQL, (2) make code changes, (3) run lint+typecheck+tests, (4) create a SEPARATE commit per review round (not amend) for incremental review, (5) push normally, (6) reply to comments via REST API, (7) resolve threads via GraphQL \`resolveReviewThread\`. Only amend+force-push when user explicitly asks or pre-commit hook modified files. - -* **ZipWriter and file handle cleanup: always use try/finally with close()**: ZipWriter in \`src/lib/sourcemap/zip.ts\` has \`close()\` for error cleanup; \`finalize()\` uses try/finally for \`fh.close()\`. Callers must wrap usage in try/catch and call \`zip.close()\` in catch. Pattern: create zip → try { add entries + finalize } catch { zip.close(); throw }. Prevents file handle leaks on partial failures. + +* **Query sanitization uses tokenization to respect quoted strings**: CLI's \`sanitizeQuery()\` regex \`/\S\*"\[^"]\*"\S\*|\S+/g\` is functionally equivalent to Sentry backend's \`split\_query\_into\_tokens()\` in \`search/utils.py\` for AND/OR detection. Known gaps: no single-quote support, no colon-space joining (\`key: value\`), no escaped quotes — none affect boolean operator detection. AND/OR matching is case-insensitive (\`token.toUpperCase()\`) matching the PEG grammar's \`"OR"i\`/\`"AND"i\`. The PEG grammar lives at \`static/app/utils/tokenizeSearch.tsx\` (frontend Peggy) and \`src/sentry/search/events/filter.py\` (backend Parsimonious) — no standalone package exists. Issue search uses the simpler \`tokenize\_query()\` which also skips AND/OR tokens. JSDoc in \`sanitizeQuery\` links to these sources with file paths and a note about potential future PEG port. -### Preference + +* **Redact sensitive flags in raw argv before sending to telemetry**: Telemetry context and argv redaction patterns: \`withTelemetry\` calls \`initTelemetryContext()\` BEFORE the callback — user ID, email, instance ID, runtime, and is\_self\_hosted tags are automatically set. For org context, read \`getDefaultOrganization()\` from SQLite (no API call). When sending raw argv, redact sensitive flags: \`SENSITIVE\_FLAGS\` in \`telemetry.ts\` (currently \`token\`). Scan for \`--token\`/\`-token\`, replace following value with \`\[REDACTED]\`. Handle both \`--flag value\` and \`--flag=value\` forms. \`setFlagContext\` handles parsed flags separately. - -* **Code style: Array.from() over spread for iterators, allowlist not whitelist**: User prefers \`Array.from(map.keys())\` over \`\[...map.keys()]\` for converting iterators to arrays (avoids intermediate spread). Use "allowlist" terminology instead of "whitelist" in comments and variable names. When a reviewer asks "Why not .filter() here?" — it may be a question, not a change request; the \`for..of\` loop may be intentionally more efficient. Confirm intent before changing. + +* **Set Sentry context for ApiError before captureException for structured diagnostics**: When \`Sentry.captureException(exc)\` is called for an \`ApiError\`, the SDK only captures \`name\`, \`message\`, and \`stacktrace\` — custom properties like \`status\`, \`endpoint\`, and \`detail\` are lost. Always call \`Sentry.setContext('api\_error', { status, endpoint, detail })\` before \`captureException\` so these fields appear as structured context in the Sentry event. Added in \`exceptionWhileRunningCommand\` in \`app.ts\`. Import \`ApiError\` from \`./lib/errors.js\` (alongside existing \`CliError\`, \`AuthError\` imports). Without this, events show only 'API request failed: 400 Bad Request' with no way to identify which endpoint failed or what the server response said. - -* **PR workflow: address review comments, resolve threads, wait for CI**: PR workflow: (1) Wait for CI, (2) Check unresolved review comments via \`gh api\`, (3) Fix in follow-up commits (not amends), (4) Reply explaining fix, (5) Resolve thread via \`gh api graphql\` \`resolveReviewThread\` mutation, (6) Push and re-check CI, (7) Final sweep. Use \`git notes add\` for implementation plans. Branch naming: \`fix/descriptive-slug\` or \`feat/descriptive-slug\`. + +* **Stricli optional boolean flags produce tri-state (true/false/undefined)**: Stricli boolean flags with \`optional: true\` (no \`default\`) produce \`boolean | undefined\` in the flags type. \`--flag\` → \`true\`, \`--no-flag\` → \`false\`, omitted → \`undefined\`. This enables auto-detect patterns: explicit user choice overrides, \`undefined\` triggers heuristic. Used by \`--compact\` on issue list. The flag type must be \`readonly field?: boolean\` (not \`readonly field: boolean\`). This differs from \`default: false\` which always produces a defined boolean. diff --git a/docs/src/fragments/commands/issue.md b/docs/src/fragments/commands/issue.md index e47f42cea..ebc503342 100644 --- a/docs/src/fragments/commands/issue.md +++ b/docs/src/fragments/commands/issue.md @@ -21,7 +21,7 @@ ID SHORT ID TITLE COUNT USERS 987654321 FRONT-DEF ReferenceError: x is not de... 456 89 ``` -**Filter by status:** +**Filter by status and search:** ```bash # Show only unresolved issues @@ -32,8 +32,27 @@ sentry issue list my-org/frontend --query "is:resolved" # Sort by frequency sentry issue list my-org/frontend --sort freq --limit 20 + +# Multiple filters (space-separated = implicit AND) +sentry issue list --query "is:unresolved level:error assigned:me" + +# Negation and wildcards +sentry issue list --query "!browser:Chrome message:*timeout*" + +# Match multiple values for one key (in-list syntax) +sentry issue list --query "browser:[Chrome,Firefox]" ``` +:::caution[Search syntax] +Sentry search uses **implicit AND** — space-separated terms are all required. +**AND/OR operators are not supported** for issue search. Use alternatives: +- `key:[val1,val2]` — in-list syntax (matches val1 OR val2 for one key) +- Run separate queries for different terms +- `*term*` — wildcard matching + +Full syntax reference: [Sentry Search Docs](https://docs.sentry.io/concepts/search/) +::: + ### View an issue ```bash diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index d9894f23f..98bffcca1 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -16,7 +16,7 @@ Manage Sentry issues List issues in a project **Flags:** -- `-q, --query - Search query (Sentry search syntax)` +- `-q, --query - Search query (Sentry syntax, implicit AND, no OR operator)` - `-n, --limit - Maximum number of issues to list - (default: "25")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` - `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "90d")` @@ -68,6 +68,15 @@ sentry issue list my-org/frontend --query "is:resolved" # Sort by frequency sentry issue list my-org/frontend --sort freq --limit 20 + +# Multiple filters (space-separated = implicit AND) +sentry issue list --query "is:unresolved level:error assigned:me" + +# Negation and wildcards +sentry issue list --query "!browser:Chrome message:*timeout*" + +# Match multiple values for one key (in-list syntax) +sentry issue list --query "browser:[Chrome,Firefox]" ``` ### `sentry issue events ` diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index a7be5093f..e6dab2e0a 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -207,19 +207,40 @@ function parseSort(value: string): SortValue { /** * Tokenize a Sentry search query respecting quoted strings. * - * Handles `key:"quoted value with spaces"` as a single token by matching - * optional non-whitespace before a quoted section, then any remaining - * non-whitespace characters. Bare words and `key:value` pairs without - * quotes are also single tokens. + * This regex is functionally equivalent to `split_query_into_tokens()` in + * the Sentry backend (`src/sentry/search/utils.py`) for the purpose of + * detecting standalone boolean operators. It splits on whitespace while + * keeping `key:"quoted value with spaces"` as a single token. + * + * Minor differences from the backend tokenizer (acceptable for our use case): + * - Does not handle single-quoted strings (`'value'`) — rare in CLI context + * - Does not join `key: value` (colon-space) into one token — irrelevant + * since AND/OR are never qualifier values with a preceding colon + * - Does not handle escaped quotes (`\"`) inside strings — edge case + * + * The full Sentry search grammar is a PEG grammar (Peggy on frontend, + * Parsimonious on backend). The canonical grammar lives at: + * https://github.com/getsentry/sentry/blob/master/static/app/components/searchSyntax/grammar.pegjs + * + * The backend's simpler tokenizer that issue search actually uses: + * https://github.com/getsentry/sentry/blob/master/src/sentry/search/utils.py + * (see `split_query_into_tokens` and `tokenize_query`) + * + * Future work: if we need full query validation beyond AND/OR detection, + * consider porting the PEG grammar via Peggy (JS PEG parser generator) or + * a pre-compiled route. The grammar is ~200 lines and self-contained. */ -const QUERY_TOKEN_RE = /\S*"[^"]*"\S*|\S+/g; +const QUERY_TOKEN_RE = /(?:[^\s"]*"[^"]*"[^\s"]*)|[^\s]+/g; /** * Sanitize `--query` before sending to the Sentry API. * * Tokenizes the query respecting quoted strings, then checks for standalone - * `OR` / `AND` tokens (not inside quoted values or qualifier values like - * `tag:OR`). + * `OR` / `AND` tokens (case-insensitive, matching the PEG grammar's + * `or_operator = "OR"i` / `and_operator = "AND"i` rules). + * + * Tokens inside quoted values (`message:"error OR timeout"`) or qualifier + * values (`tag:OR`) are never standalone and are not matched. * * - **AND**: Sentry search implicitly ANDs space-separated terms, so explicit * `AND` has identical semantics. Strip it and warn — the user's intent is @@ -228,6 +249,9 @@ const QUERY_TOKEN_RE = /\S*"[^"]*"\S*|\S+/g; * rejects it with 400. Throw a helpful ValidationError since we cannot * silently approximate the intent. * + * The backend's `tokenize_query()` also skips AND/OR: + * `if token.upper() in ["OR", "AND"]: continue` + * * @returns The sanitized query string (AND stripped, OR rejected) */ function sanitizeQuery( @@ -241,9 +265,10 @@ function sanitizeQuery( const cleaned: string[] = []; for (const token of tokens) { - if (token === "OR") { + const upper = token.toUpperCase(); + if (upper === "OR") { hasOr = true; - } else if (token === "AND") { + } else if (upper === "AND") { hasAnd = true; } else { cleaned.push(token); @@ -255,6 +280,7 @@ function sanitizeQuery( "Sentry search does not support the OR operator.\n\n" + "Alternatives:\n" + ' - Use space-separated terms (implicit AND): --query "error timeout"\n' + + ' - Use in-list syntax for a single key: --query "key:[val1,val2]"\n' + " - Run separate queries for each term\n" + ' - Use wildcards for partial matching: --query "*error*"\n\n' + "Search syntax: https://docs.sentry.io/concepts/search/", @@ -1583,16 +1609,83 @@ function formatIssueListHuman(result: IssueListResult): string { } /** - * Transform an {@link IssueListResult} into the JSON output format. + * Compact search syntax reference embedded in JSON output. * - * Paginated responses produce a `{ data, hasMore, nextCursor?, errors? }` envelope. - * Non-paginated responses produce a flat `[...]` array. - * Field filtering is applied per-element inside `data`, not to the wrapper. + * Gives agents and power users a machine-readable summary of Sentry's issue + * 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 */ -// JSON transform delegates to the shared jsonTransformListResult in org-list.ts. -// IssueListResult extends ListResult, so the shared function handles -// all envelope fields (hasMore, nextCursor, errors, jsonExtra) uniformly. -const jsonTransformIssueList = jsonTransformListResult; +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", + }, + 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", + ], +}; + +/** + * JSON transform for issue list that conditionally injects search syntax. + * + * Delegates to shared `jsonTransformListResult` for envelope handling. + * Adds `_searchSyntax` only when the result set is empty — that's when + * users/agents most likely need query help (bad query, wrong syntax). + * Avoids bloating every successful response with static metadata. + */ +function jsonTransformIssueList( + result: IssueListResult, + fields?: string[] +): unknown { + const transformed = jsonTransformListResult(result, fields); + // Only inject into empty paginated envelopes — helps agents discover + // query syntax when their search returned nothing. + if ( + transformed && + typeof transformed === "object" && + !Array.isArray(transformed) + ) { + const envelope = transformed as Record; + const data = envelope.data; + if (Array.isArray(data) && data.length === 0) { + envelope._searchSyntax = SEARCH_SYNTAX_REFERENCE; + } + } + return transformed; +} /** Output configuration for the issue list command. */ const issueListOutput: OutputConfig = { @@ -1623,6 +1716,16 @@ export const listCommand = buildListCommand("issue", { "Use --cursor / -c next / -c prev to paginate through larger result sets.\n\n" + "By default, only issues with activity in the last 90 days are shown. " + "Use --period to adjust (e.g. --period 24h, --period 14d).\n\n" + + "Query syntax (--query flag):\n" + + " Terms are space-separated and implicitly ANDed together.\n" + + " AND/OR operators are NOT supported. Use alternatives:\n" + + " key:[val1,val2] # in-list: matches val1 OR val2 for one key\n" + + " *term* # wildcard matching\n" + + " Filters: key:value, !key:value (negation), key:>N, key: { expect(warnings[0]).toContain("error timeout"); }); - test("does not match lowercase 'and' or 'or'", () => { - // Sentry search operators are case-sensitive uppercase + test("does not match 'and'/'or' as substrings of normal words", () => { expect(sanitizeQuery("sandbox handler", noopLog)).toBe("sandbox handler"); expect(sanitizeQuery("order error", noopLog)).toBe("order error"); }); + test("handles case-insensitive OR (matches PEG grammar)", () => { + expect(() => sanitizeQuery("error Or timeout", noopLog)).toThrow( + ValidationError + ); + expect(() => sanitizeQuery("error or timeout", noopLog)).toThrow( + ValidationError + ); + }); + + test("handles case-insensitive AND (matches PEG grammar)", () => { + expect(sanitizeQuery("error And timeout", noopLog)).toBe("error timeout"); + expect(sanitizeQuery("error and timeout", noopLog)).toBe("error timeout"); + }); + test("rejects OR even with qualifiers mixed in", () => { expect(() => sanitizeQuery("is:unresolved error OR timeout", noopLog)