diff --git a/apps/cli-docs/src/fragments/commands/schema.md b/apps/cli-docs/src/fragments/commands/schema.md index e69de29bb..34b4bc2db 100644 --- a/apps/cli-docs/src/fragments/commands/schema.md +++ b/apps/cli-docs/src/fragments/commands/schema.md @@ -0,0 +1,16 @@ +Search the bundled API schema using product concepts or CLI command paths. +Discovery includes summaries, paths, tags, and query parameter values: `logs` +can find Explore's `dataset=logs` as well as feature-flag audit logs. Results +explain their matches and show related CLI commands when a relationship is +declared. A related API does not necessarily reproduce the command's filters, +pagination, or transformations. + +Use an exact operation ID, SDK function name, or HTTP method and path for a +precise lookup. Concept searches return all matching candidates; deprecated +operations are marked and sorted after active ones. No endpoint is chosen just +because it appears first in the catalog. + +With `--json`, discovery returns an array of endpoints with additive `match` +metadata. Exact single-endpoint lookups remain endpoint objects, `--search` +always returns an array, and `--all` returns the flat catalog without match +metadata. The schema is bundled from the CLI's SDK version and works offline. diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index 54102fc0e..2ad0007a2 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -102,6 +102,26 @@ pnpm run build ## Architecture +### API discovery references + +`sentry schema` reads the index generated by `pnpm run generate:schema` from +the installed `@sentry/api` version. New operations and query enum values become +searchable when this index is regenerated; do not add a separate vocabulary map. + +To associate a CLI command with its API, add optional `docs.relatedApi` metadata +to its command builder. Reuse a descriptor exported by the API helper, such as +`LOGS_API` in `src/lib/api/logs.ts`. Each reference contains the exact generated +SDK function name (`fn`) and an optional `note` describing the dataset or filter +used. Share selector constants with the request where practical. A reference +documents related functionality, not an equivalent request. + +Introspection derives command paths, registered aliases, and group membership +from the route tree. Groups collect their public commands' references, so adding +a command requires no separate group mapping. Documentation generation checks +that every reference matches exactly one operation in the generated catalog. + +### Authentication + The CLI uses the OAuth 2.0 Device Authorization Grant ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) for authentication. This flow is designed for CLI tools and other devices that can't easily handle browser redirects: 1. CLI requests a device code from Sentry diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md index 79724f8f2..f7987673f 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md @@ -19,7 +19,7 @@ Browse the Sentry API schema | Argument | Description | |----------|-------------| -| `` | Resource, exact operation ID, or METHOD /path | +| `` | API concept, CLI command path, exact operation ID, or METHOD /path | **Flags:** - `--all - Show all endpoints in a flat list` @@ -34,9 +34,21 @@ sentry schema # Browse issue endpoints sentry schema issues -# View details for a specific operation +# Find list operations (there may be multiple candidates) sentry schema issues list +# Find APIs used by log listing +sentry schema log list + +# Find APIs used by trace listing, including command aliases +sentry schema trace ls + +# Find APIs used by a command group +sentry schema alert metrics + +# Find APIs used by a nested command +sentry schema alert metrics view + # Look up an endpoint by its exact operation ID sentry schema listOrganizationEvents diff --git a/packages/cli/script/generate-api-schema.ts b/packages/cli/script/generate-api-schema.ts index 7c86e6644..af9c24092 100644 --- a/packages/cli/script/generate-api-schema.ts +++ b/packages/cli/script/generate-api-schema.ts @@ -22,6 +22,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { extractApiEndpoints, type OpenApiSpec } from "./lib/api-schema.js"; /** Resolve the root directory of @sentry/api by finding its main entry point. */ function sentryApiDir(): string { @@ -45,38 +46,6 @@ async function getOpenApiUrl(): Promise { return `https://raw.githubusercontent.com/getsentry/sentry-api-schema/${pkg.version}/openapi-derefed.json`; } -/** Regex to extract path parameters from URL templates */ -const PATH_PARAM_PATTERN = /\{(\w+)\}/g; - -// Single source of truth for the ApiEndpoint type lives in src/lib/api-schema.ts. -// Re-export so this file remains a module (required for top-level await). -export type { ApiEndpoint } from "../src/lib/api-schema.js"; - -import type { ApiEndpoint } from "../src/lib/api-schema.js"; - -// --------------------------------------------------------------------------- -// OpenAPI Types (minimal subset we need) -// --------------------------------------------------------------------------- - -type OpenApiSpec = { - paths: Record>; -}; - -type OpenApiOperation = { - operationId?: string; - description?: string; - deprecated?: boolean; - parameters?: OpenApiParameter[]; -}; - -type OpenApiParameter = { - in: "path" | "query" | "header" | "cookie"; - name: string; - required?: boolean; - description?: string; - schema?: { type?: string }; -}; - // --------------------------------------------------------------------------- // SDK Function Name Mapping // --------------------------------------------------------------------------- @@ -128,42 +97,6 @@ async function buildSdkFunctionMap(): Promise> { return results; } -// --------------------------------------------------------------------------- -// Resource Derivation -// --------------------------------------------------------------------------- - -/** - * Derive the resource name from a URL template. - * Uses the last non-parameter path segment. - * - * @example "/api/0/organizations/{org}/issues/" → "issues" - * @example "/api/0/issues/{issue_id}/" → "issues" - */ -function deriveResource(url: string): string { - const segments = url - .split("/") - .filter((s) => s.length > 0 && !s.startsWith("{")); - const meaningful = segments.filter((s) => s !== "api" && s !== "0"); - return meaningful.at(-1) ?? "unknown"; -} - -/** - * Extract path parameter names from a URL template. - */ -function extractPathParams(url: string): string[] { - const params: string[] = []; - const pattern = new RegExp( - PATH_PARAM_PATTERN.source, - PATH_PARAM_PATTERN.flags, - ); - let match = pattern.exec(url); - while (match !== null) { - params.push(match[1]); - match = pattern.exec(url); - } - return params; -} - // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -181,49 +114,7 @@ const spec = (await response.json()) as OpenApiSpec; console.log("Building SDK function name map from @sentry/api..."); const sdkMap = await buildSdkFunctionMap(); -const endpoints: ApiEndpoint[] = []; -const HTTP_METHODS = ["get", "post", "put", "delete", "patch"]; - -for (const [urlPath, pathItem] of Object.entries(spec.paths)) { - for (const method of HTTP_METHODS) { - const operation = pathItem[method] as OpenApiOperation | undefined; - if (!operation) { - continue; - } - - const methodUpper = method.toUpperCase(); - const sdkKey = `${methodUpper}:${urlPath}`; - const fn = sdkMap.get(sdkKey) ?? ""; - - const queryParams = (operation.parameters ?? []) - .filter((p) => p.in === "query") - .map((p) => p.name); - - endpoints.push({ - fn, - method: methodUpper, - path: urlPath, - description: (operation.description ?? "").trim(), - pathParams: extractPathParams(urlPath), - queryParams, - deprecated: - operation.deprecated === true || - fn.startsWith("deprecated") || - (operation.operationId ?? "").toLowerCase().includes("deprecated"), - resource: deriveResource(urlPath), - operationId: operation.operationId ?? "", - }); - } -} - -// Sort by resource, then method for stable output -endpoints.sort((a, b) => { - const resourceCmp = a.resource.localeCompare(b.resource); - if (resourceCmp !== 0) { - return resourceCmp; - } - return a.operationId.localeCompare(b.operationId); -}); +const endpoints = extractApiEndpoints(spec, sdkMap); await mkdir(dirname(OUTPUT_PATH), { recursive: true }); await writeFile(OUTPUT_PATH, JSON.stringify(endpoints, null, 2)); diff --git a/packages/cli/script/generate-command-docs.ts b/packages/cli/script/generate-command-docs.ts index 36e735a5a..cdd3c7b5e 100644 --- a/packages/cli/script/generate-command-docs.ts +++ b/packages/cli/script/generate-command-docs.ts @@ -60,7 +60,9 @@ import type { } from "../src/lib/introspect.js"; const { routes } = await import("../src/app.js"); -const { extractAllRoutes } = await import("../src/lib/introspect.js"); +const { extractAllRoutes, validateCommandApiReferences } = + await import("../src/lib/introspect.js"); +const { getAllEndpoints } = await import("../src/lib/api-schema.js"); const { ENV_VAR_REGISTRY } = await import("../src/lib/env-registry.js"); const DOCS_DIR = `${DOCS_CONTENT}/commands`; @@ -386,6 +388,7 @@ async function readTopLevelFragment(fragmentName: string): Promise { // --------------------------------------------------------------------------- const routeMap = routes as unknown as RouteMap; +validateCommandApiReferences(routeMap, getAllEndpoints()); const routeInfos = extractAllRoutes(routeMap).filter( (r) => !SKIP_ROUTES.has(r.name), ); diff --git a/packages/cli/script/lib/api-schema.ts b/packages/cli/script/lib/api-schema.ts new file mode 100644 index 000000000..e00140397 --- /dev/null +++ b/packages/cli/script/lib/api-schema.ts @@ -0,0 +1,139 @@ +/** Extract the bundled discovery index from a dereferenced OpenAPI document. */ + +import type { + ApiEndpoint, + ApiQueryParameter, +} from "../../src/lib/api-schema.js"; + +const HTTP_METHODS = ["get", "post", "put", "delete", "patch"] as const; +const PATH_PARAM_PATTERN = /\{(\w+)\}/g; + +/** Only the OpenAPI fields used by the discovery index. */ +export type OpenApiSpec = { + paths: Record; +}; + +type OpenApiPathItem = Partial< + Record<(typeof HTTP_METHODS)[number], OpenApiOperation> +> & { + parameters?: OpenApiParameter[]; +}; + +type OpenApiOperation = { + operationId?: string; + summary?: string; + description?: string; + tags?: string[]; + deprecated?: boolean; + parameters?: OpenApiParameter[]; +}; + +type OpenApiParameter = { + in: "path" | "query" | "header" | "cookie"; + name: string; + required?: boolean; + description?: string; + schema?: { + type?: string; + enum?: unknown[]; + items?: { enum?: unknown[] }; + }; +}; + +/** Keep the existing resource grouping: the last literal URL segment. */ +function deriveResource(url: string): string { + const segments = url + .split("/") + .filter((s) => s.length > 0 && !s.startsWith("{")); + const meaningful = segments.filter((s) => s !== "api" && s !== "0"); + return meaningful.at(-1) ?? "unknown"; +} + +function isEnumValue( + value: unknown, +): value is string | number | boolean | null { + return ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +/** Operation parameters override inherited parameters with the same name/in. */ +function extractQueryParameters( + pathItem: OpenApiPathItem, + operation: OpenApiOperation, +): ApiQueryParameter[] { + const parameters = new Map(); + for (const parameter of [ + ...(pathItem.parameters ?? []), + ...(operation.parameters ?? []), + ]) { + if (parameter.in === "query") { + parameters.set(parameter.name, parameter); + } + } + + return [...parameters.values()].map((parameter) => { + const schema = parameter.schema; + const values = schema?.type === "array" ? schema.items?.enum : schema?.enum; + return { + name: parameter.name, + description: parameter.description?.trim(), + type: schema?.type, + required: parameter.required === true, + enum: values?.filter(isEnumValue), + }; + }); +} + +/** + * Generate entries directly from API metadata so new operations and enum values + * become discoverable on the next schema generation, without a vocabulary map. + */ +export function extractApiEndpoints( + spec: OpenApiSpec, + sdkMap: ReadonlyMap, +): ApiEndpoint[] { + const endpoints: ApiEndpoint[] = []; + + for (const [urlPath, pathItem] of Object.entries(spec.paths)) { + for (const method of HTTP_METHODS) { + const operation = pathItem[method]; + if (!operation) { + continue; + } + + const methodUpper = method.toUpperCase(); + const fn = sdkMap.get(`${methodUpper}:${urlPath}`) ?? ""; + const queryParameters = extractQueryParameters(pathItem, operation); + + endpoints.push({ + fn, + method: methodUpper, + path: urlPath, + summary: operation.summary?.trim(), + description: (operation.description ?? "").trim(), + tags: operation.tags, + pathParams: [...urlPath.matchAll(PATH_PARAM_PATTERN)].map((match) => + match[0].slice(1, -1), + ), + queryParams: queryParameters.map((parameter) => parameter.name), + queryParameters, + deprecated: + operation.deprecated === true || + fn.startsWith("deprecated") || + (operation.operationId ?? "").toLowerCase().includes("deprecated"), + resource: deriveResource(urlPath), + operationId: operation.operationId ?? "", + }); + } + } + + // Keep stable ordering and the existing resource/operation ID contract. + return endpoints.sort((a, b) => { + const resourceCmp = a.resource.localeCompare(b.resource); + return resourceCmp || a.operationId.localeCompare(b.operationId); + }); +} diff --git a/packages/cli/src/commands/alert/metrics/create.ts b/packages/cli/src/commands/alert/metrics/create.ts index 9516ba514..b55a701d6 100644 --- a/packages/cli/src/commands/alert/metrics/create.ts +++ b/packages/cli/src/commands/alert/metrics/create.ts @@ -5,6 +5,7 @@ */ import type { SentryContext } from "../../../context.js"; +import { METRIC_ALERT_API } from "../../../lib/api/alerts.js"; import { createMetricAlertRule } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { stringifyUnknown, ValidationError } from "../../../lib/errors.js"; @@ -72,6 +73,7 @@ function resolveMetricCreateOrg( export const createCommand = buildCommand({ docs: { + relatedApi: [METRIC_ALERT_API.create], brief: "Create a metric alert rule", fullDescription: "Create a metric alert rule (a metric monitor in the detectors API).\n\n" + diff --git a/packages/cli/src/commands/alert/metrics/delete.ts b/packages/cli/src/commands/alert/metrics/delete.ts index 42b9b93ec..351bfa54d 100644 --- a/packages/cli/src/commands/alert/metrics/delete.ts +++ b/packages/cli/src/commands/alert/metrics/delete.ts @@ -5,6 +5,7 @@ */ import type { SentryContext } from "../../../context.js"; +import { METRIC_ALERT_API } from "../../../lib/api/alerts.js"; import { deleteMetricAlertRule } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; @@ -44,6 +45,7 @@ function formatDeleted(r: DeleteResult): string { export const deleteCommand = buildDeleteCommand({ docs: { + relatedApi: [METRIC_ALERT_API.delete], brief: "Delete a metric alert rule", fullDescription: "Permanently remove a metric alert rule from an organization. " + diff --git a/packages/cli/src/commands/alert/metrics/edit.ts b/packages/cli/src/commands/alert/metrics/edit.ts index 798727707..47efd764e 100644 --- a/packages/cli/src/commands/alert/metrics/edit.ts +++ b/packages/cli/src/commands/alert/metrics/edit.ts @@ -5,6 +5,7 @@ */ import type { SentryContext } from "../../../context.js"; +import { METRIC_ALERT_API } from "../../../lib/api/alerts.js"; import { getMetricAlertRuleDocument, putMetricAlertRule, @@ -174,6 +175,7 @@ function formatEdited(r: EditResult): string { export const editCommand = buildCommand({ docs: { + relatedApi: [METRIC_ALERT_API.get, METRIC_ALERT_API.update], brief: "Edit a metric alert rule", fullDescription: "Update a metric alert rule. Pass at least one of --name or --status. " + diff --git a/packages/cli/src/commands/alert/metrics/list.ts b/packages/cli/src/commands/alert/metrics/list.ts index a8e90a208..d0eb6c2e4 100644 --- a/packages/cli/src/commands/alert/metrics/list.ts +++ b/packages/cli/src/commands/alert/metrics/list.ts @@ -11,7 +11,10 @@ */ import type { SentryContext } from "../../../context.js"; -import type { MetricAlertRule } from "../../../lib/api/alerts.js"; +import { + METRIC_ALERT_API, + type MetricAlertRule, +} from "../../../lib/api/alerts.js"; import { paginate } from "../../../lib/api/infrastructure.js"; import { listMetricAlertsPaginated } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; @@ -493,6 +496,7 @@ const jsonTransformMetricAlertList = jsonTransformListResult; export const listCommand = buildListCommand("alert metrics", { docs: { + relatedApi: [METRIC_ALERT_API.list], brief: "List metric alert rules", fullDescription: "List metric alert rules for one or more Sentry organizations.\n\n" + diff --git a/packages/cli/src/commands/alert/metrics/view.ts b/packages/cli/src/commands/alert/metrics/view.ts index 2f5da8155..1cb1c6d74 100644 --- a/packages/cli/src/commands/alert/metrics/view.ts +++ b/packages/cli/src/commands/alert/metrics/view.ts @@ -3,6 +3,7 @@ import { getIssueAlertWorkflowDocument, getMetricAlertRule, } from "../../../lib/api-client.js"; +import { METRIC_ALERT_API } from "../../../lib/api/alerts.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; import { buildCommand } from "../../../lib/command.js"; @@ -28,6 +29,7 @@ type ViewFlags = { export const viewCommand = buildCommand({ docs: { + relatedApi: [METRIC_ALERT_API.get], brief: "View a metric alert rule", fullDescription: "View a metric alert rule by ID or name, including detection settings, " + diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index 1735e7e81..76aea7954 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -9,6 +9,7 @@ // oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; import type { SentryContext } from "../../context.js"; +import { LOGS_API } from "../../lib/api/logs.js"; import { type LogSortDirection, listLogs, @@ -701,6 +702,7 @@ export const listCommand = buildListCommand( "log", { docs: { + relatedApi: [LOGS_API], brief: "List logs from a project", fullDescription: "List and stream logs from Sentry projects.\n\n" + diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index 38cab4a19..2cdaa102f 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -5,10 +5,9 @@ */ import { isatty } from "node:tty"; - import pLimit from "p-limit"; - import type { SentryContext } from "../../context.js"; +import { LOGS_API } from "../../lib/api/logs.js"; import { getLogItemDetail, getLogs } from "../../lib/api-client.js"; import { detectSwappedViewArgs, @@ -560,6 +559,7 @@ function formatLogViewHuman(data: LogViewData): string { export const viewCommand = buildCommand({ docs: { + relatedApi: [LOGS_API], brief: "View details of one or more log entries", fullDescription: "View detailed information about Sentry log entries by their IDs.\n\n" + diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index 8b5a2463a..d61b8807b 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -8,8 +8,9 @@ * sentry schema → list all resources * sentry schema --all → flat list of all endpoints * sentry schema --search → search endpoints by keyword - * sentry schema → show endpoints for a resource - * sentry schema → show detailed endpoint info + * sentry schema → discover related API endpoints + * sentry schema → list matching operation candidates + * sentry schema alert metrics view → find APIs used by this CLI command * sentry schema → show details by exact operation ID * sentry schema "GET /api/0/..." → show details for one endpoint * sentry schema monitor* → glob search for resources @@ -18,14 +19,15 @@ import type { SentryContext } from "../context.js"; import { type ApiEndpoint, + type CommandApiReference, findEndpointsByIdentifier, findEndpointsByPath, getAllEndpoints, - getEndpoint, getEndpointsByResource, getResourceSummaries, parseEndpointQuery, type ResourceSummary, + type SchemaEndpoint, searchEndpoints, } from "../lib/api-schema.js"; import { buildCommand } from "../lib/command.js"; @@ -39,6 +41,7 @@ import { } from "../lib/formatters/markdown.js"; import { CommandOutput } from "../lib/formatters/output.js"; import { fuzzyMatch } from "../lib/fuzzy.js"; +import { getCommandApiReferences, type RouteMap } from "../lib/introspect.js"; // --------------------------------------------------------------------------- // Output data types @@ -47,7 +50,7 @@ import { fuzzyMatch } from "../lib/fuzzy.js"; /** Discriminated union of all possible schema command outputs */ type SchemaResult = | { kind: "resources"; resources: ResourceSummary[] } - | { kind: "endpoints"; endpoints: readonly ApiEndpoint[] } + | { kind: "endpoints"; endpoints: readonly SchemaEndpoint[] } | { kind: "endpoint"; endpoint: ApiEndpoint }; // --------------------------------------------------------------------------- @@ -95,7 +98,7 @@ function formatResourceList(resources: ResourceSummary[]): string { } /** Format a flat list of endpoints */ -function formatEndpointList(endpoints: readonly ApiEndpoint[]): string { +function formatEndpointList(endpoints: readonly SchemaEndpoint[]): string { if (endpoints.length === 0) { return muted("No endpoints found."); } @@ -108,9 +111,22 @@ function formatEndpointList(endpoints: readonly ApiEndpoint[]): string { const colored = formatMethod(e.method); const padded = `${colored}${" ".repeat(maxMethod + padding - e.method.length)}`; const path = e.path; - const label = muted(e.operationId || e.fn); + const label = muted(e.summary || e.operationId || e.fn); const deprecated = e.deprecated ? yellow(" [deprecated]") : ""; - return `${padded}${path} ${label}${deprecated}`; + const evidence = e.match + ? renderMarkdown( + [ + ...e.match.reasons + .slice(0, 2) + .map((reason) => `- Matches: ${reason}`), + ...e.match.relatedCommands.map( + ({ command, note }) => + `- Used by: \`${command}\`${note ? ` — ${note}` : ""}`, + ), + ].join("\n"), + ) + : ""; + return `${padded}${path} ${label}${deprecated}${evidence ? `\n${evidence}` : ""}`; }) .join("\n"); } @@ -118,6 +134,9 @@ function formatEndpointList(endpoints: readonly ApiEndpoint[]): string { /** Format a single endpoint in detail using mdKvTable + renderMarkdown */ function formatEndpointDetail(endpoint: ApiEndpoint): string { const kvRows: [string, string][] = [ + ...(endpoint.summary + ? [["Summary", endpoint.summary] as [string, string]] + : []), ["Resource", endpoint.resource], ["Operation", endpoint.operationId], ...(endpoint.fn @@ -134,7 +153,15 @@ function formatEndpointDetail(endpoint: ApiEndpoint): string { } if (endpoint.queryParams.length > 0) { - kvRows.push(["Query Params", endpoint.queryParams.join(", ")]); + const parameters = endpoint.queryParameters?.map( + (parameter) => + `${parameter.name}${parameter.required ? " (required)" : ""}` + + (parameter.enum?.length ? `: ${parameter.enum.join(", ")}` : ""), + ); + kvRows.push([ + "Query Params", + (parameters ?? endpoint.queryParams).join("; "), + ]); } const heading = `## ${endpoint.method} \`${endpoint.path}\``; @@ -226,15 +253,6 @@ function noResourceMatchError(resource: string): ResolutionError { ); } -/** Join leftover positionals so `--search GET /api/0/...` (unquoted) still parses. */ -function resolveSearchQuery(search: string, args: string[]): string { - if (args.length === 0) { - return search; - } - const combined = [search, ...args].join(" "); - return parseEndpointQuery(combined).path ? combined : search; -} - function queryLabel(resource: string, operation?: string): string { return operation ? `${resource} ${operation}` : resource; } @@ -336,6 +354,33 @@ function resolveIdentifierLookup(identifier: string): SchemaResult | undefined { return; } +const QUERY_WORD_SEPARATOR = /\s+/; + +/** Explicit resource globs retain their resource-only behavior. */ +function resolveGlobQuery(resource: string): SchemaResult { + // Convert glob pattern to regex: * → .*, ? → ., escape other special chars + const escaped = resource + .toLowerCase() + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + const pattern = new RegExp(`^${escaped}$`); + const allResources = getResourceSummaries(); + const matched = allResources.filter((r) => + pattern.test(r.name.toLowerCase()), + ); + if (matched.length === 0) { + throw new ResolutionError( + `Pattern "${resource}"`, + "matched no resources", + "sentry schema", + ["sentry schema --all List every endpoint in a flat table"], + ); + } + const endpoints = matched.flatMap((r) => getEndpointsByResource(r.name)); + return { kind: "endpoints", endpoints }; +} + /** * Resolve a resource + optional operation into a SchemaResult. * Throws ResolutionError for no-match cases; throws OutputError with the @@ -346,66 +391,52 @@ function resolveIdentifierLookup(identifier: string): SchemaResult | undefined { export function resolveResourceQuery( resource: string, operation?: string, + references: readonly CommandApiReference[] = [], ): SchemaResult { + const query = queryLabel(resource, operation); const pathQuery = resolvePathQuery(resource, operation); if (pathQuery) { return resolvePathLookup(pathQuery, resource, operation); } - // Glob-style search: if the resource arg contains * or ?, match resources - if (resource.includes("*") || resource.includes("?")) { - // Convert glob pattern to regex: * → .*, ? → ., escape other special chars - const escaped = resource - .toLowerCase() - .replace(/[.+^${}()|[\]\\]/g, "\\$&") - .replace(/\*/g, ".*") - .replace(/\?/g, "."); - const pattern = new RegExp(`^${escaped}$`); - const allResources = getResourceSummaries(); - const matched = allResources.filter((r) => - pattern.test(r.name.toLowerCase()), - ); - if (matched.length === 0) { - throw new ResolutionError( - `Pattern "${resource}"`, - "matched no resources", - "sentry schema", - ["sentry schema --all List every endpoint in a flat table"], - ); - } - const endpoints = matched.flatMap((r) => getEndpointsByResource(r.name)); - return { kind: "endpoints", endpoints }; + const identifierResult = resolveIdentifierLookup(query); + if (identifierResult) { + return identifierResult; } - // Resource + operation: show single endpoint detail - if (operation) { - const endpoint = getEndpoint(resource, operation); - if (endpoint) { - return { kind: "endpoint", endpoint }; - } - // Resource exists but the operation didn't match: show its endpoints so - // the user can pick a valid operation. This is scoped to the resource, - // not the full list, so it isn't misleading. - const resourceEndpoints = getEndpointsByResource(resource); - if (resourceEndpoints.length > 0) { - throw new OutputError({ - kind: "endpoints", - endpoints: resourceEndpoints, - } satisfies SchemaResult); - } - throw noResourceMatchError(resource); + // Globs remain an explicit resource filter, separate from concept discovery. + if ( + !(operation || query.includes(" ")) && + (resource.includes("*") || resource.includes("?")) + ) { + return resolveGlobQuery(resource); } - // Resource only: show all endpoints for that resource - const endpoints = getEndpointsByResource(resource); - if (endpoints.length === 0) { - const identifierResult = resolveIdentifierLookup(resource); - if (identifierResult) { - return identifierResult; + const [resourceName, ...operationWords] = query + .trim() + .split(QUERY_WORD_SEPARATOR); + const resourceEndpoints = getEndpointsByResource(resourceName ?? ""); + const operationQuery = operationWords.join(" "); + if (operationQuery) { + const exact = findEndpointsByIdentifier(operationQuery).filter((endpoint) => + resourceEndpoints.includes(endpoint), + ); + if (exact.length === 1 && exact[0]) { + return { kind: "endpoint", endpoint: exact[0] }; } - throw noResourceMatchError(resource); } - return { kind: "endpoints", endpoints }; + + const matches = searchEndpoints(query, undefined, references); + if (matches.length > 0) { + return { kind: "endpoints", endpoints: matches }; + } + if (operationQuery && resourceEndpoints.length > 0) { + throw new OutputError({ + kind: "endpoints", + endpoints: resourceEndpoints, + } satisfies SchemaResult); + } + throw noResourceMatchError(query); } // --------------------------------------------------------------------------- @@ -423,7 +454,8 @@ export const schemaCommand = buildCommand({ brief: "Browse the Sentry API schema", fullDescription: "Browse and search the Sentry API schema. Shows available resources, " + - "operations, and endpoint details. Use with --json for machine-readable output.", + "operations, and endpoint details. Search by API concepts or CLI command paths; " + + "discovery results explain matches and related commands. Use with --json for machine-readable output.", examples: [ { description: "List all API resources", command: "sentry schema" }, { @@ -431,9 +463,26 @@ export const schemaCommand = buildCommand({ command: "sentry schema issues", }, { - description: "View details for a specific operation", + description: "Find list operations (there may be multiple candidates)", command: "sentry schema issues list", }, + { + description: "Find APIs used by log listing", + command: "sentry schema log list", + }, + { + description: + "Find APIs used by trace listing, including command aliases", + command: "sentry schema trace ls", + }, + { + description: "Find APIs used by a command group", + command: "sentry schema alert metrics", + }, + { + description: "Find APIs used by a nested command", + command: "sentry schema alert metrics view", + }, { description: "Look up an endpoint by its exact operation ID", command: "sentry schema listOrganizationEvents", @@ -475,50 +524,55 @@ export const schemaCommand = buildCommand({ positional: { kind: "array", parameter: { - brief: "Resource, exact operation ID, or METHOD /path", + brief: + "API concept, CLI command path, exact operation ID, or METHOD /path", parse: String, placeholder: "resource", }, }, }, - // Stricli requires AsyncGenerator but schema queries are synchronous (in-memory JSON) async *func(this: SentryContext, flags: SchemaFlags, ...args: string[]) { - const [resource, operation] = args; + const query = flags.search + ? [flags.search, ...args].join(" ") + : args.join(" "); - // --search takes priority - if (flags.search) { - const query = resolveSearchQuery(flags.search, args); - const results = searchEndpoints(query); - if (results.length === 0) { - throw new OutputError({ - kind: "endpoints", - endpoints: [], - } satisfies SchemaResult); - } + // --search takes priority over --all. + if (flags.all && !flags.search) { return yield new CommandOutput({ kind: "endpoints", - endpoints: results, + endpoints: getAllEndpoints(), }); } - // --all: flat endpoint list - if (flags.all) { + if (!query) { return yield new CommandOutput({ - kind: "endpoints", - endpoints: getAllEndpoints(), + kind: "resources", + resources: getResourceSummaries(), }); } - // No positional args: show resource summary - if (!resource) { - const resources = getResourceSummaries(); + const { routes } = await import("../app.js"); + const references = getCommandApiReferences( + routes as unknown as RouteMap, + parseEndpointQuery(query).text, + ); + + if (flags.search) { + const results = searchEndpoints(query, undefined, references); + if (results.length === 0) { + throw new OutputError({ + kind: "endpoints", + endpoints: [], + } satisfies SchemaResult); + } return yield new CommandOutput({ - kind: "resources", - resources, + kind: "endpoints", + endpoints: results, }); } - // Resolve resource (with optional operation or glob pattern) - return yield new CommandOutput(resolveResourceQuery(resource, operation)); + return yield new CommandOutput( + resolveResourceQuery(query, undefined, references), + ); }, }); diff --git a/packages/cli/src/commands/trace/list.ts b/packages/cli/src/commands/trace/list.ts index 16998ea52..54751a26f 100644 --- a/packages/cli/src/commands/trace/list.ts +++ b/packages/cli/src/commands/trace/list.ts @@ -5,6 +5,7 @@ */ import type { SentryContext } from "../../context.js"; +import { LIST_TRANSACTIONS_API } from "../../lib/api/traces.js"; import { listTransactions } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { @@ -195,6 +196,7 @@ function jsonTransformTraceList( export const listCommand = buildListCommand("trace", { docs: { + relatedApi: [LIST_TRANSACTIONS_API], brief: "List recent traces in a project", fullDescription: "List recent traces from Sentry projects.\n\n" + diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index 3aa370eff..4c5a836e8 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -5,6 +5,7 @@ */ import type { SentryContext } from "../../context.js"; +import { TRACE_DETAILS_API } from "../../lib/api/traces.js"; import { attributesToDict, fetchMultiSpanDetails, @@ -432,6 +433,7 @@ function fetchTraceSpanDetails( export const viewCommand = buildCommand({ docs: { + relatedApi: [TRACE_DETAILS_API], brief: "View details of a specific trace", fullDescription: "View detailed information about a distributed trace by its ID.\n\n" + diff --git a/packages/cli/src/lib/api-schema.ts b/packages/cli/src/lib/api-schema.ts index 8b807356d..cba9bb039 100644 --- a/packages/cli/src/lib/api-schema.ts +++ b/packages/cli/src/lib/api-schema.ts @@ -8,6 +8,45 @@ import endpoints from "../generated/api-schema.json"; +/** Searchable query parameter metadata from the pinned OpenAPI specification. */ +export type ApiQueryParameter = { + /** Parameter name accepted by the endpoint. */ + name: string; + /** Usage description from OpenAPI. */ + description?: string; + /** OpenAPI schema type, when declared. */ + type?: string; + /** Whether the request must include this parameter. */ + required: boolean; + /** Allowed scalar values, or item values for an array parameter. */ + enum?: (string | number | boolean | null)[]; +}; + +/** An API used by a command, with context rather than a claim of equivalence. */ +export type ApiReference = { + /** Exact SDK function name identifying the generated endpoint. */ + fn: string; + /** Command-specific selectors or context, not a complete API recipe. */ + note?: string; +}; + +/** An API reference attributed to its public CLI command. */ +export type CommandApiReference = ApiReference & { + /** Canonical command path, including the `sentry` prefix. */ + command: string; +}; + +/** Discovery evidence is additive; exact lookups return plain ApiEndpoints. */ +export type SchemaEndpoint = ApiEndpoint & { + /** Present only for discovery results, never exact endpoint lookups. */ + match?: { + /** Metadata or command relationships that matched the query. */ + reasons: string[]; + /** Commands matched by this CLI query, not every caller of the endpoint. */ + relatedCommands: { command: string; note?: string }[]; + }; +}; + /** A single API endpoint entry from the generated schema */ export type ApiEndpoint = { /** SDK function name from @sentry/api (empty string if unmapped) */ @@ -18,10 +57,16 @@ export type ApiEndpoint = { path: string; /** Human-readable description of the endpoint */ description: string; + /** Short endpoint description from OpenAPI. */ + summary?: string; + /** OpenAPI categories available for discovery. */ + tags?: string[]; /** Path parameter names extracted from the URL template */ pathParams: string[]; /** Query parameter names from the OpenAPI spec */ queryParams: string[]; + /** Searchable parameter details, alongside the compatible name-only list. */ + queryParameters?: ApiQueryParameter[]; /** Whether the endpoint is deprecated */ deprecated: boolean; /** Resource group name derived from URL (e.g., "projects", "issues") */ @@ -88,10 +133,11 @@ export function parseEndpointQuery(query: string): ParsedEndpointQuery { export function findEndpointsByPath( path: string, method?: string, + catalog: readonly ApiEndpoint[] = schema, ): ApiEndpoint[] { const methodUpper = method?.toUpperCase(); const normalized = normalizeApiPath(path); - const candidates = schema.filter((endpoint) => { + const candidates = catalog.filter((endpoint) => { if (methodUpper && endpoint.method !== methodUpper) { return false; } @@ -127,14 +173,17 @@ export function findEndpointsByPath( * Find endpoints by an exact SDK function name or OpenAPI operation ID. * * Matching is case-insensitive but deliberately not fuzzy: partial operation - * names can match many unrelated endpoints and should use `--search` instead. + * names can match many unrelated endpoints and are handled by discovery. */ -export function findEndpointsByIdentifier(identifier: string): ApiEndpoint[] { +export function findEndpointsByIdentifier( + identifier: string, + catalog: readonly ApiEndpoint[] = schema, +): ApiEndpoint[] { const normalized = identifier.trim().toLowerCase(); if (!normalized) { return []; } - return schema.filter( + return catalog.filter( (endpoint) => endpoint.fn.toLowerCase() === normalized || endpoint.operationId.toLowerCase() === normalized, @@ -153,69 +202,164 @@ export function getEndpointsByResource(resource: string): ApiEndpoint[] { return schema.filter((e) => e.resource.toLowerCase() === lower); } -/** - * Get a specific endpoint by resource + operationId substring (case-insensitive). - * Prefers operationIds that start with the query over substring matches. - */ -export function getEndpoint( - resource: string, - operationQuery: string, -): ApiEndpoint | undefined { - const lowerResource = resource.toLowerCase(); - const lowerQuery = operationQuery.toLowerCase(); - - const candidates = schema.filter( - (e) => e.resource.toLowerCase() === lowerResource, - ); +const CAMEL_CASE_BOUNDARY = /([a-z0-9])([A-Z])/g; +const WORD_SEPARATOR = /[^a-z0-9]+/; +const PARAM_PLACEHOLDER = /\{[^}]+\}/g; - // Priority 1: operationId starts with the query (e.g., "list" → "List an Org's Issues") - const startMatch = candidates.find( - (e) => - e.operationId.toLowerCase().startsWith(lowerQuery) || - e.fn.toLowerCase().startsWith(lowerQuery), - ); - if (startMatch) { - return startMatch; +/** Conservative English plurals for discovery only, never endpoint identity. */ +function normalizeWord(word: string): string { + if (word.length > 4 && word.endsWith("ies")) { + return `${word.slice(0, -3)}y`; + } + if ( + word.length > 3 && + word.endsWith("s") && + !word.endsWith("ss") && + !word.endsWith("us") && + !word.endsWith("is") + ) { + return word.slice(0, -1); } + return word; +} - // Priority 2: substring match anywhere in operationId or fn - return candidates.find( - (e) => - e.operationId.toLowerCase().includes(lowerQuery) || - e.fn.toLowerCase().includes(lowerQuery), +function words(text: string): Set { + return new Set( + text + .replace(CAMEL_CASE_BOUNDARY, "$1 $2") + .toLowerCase() + .split(WORD_SEPARATOR) + .filter(Boolean) + .map(normalizeWord), ); } +type SearchField = { text: string; reason: string; weight: number }; + +function searchFields(endpoint: ApiEndpoint): SearchField[] { + const fields: SearchField[] = [ + { + text: endpoint.resource, + reason: `API resource: ${endpoint.resource}`, + weight: 80, + }, + { text: endpoint.summary ?? "", reason: "Operation summary", weight: 60 }, + { + text: `${endpoint.fn} ${endpoint.operationId}`, + reason: "Operation name", + weight: 40, + }, + { + text: endpoint.path.replace(PARAM_PLACEHOLDER, ""), + reason: "API path", + weight: 30, + }, + { text: endpoint.tags?.join(" ") ?? "", reason: "API tags", weight: 20 }, + { text: endpoint.description, reason: "Operation description", weight: 10 }, + ]; + for (const parameter of endpoint.queryParameters ?? []) { + fields.push({ + text: parameter.name, + reason: `Query parameter: ${parameter.name}`, + weight: 20, + }); + for (const value of parameter.enum ?? []) { + fields.push({ + text: String(value), + reason: `Query parameter: ${parameter.name}=${String(value)}`, + weight: 100, + }); + } + if (parameter.description) { + fields.push({ + text: parameter.description, + reason: `Query parameter description: ${parameter.name}`, + weight: 10, + }); + } + } + return fields.sort((a, b) => b.weight - a.weight); +} + +function matchEndpoint( + endpoint: ApiEndpoint, + tokens: Set, + references: readonly CommandApiReference[], +): { endpoint: SchemaEndpoint; score: number } | undefined { + const relatedCommands = references + .filter((ref) => ref.fn === endpoint.fn) + .map(({ command, note }) => ({ command, ...(note ? { note } : {}) })); + const scores = new Map(); + const reasons: string[] = []; + for (const field of searchFields(endpoint)) { + const fieldWords = words(field.text); + const matches = [...tokens].filter((token) => fieldWords.has(token)); + if (matches.length === 0) { + continue; + } + for (const token of matches) { + scores.set(token, Math.max(scores.get(token) ?? 0, field.weight)); + } + reasons.push(field.reason); + } + if (relatedCommands.length === 0 && scores.size !== tokens.size) { + return; + } + return { + endpoint: { ...endpoint, match: { reasons, relatedCommands } }, + score: [...scores.values()].reduce( + (sum, value) => sum + value, + relatedCommands.length > 0 ? 1000 : 0, + ), + }; +} + /** - * Search endpoints by query string. - * Path-shaped input (`GET /api/0/...`, `/api/0/...`, or a Sentry API URL) - * uses path matching. Anything else is a case-insensitive substring of - * fn, path, description, resource, or operationId. + * Discover API capabilities, retaining why each candidate matched. All query + * words must match, unless the CLI explicitly declares a related operation. + * References must be scoped to this query by getCommandApiReferences; they + * establish a semantic match even when the API uses different vocabulary. + * Exact identifiers and paths keep their existing, unannotated JSON shape. */ -export function searchEndpoints(query: string): ApiEndpoint[] { +export function searchEndpoints( + query: string, + catalog: readonly ApiEndpoint[] = schema, + references: readonly CommandApiReference[] = [], +): SchemaEndpoint[] { const parsed = parseEndpointQuery(query); if (parsed.path) { - return findEndpointsByPath(parsed.path, parsed.method); + return findEndpointsByPath(parsed.path, parsed.method, catalog); } - - const lower = parsed.text.toLowerCase(); - if (!lower) { + const exact = findEndpointsByIdentifier(parsed.text, catalog).filter( + (endpoint) => !parsed.method || endpoint.method === parsed.method, + ); + if (exact.length > 0) { + return exact; + } + const tokens = words(parsed.text); + if (tokens.size === 0) { return []; } - - const matches = schema.filter( - (e) => - e.fn.toLowerCase().includes(lower) || - e.path.toLowerCase().includes(lower) || - e.description.toLowerCase().includes(lower) || - e.resource.toLowerCase().includes(lower) || - e.operationId.toLowerCase().includes(lower), - ); - if (!parsed.method) { - return matches; + const matches: { endpoint: SchemaEndpoint; score: number }[] = []; + for (const endpoint of catalog) { + if (parsed.method && endpoint.method !== parsed.method) { + continue; + } + const match = matchEndpoint(endpoint, tokens, references); + if (match) { + matches.push(match); + } } - const methodMatches = matches.filter((e) => e.method === parsed.method); - return methodMatches.length > 0 ? methodMatches : matches; + // A stable tie-breaker prevents catalog insertion order from choosing an API. + matches.sort( + (a, b) => + Number(a.endpoint.deprecated) - Number(b.endpoint.deprecated) || + b.score - a.score || + a.endpoint.path.localeCompare(b.endpoint.path) || + a.endpoint.method.localeCompare(b.endpoint.method) || + a.endpoint.fn.localeCompare(b.endpoint.fn), + ); + return matches.map((match) => match.endpoint); } function extractApiPath(input: string): string | undefined { diff --git a/packages/cli/src/lib/api/alerts.ts b/packages/cli/src/lib/api/alerts.ts index 26591fc4b..2e8cb5d85 100644 --- a/packages/cli/src/lib/api/alerts.ts +++ b/packages/cli/src/lib/api/alerts.ts @@ -29,6 +29,7 @@ import { metricAlertDatasetName, metricAlertSource, } from "../../types/metric-alert.js"; +import type { ApiReference } from "../api-schema.js"; import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { @@ -393,8 +394,34 @@ export async function getIssueAlertRule( // (create/update/delete) also target the detectors write API — the flat CLI // input is translated into the nested detector body by `buildDetectorBody`. +/** Detector kind managed by the metric alert commands. */ +const METRIC_DETECTOR_TYPE = "metric_issue"; /** Search query that limits the detectors endpoint to metric alert rules. */ -const METRIC_DETECTOR_QUERY = "type:metric_issue"; +const METRIC_DETECTOR_QUERY = `type:${METRIC_DETECTOR_TYPE}`; + +/** API references shared by metric alert commands; detectors also serve other products. */ +export const METRIC_ALERT_API = { + list: { + fn: "listOrganizationDetectors", + note: `Filters detectors with query=${METRIC_DETECTOR_QUERY}.`, + }, + get: { + fn: "getOrganizationDetector", + note: `The CLI accepts ${METRIC_DETECTOR_TYPE} detectors and presents them as metric alert rules.`, + }, + create: { + fn: "createOrganizationProjectDetector", + note: `Creates a ${METRIC_DETECTOR_TYPE} detector from the CLI metric alert configuration.`, + }, + update: { + fn: "updateOrganizationDetector", + note: "The CLI merges changes into the existing metric alert configuration before updating the detector.", + }, + delete: { + fn: "deleteOrganizationDetector", + note: `The CLI resolves a ${METRIC_DETECTOR_TYPE} detector before deleting it.`, + }, +} satisfies Record; /** * List metric alert rules for an organization with cursor-based pagination. @@ -485,7 +512,7 @@ async function getMetricDetector( result, `Failed to get metric alert rule '${ruleId}'`, ); - if (data.type !== undefined && data.type !== "metric_issue") { + if (data.type !== undefined && data.type !== METRIC_DETECTOR_TYPE) { throw new ApiError( `Metric alert rule '${ruleId}' not found`, 404, @@ -705,7 +732,7 @@ function buildDetectorBody( !(triggers && !isDetectorConditionList(triggers)); const detectorBody: Record = { name: typeof body.name === "string" ? body.name : "", - type: "metric_issue", + type: METRIC_DETECTOR_TYPE, dataSources: [buildDetectorDataSource(body)], config: keepStoredConfig ? body.detectorConfig diff --git a/packages/cli/src/lib/api/logs.ts b/packages/cli/src/lib/api/logs.ts index 2d192387e..b1f0c22ce 100644 --- a/packages/cli/src/lib/api/logs.ts +++ b/packages/cli/src/lib/api/logs.ts @@ -19,6 +19,7 @@ import { type TraceLog, TraceLogsResponseSchema, } from "../../types/index.js"; +import type { ApiReference } from "../api-schema.js"; import { ApiError } from "../errors.js"; import { decodeUuidV7Timestamp } from "../hex-id.js"; import { resolveOrgRegion } from "../region.js"; @@ -35,6 +36,14 @@ import { } from "./infrastructure.js"; import { getTraceItemDetail } from "./traces.js"; +const LOG_DATASET = "logs" as const; + +/** Shared API discovery context for log listing and item lookup. */ +export const LOGS_API = { + fn: "listOrganizationEvents", + note: `Uses dataset=${LOG_DATASET}; the CLI adds fields and filters to list or look up log entries.`, +} satisfies ApiReference; + /** Sort direction for log queries: newest-first or oldest-first. */ export type LogSortDirection = "newest" | "oldest"; @@ -206,7 +215,7 @@ export async function listLogs( ...config, path: { organization_id_or_slug: orgSlug }, query: { - dataset: "logs", + dataset: LOG_DATASET, field: fields, project: numericProjectId === undefined ? undefined : [numericProjectId], @@ -336,7 +345,7 @@ async function getLogsBatch( ) { // `sampling` is supported by the backend but absent from the SDK's query type. const queryParams = { - dataset: "logs" as const, + dataset: LOG_DATASET, field: fields, project: numericProjectId === undefined ? undefined : [numericProjectId], query: `${projectFilter}sentry.item_id:[${ids.join(",")}]`, diff --git a/packages/cli/src/lib/api/traces.ts b/packages/cli/src/lib/api/traces.ts index 97da21964..6c6cb2963 100644 --- a/packages/cli/src/lib/api/traces.ts +++ b/packages/cli/src/lib/api/traces.ts @@ -23,6 +23,7 @@ import { // Re-export so existing callers (api-client.ts, formatters/trace.ts) don't need to change. export type { TraceItemAttribute, TraceItemDetail } from "../../types/index.js"; +import type { ApiReference } from "../api-schema.js"; import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; import { isAllDigits } from "../utils.js"; @@ -37,6 +38,21 @@ import { const log = logger.withTag("api.traces"); +const TRANSACTION_DATASET = "spans"; +const TRANSACTION_QUERY = "is_transaction:true"; + +/** Trace list queries root spans rather than a dedicated traces collection. */ +export const LIST_TRANSACTIONS_API = { + fn: "listOrganizationEvents", + note: `Uses dataset=${TRANSACTION_DATASET} with ${TRANSACTION_QUERY}; the CLI resolves projects and paginates.`, +} satisfies ApiReference; + +/** Main trace view API; full span attributes may require additional requests. */ +export const TRACE_DETAILS_API = { + fn: "getOrganizationTrace", + note: "Retrieves the trace tree; the CLI resolves context and may fetch additional span attributes.", +} satisfies ApiReference; + // --------------------------------------------------------------------------- // Trace item (span) detail types // --------------------------------------------------------------------------- @@ -454,7 +470,7 @@ async function fetchTransactionsPage( ); const projectFilter = numericProjectId === undefined ? `project:${projectSlug}` : ""; - const fullQuery = ["is_transaction:true", projectFilter, options.query] + const fullQuery = [TRANSACTION_QUERY, projectFilter, options.query] .filter(Boolean) .join(" "); @@ -464,7 +480,7 @@ async function fetchTransactionsPage( `/organizations/${orgSlug}/events/`, { params: { - dataset: "spans", + dataset: TRANSACTION_DATASET, field: TRANSACTION_FIELDS, project: numericProjectId === undefined diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 88535c601..ea7c194e8 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -37,6 +37,7 @@ import { numberParser as stricliNumberParser, } from "@stricli/core"; import type { Writer } from "../types/index.js"; +import type { ApiReference } from "./api-schema.js"; import { appendCacheHint } from "./cache-hint.js"; import { getAuthConfig, hasUsableStoredAccount } from "./db/auth.js"; import { getEnv } from "./env.js"; @@ -112,6 +113,8 @@ export type CommandExample = { export type CommandDocumentation = StricliBuilderArgs["docs"] & { readonly examples?: readonly CommandExample[]; + /** Related API operations, with context rather than equivalence claims. */ + readonly relatedApi?: readonly ApiReference[]; }; /** @@ -924,6 +927,10 @@ export function buildCommand< if (builderArgs.destructive) { (cmd as unknown as Record).__destructive = true; } + if (builderArgs.docs.relatedApi?.length) { + (cmd as unknown as Record).__relatedApi = + builderArgs.docs.relatedApi; + } // Attach the JSON schema to the built command as a non-standard property. // introspect.ts reads this to populate CommandInfo.jsonFields for help diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index 3c8e59d12..1e95c2efb 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -18,6 +18,11 @@ import { ORG_PROJECT_TARGET, ORG_TARGET, } from "./completion-target.js"; +import type { + ApiEndpoint, + ApiReference, + CommandApiReference, +} from "./api-schema.js"; import { extractSchemaFields, type SchemaFieldInfo, @@ -33,6 +38,7 @@ export type RouteMapEntry = { name: { original: string }; target: RouteTarget; hidden: boolean; + aliases?: readonly string[]; }; /** A routing target is either a route map (group) or a command */ @@ -70,6 +76,8 @@ export type Command = { __examples?: readonly CommandExample[]; /** Set by `buildCommand({ destructive: true })` and `buildDeleteCommand`. */ __destructive?: boolean; + /** Related API metadata retained by the command builder. */ + __relatedApi?: readonly ApiReference[]; }; /** Positional parameter definitions — either fixed-length tuple or variadic array */ @@ -128,6 +136,7 @@ export type CommandInfo = { jsonFields?: SchemaFieldInfo[]; /** Present (and `true`) when the command is destructive or irreversible */ destructive?: true; + relatedApi?: readonly ApiReference[]; }; /** Extracted metadata for a single flag */ @@ -325,6 +334,7 @@ export function buildCommandInfo(cmd: Command, path: string): CommandInfo { ), jsonFields: jsonFields?.length ? jsonFields : undefined, ...(cmd.__destructive ? { destructive: true as const } : {}), + ...(cmd.__relatedApi?.length ? { relatedApi: cmd.__relatedApi } : {}), }; } @@ -482,26 +492,33 @@ export function extractCompletionTargets( return targets; } -/** Matches the "sentry " prefix at the start of a command path. */ -const SENTRY_PREFIX_RE = /^sentry /; - /** Maximum number of fuzzy suggestions to include in an UnresolvedPath. */ const MAX_SUGGESTIONS = 3; +const COMMAND_PATH_SEPARATOR = /\s+/; + +/** Find canonical public paths so hidden aliases cannot expose internal commands. */ +function publicTargetPaths( + routeMap: RouteMap, + prefix: readonly string[] = [], + paths = new Map(), +): Map { + for (const entry of routeMap.getAllEntries()) { + if (entry.hidden || paths.has(entry.target)) { + continue; + } + const path = [...prefix, entry.name.original]; + paths.set(entry.target, path); + if (isRouteMap(entry.target)) { + publicTargetPaths(entry.target, path, paths); + } + } + return paths; +} /** - * Resolve a command path through the route tree. - * - * Navigates the tree using the provided path segments: - * - Single segment (e.g. ["issue"]) → returns the route group if it's a RouteMap, - * or the command if it's a standalone command - * - Two segments (e.g. ["issue", "list"]) → returns the specific subcommand - * - * When a segment doesn't match, returns an {@link UnresolvedPath} with - * fuzzy-matched suggestions from the available routes at that level. - * - * @param routeMap - Top-level Stricli route map - * @param path - Command path segments (e.g. ["issue", "list"]) - * @returns Resolved command/group, unresolved with suggestions, or null for empty paths + * Resolve a full command path using the registered route names and aliases. + * Hidden aliases are accepted only when their target also has a public path. + * Results always use the canonical public spelling; no command is executed. */ export function resolveCommandPath( routeMap: RouteMap, @@ -511,85 +528,100 @@ export function resolveCommandPath( return null; } - const first = path[0]; - const rest = path.slice(1); + const publicPaths = publicTargetPaths(routeMap); + let target: RouteTarget = routeMap; + let canonicalPath: string[] = []; - // length === 0 is handled above; this guard helps TS narrow the type - if (first === undefined) { - return null; - } - - // Collect visible entries once — used for both exact match and fuzzy fallback - const visibleEntries = routeMap.getAllEntries().filter((e) => !e.hidden); - const entry = visibleEntries.find((e) => e.name.original === first); - - if (!entry) { - const names = visibleEntries.map((e) => e.name.original); - return { - kind: "unresolved", - input: first, - suggestions: fuzzyMatch(first, names, { maxResults: MAX_SUGGESTIONS }), - }; - } - - const target = entry.target; - - // No more path segments — return what we found - if (rest.length === 0) { - if (isRouteMap(target)) { - return { - kind: "group", - info: { - name: entry.name.original, - brief: target.brief, - commands: extractRouteGroupCommands(target, entry.name.original), - }, - }; + for (const segment of path) { + if (!isRouteMap(target)) { + return null; } - if (isCommand(target)) { + const entries: RouteMapEntry[] = target + .getAllEntries() + .filter( + (candidate) => !candidate.hidden || publicPaths.has(candidate.target), + ); + const entry: RouteMapEntry | undefined = + entries.find((candidate) => candidate.name.original === segment) ?? + entries.find((candidate) => candidate.aliases?.includes(segment)); + if (!entry) { return { - kind: "command", - info: buildCommandInfo(target, `sentry ${entry.name.original}`), + kind: "unresolved", + input: segment, + suggestions: fuzzyMatch( + segment, + entries + .filter((candidate) => !candidate.hidden) + .map((candidate) => candidate.name.original), + { maxResults: MAX_SUGGESTIONS }, + ), }; } - return null; + canonicalPath = entry.hidden + ? [...(publicPaths.get(entry.target) ?? [])] + : [...canonicalPath, entry.name.original]; + target = entry.target; } - // More path segments — must be a route map to navigate deeper - if (!isRouteMap(target)) { - return null; - } - - // Recurse into the sub-route map with remaining path segments - const subResult = resolveCommandPath(target, rest); - if (!subResult) { - return null; - } - - // Prepend the parent route segment to all paths in the result - const parentPrefix = entry.name.original; - const prependPrefix = (p: string) => - p.replace(SENTRY_PREFIX_RE, `sentry ${parentPrefix} `); - - if (subResult.kind === "command") { - return { - kind: "command", - info: { ...subResult.info, path: prependPrefix(subResult.info.path) }, - }; - } - if (subResult.kind === "group") { + const name = canonicalPath.join(" "); + if (isRouteMap(target)) { return { kind: "group", info: { - ...subResult.info, - name: `${parentPrefix} ${subResult.info.name}`, - commands: subResult.info.commands.map((cmd) => ({ - ...cmd, - path: prependPrefix(cmd.path), - })), + name, + brief: target.brief, + commands: extractRouteGroupCommands(target, name), }, }; } + if (isCommand(target)) { + return { + kind: "command", + info: buildCommandInfo(target, `sentry ${name}`), + }; + } + return null; +} - return subResult; +/** Collect related APIs for one exact command path or all commands in a group. */ +export function getCommandApiReferences( + routeMap: RouteMap, + query: string, +): CommandApiReference[] { + const path = query.trim().split(COMMAND_PATH_SEPARATOR).filter(Boolean); + const resolved = resolveCommandPath(routeMap, path); + if (!resolved || resolved.kind === "unresolved") { + return []; + } + const commands = + resolved.kind === "command" ? [resolved.info] : resolved.info.commands; + return commands.flatMap((command) => + (command.relatedApi ?? []).map((reference) => ({ + ...reference, + command: command.path, + })), + ); +} + +/** Reject stale or ambiguous references before generating command documentation. */ +export function validateCommandApiReferences( + routeMap: RouteMap, + endpoints: readonly Pick[], +): void { + const counts = new Map(); + for (const endpoint of endpoints) { + counts.set(endpoint.fn, (counts.get(endpoint.fn) ?? 0) + 1); + } + for (const route of extractAllRoutes(routeMap)) { + for (const command of route.commands) { + for (const reference of command.relatedApi ?? []) { + const count = counts.get(reference.fn) ?? 0; + if (!reference.fn || count !== 1) { + throw new Error( + `${command.path}: related API "${reference.fn}" matches ${count} schema operations; expected exactly one`, + ); + } + } + } + } } diff --git a/packages/cli/test/commands/schema-path.test.ts b/packages/cli/test/commands/schema-path.test.ts index 4b118d817..5ffa4012e 100644 --- a/packages/cli/test/commands/schema-path.test.ts +++ b/packages/cli/test/commands/schema-path.test.ts @@ -5,7 +5,8 @@ import { describe, expect, test, vi } from "vitest"; import { schemaCommand } from "../../src/commands/schema.js"; import type { SentryContext } from "../../src/context.js"; -import { ResolutionError } from "../../src/lib/errors.js"; +import { getAllEndpoints } from "../../src/lib/api-schema.js"; +import { OutputError, ResolutionError } from "../../src/lib/errors.js"; const ORG_ISSUES = "/api/0/organizations/{organization_id_or_slug}/issues/"; const SKILL_QUERY = `GET ${ORG_ISSUES}`; @@ -35,24 +36,173 @@ function stdoutOf(stdoutWrite: ReturnType): string { } describe("sentry schema path lookup", () => { - test("METHOD + path returns that endpoint", async () => { + test.each([ + ["listOrganizationIssues"], + ["issues", "listOrganizationIssues"], + [SKILL_QUERY], + ["GET", ORG_ISSUES], + ])("exact lookup preserves the endpoint object for %j", async (...args) => { const { context, stdoutWrite } = createMockContext(); const func = await schemaCommand.loader(); - await func.call(context, { json: true, all: false }, SKILL_QUERY); + await func.call(context, { json: true, all: false }, ...args); + expect(JSON.parse(stdoutOf(stdoutWrite))).toEqual( + getAllEndpoints().find( + (endpoint) => endpoint.fn === "listOrganizationIssues", + ), + ); + }); - const parsed = JSON.parse(stdoutOf(stdoutWrite)); - expect(parsed.fn).toBe("listOrganizationIssues"); - expect(parsed.method).toBe("GET"); - expect(parsed.path).toBe(ORG_ISSUES); + test("--all remains an unannotated flat array", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await schemaCommand.loader(); + await func.call(context, { json: true, all: true }); + expect(JSON.parse(stdoutOf(stdoutWrite))).toEqual(getAllEndpoints()); }); - test("unquoted METHOD and path as two positionals also match", async () => { + test.each([ + { + args: ["log", "list"], + fn: "listOrganizationEvents", + command: "sentry log list", + note: "dataset=logs", + }, + { + args: ["logs"], + fn: "listOrganizationEvents", + command: "sentry log list", + note: "dataset=logs", + }, + { + args: ["trace", "ls"], + fn: "listOrganizationEvents", + command: "sentry trace list", + note: "is_transaction:true", + }, + { + args: ["trace", "view"], + fn: "getOrganizationTrace", + command: "sentry trace view", + }, + { + args: ["alert", "metrics", "view"], + fn: "getOrganizationDetector", + command: "sentry alert metrics view", + }, + ])("discovers the API used by $args", async ({ args, fn, command, note }) => { const { context, stdoutWrite } = createMockContext(); const func = await schemaCommand.loader(); - await func.call(context, { json: true, all: false }, "GET", ORG_ISSUES); + await func.call(context, { json: true, all: false }, ...args); + const result = JSON.parse(stdoutOf(stdoutWrite)); + expect(Array.isArray(result)).toBe(true); + const endpoint = result.find( + (candidate: { fn: string }) => candidate.fn === fn, + ); + expect(endpoint?.match.relatedCommands).toContainEqual( + expect.objectContaining({ command }), + ); + if (note) { + expect( + endpoint.match.relatedCommands.find( + (candidate: { command: string }) => candidate.command === command, + ).note, + ).toContain(note); + } + }); - const parsed = JSON.parse(stdoutOf(stdoutWrite)); - expect(parsed.fn).toBe("listOrganizationIssues"); + test("--search and positionals share discovery, including a complete nested path", async () => { + const positional = createMockContext(); + const search = createMockContext(); + const func = await schemaCommand.loader(); + await func.call( + positional.context, + { json: true, all: false }, + "alert", + "metrics", + "view", + ); + await func.call( + search.context, + { json: true, all: false, search: "alert" }, + "metrics", + "view", + ); + expect(JSON.parse(stdoutOf(search.stdoutWrite))).toEqual( + JSON.parse(stdoutOf(positional.stdoutWrite)), + ); + }); + + test.each([ + { + args: ["trace", "ls"], + search: false, + fn: "listOrganizationEvents", + command: "sentry trace list", + }, + { + args: ["alert", "metrics", "view"], + search: true, + fn: "getOrganizationDetector", + command: "sentry alert metrics view", + }, + ])( + "HTTP filters preserve CLI discovery for $args", + async ({ args, search, fn, command }) => { + const { context, stdoutWrite } = createMockContext(); + const func = await schemaCommand.loader(); + await func.call( + context, + { json: true, all: false, search: search ? "GET" : undefined }, + ...(search ? args : ["GET", ...args]), + ); + expect(JSON.parse(stdoutOf(stdoutWrite))).toContainEqual( + expect.objectContaining({ + fn, + method: "GET", + match: expect.objectContaining({ + relatedCommands: expect.arrayContaining([ + expect.objectContaining({ command }), + ]), + }), + }), + ); + + const incompatible = createMockContext(); + await expect( + func.call( + incompatible.context, + { json: true, all: false, search: search ? "POST" : undefined }, + ...(search ? args : ["POST", ...args]), + ), + ).rejects.toBeInstanceOf(search ? OutputError : ResolutionError); + expect(stdoutOf(incompatible.stdoutWrite).trim()).toBe( + search ? "[]" : "", + ); + }, + ); + + test("renders the selector and related CLI command in human output", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await schemaCommand.loader(); + await func.call(context, { json: false, all: false }, "logs"); + const output = stdoutOf(stdoutWrite); + expect(output).toContain("dataset=logs"); + expect(output).toContain("sentry log list"); + }); + + test("discovery supports existing --fields filtering", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await schemaCommand.loader(); + await func.call( + context, + { json: true, all: false, fields: ["fn"] }, + "log", + "list", + ); + const result = JSON.parse(stdoutOf(stdoutWrite)); + expect(result).toContainEqual({ fn: "listOrganizationEvents" }); + expect( + result.every((endpoint: object) => Object.keys(endpoint).join() === "fn"), + ).toBe(true); }); test("path without METHOD lists methods on that URL", async () => { @@ -126,7 +276,7 @@ describe("sentry schema path lookup", () => { expect(message).toContain(`sentry schema "GET ${ORG_ISSUES}"`); }); - test("resource lookup is unchanged", async () => { + test("resource discovery preserves the endpoint array JSON shape", async () => { const { context, stdoutWrite } = createMockContext(); const func = await schemaCommand.loader(); await func.call(context, { json: true, all: false }, "issues"); @@ -135,7 +285,7 @@ describe("sentry schema path lookup", () => { expect(Array.isArray(parsed)).toBe(true); expect(parsed.length).toBeGreaterThan(3); expect( - parsed.every((e: { resource: string }) => e.resource === "issues"), + parsed.some((e: { fn: string }) => e.fn === "listOrganizationIssues"), ).toBe(true); }); }); diff --git a/packages/cli/test/commands/schema.test.ts b/packages/cli/test/commands/schema.test.ts index bdb1cdb44..c46d2cc66 100644 --- a/packages/cli/test/commands/schema.test.ts +++ b/packages/cli/test/commands/schema.test.ts @@ -11,14 +11,15 @@ import { resolveResourceQuery } from "../../src/commands/schema.js"; import { OutputError, ResolutionError } from "../../src/lib/errors.js"; describe("resolveResourceQuery", () => { - test("returns endpoints for a known resource", () => { + test("discovers endpoints for a known resource", () => { const result = resolveResourceQuery("issues"); expect(result.kind).toBe("endpoints"); if (result.kind === "endpoints") { expect(result.endpoints.length).toBeGreaterThan(0); - for (const ep of result.endpoints) { - expect(ep.resource).toBe("issues"); - } + expect( + result.endpoints.some((ep) => ep.fn === "listOrganizationIssues"), + ).toBe(true); + expect(result.endpoints.every((ep) => ep.match)).toBe(true); } }); @@ -31,10 +32,30 @@ describe("resolveResourceQuery", () => { } }); - test("does not interpret a partial operation identifier as exact", () => { - expect(() => resolveResourceQuery("OrganizationEvents")).toThrow( - ResolutionError, - ); + test("treats partial operation names as discovery, never exact selection", () => { + const result = resolveResourceQuery("OrganizationEvents"); + expect(result.kind).toBe("endpoints"); + if (result.kind === "endpoints") { + expect( + result.endpoints.some((ep) => ep.fn === "listOrganizationEvents"), + ).toBe(true); + } + }); + + test("returns every list candidate instead of selecting a deprecated endpoint", () => { + const result = resolveResourceQuery("issues", "list"); + expect(result.kind).toBe("endpoints"); + if (result.kind === "endpoints") { + expect( + result.endpoints.find((ep) => ep.fn === "listOrganizationIssues"), + ).toBeDefined(); + expect( + result.endpoints.find( + (ep) => ep.deprecated && ep.resource === "issues", + ), + ).toBeDefined(); + expect(result.endpoints[0]?.deprecated).toBe(false); + } }); test("no-match resource throws ResolutionError instead of listing everything", () => { let thrown: unknown; diff --git a/packages/cli/test/lib/api-schema-discovery.test.ts b/packages/cli/test/lib/api-schema-discovery.test.ts new file mode 100644 index 000000000..2bed60e48 --- /dev/null +++ b/packages/cli/test/lib/api-schema-discovery.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "vitest"; +import { + type ApiEndpoint, + findEndpointsByIdentifier, + findEndpointsByPath, + searchEndpoints, +} from "../../src/lib/api-schema.js"; + +// Representative OpenAPI operations: logs are both an Explore dataset and a +// feature-flag resource. A lexical resource hit must not hide the dataset. +const events: ApiEndpoint = { + fn: "listOrganizationEvents", + operationId: "listOrganizationEvents", + summary: "Query Explore Events in Table Format", + method: "GET", + path: "/api/0/organizations/{org}/events/", + resource: "events", + description: "Retrieves explore data for a given organization.", + tags: ["Explore"], + pathParams: ["org"], + queryParams: ["dataset"], + queryParameters: [ + { + name: "dataset", + required: true, + type: "string", + enum: ["logs", "spans"], + }, + ], + deprecated: false, +}; +const flagLogs: ApiEndpoint = { + fn: "listOrganizationFlagLogs", + operationId: "listOrganizationFlagLogs", + method: "GET", + path: "/api/0/organizations/{org}/flags/logs/", + resource: "logs", + description: "List an organization's flag logs.", + pathParams: ["org"], + queryParams: [], + deprecated: false, +}; + +describe("schema discovery", () => { + test("finds log capabilities alongside the exact flags resource and explains the selector", () => { + const result = searchEndpoints("logs", [flagLogs, events]); + expect(result.map((endpoint) => endpoint.fn)).toEqual([ + "listOrganizationEvents", + "listOrganizationFlagLogs", + ]); + expect(result[0]?.match?.reasons).toContain( + "Query parameter: dataset=logs", + ); + expect(result[1]?.match?.reasons).toContain("API resource: logs"); + expect(searchEndpoints("log", [flagLogs, events])).toEqual(result); + }); + + test("a new API dataset becomes searchable without a vocabulary mapping", () => { + expect(searchEndpoints("uptime_results", [events])).toEqual([]); + const grown: ApiEndpoint = { + ...events, + queryParameters: [ + { + name: "dataset", + required: true, + enum: ["logs", "spans", "uptime_results"], + }, + ], + }; + expect( + searchEndpoints("uptime_results", [grown])[0]?.match?.reasons, + ).toContain("Query parameter: dataset=uptime_results"); + }); + + test("adding or reordering list operations keeps all candidates and exact lookups stable", () => { + const issues: ApiEndpoint = { + ...events, + fn: "listOrganizationIssues", + operationId: "listOrganizationIssues", + resource: "issues", + path: "/api/0/organizations/{org}/issues/", + }; + const projectIssues: ApiEndpoint = { + ...issues, + fn: "deprecatedListProjectIssues", + operationId: "List a Project's Issues", + path: "/api/0/projects/{org}/{project}/issues/", + deprecated: true, + }; + const added: ApiEndpoint = { + ...issues, + fn: "listTeamIssues", + operationId: "listTeamIssues", + path: "/api/0/teams/{team}/issues/", + }; + const original = searchEndpoints("issues list", [projectIssues, issues]); + expect(original.map((endpoint) => endpoint.fn)).toEqual([ + issues.fn, + projectIssues.fn, + ]); + const grown = [projectIssues, added, issues]; + const result = searchEndpoints("issue list", grown); + expect(result.map((endpoint) => endpoint.fn)).toEqual([ + issues.fn, + added.fn, + projectIssues.fn, + ]); + expect(searchEndpoints("issues list", [...grown].reverse())).toEqual( + result, + ); + expect(findEndpointsByIdentifier(issues.fn, grown)).toEqual([issues]); + expect(findEndpointsByPath(issues.path, "GET", grown)).toEqual([issues]); + }); + + test("preserves both command contexts when they use the same API", () => { + const result = searchEndpoints( + "observability", + [events], + [ + { + command: "sentry log list", + fn: events.fn, + note: "Queries dataset=logs.", + }, + { + command: "sentry trace list", + fn: events.fn, + note: "Queries dataset=spans with is_transaction:true.", + }, + ], + ); + expect(result).toHaveLength(1); + expect(result[0]?.match?.relatedCommands).toEqual([ + { command: "sentry log list", note: "Queries dataset=logs." }, + { + command: "sentry trace list", + note: "Queries dataset=spans with is_transaction:true.", + }, + ]); + }); + + test("requires every search token and honors an explicit HTTP method", () => { + expect(searchEndpoints("logs nonexistent", [events, flagLogs])).toEqual([]); + expect(searchEndpoints("POST logs", [events, flagLogs])).toEqual([]); + expect(searchEndpoints("GET logs", [events, flagLogs])).toHaveLength(2); + }); + + test("exact path and operation lookup do not acquire discovery metadata", () => { + expect(searchEndpoints(events.fn, [events, flagLogs])).toEqual([events]); + expect(searchEndpoints(`GET ${events.path}`, [events, flagLogs])).toEqual([ + events, + ]); + }); +}); diff --git a/packages/cli/test/lib/api-schema.test.ts b/packages/cli/test/lib/api-schema.test.ts index 25ff58084..f64a49b9e 100644 --- a/packages/cli/test/lib/api-schema.test.ts +++ b/packages/cli/test/lib/api-schema.test.ts @@ -8,7 +8,6 @@ import { findEndpointsByPath, getAllEndpoints, getAllResources, - getEndpoint, getEndpointsByResource, getResourceSummaries, parseEndpointQuery, @@ -84,27 +83,6 @@ describe("getEndpointsByResource", () => { }); }); -describe("getEndpoint", () => { - test("finds specific endpoint by operationId substring", () => { - const ep = getEndpoint("issues", "getOrganizationIssue"); - expect(ep).toBeDefined(); - expect(ep?.resource).toBe("issues"); - expect(ep?.method).toBe("GET"); - expect(ep?.fn).toBe("getOrganizationIssue"); - }); - - test("is case-insensitive", () => { - const ep1 = getEndpoint("issues", "list"); - const ep2 = getEndpoint("Issues", "List"); - expect(ep1).toEqual(ep2); - }); - - test("returns undefined for unknown endpoint", () => { - expect(getEndpoint("issues", "xyznonexistent123")).toBeUndefined(); - expect(getEndpoint("nonexistent", "list")).toBeUndefined(); - }); -}); - describe("parseEndpointQuery", () => { test("parses METHOD + OpenAPI path", () => { const parsed = parseEndpointQuery( diff --git a/packages/cli/test/lib/command-api-references.test.ts b/packages/cli/test/lib/command-api-references.test.ts new file mode 100644 index 000000000..5908e1f51 --- /dev/null +++ b/packages/cli/test/lib/command-api-references.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "vitest"; +import { routes } from "../../src/app.js"; +import { + type ApiReference, + getAllEndpoints, +} from "../../src/lib/api-schema.js"; +import { buildCommand } from "../../src/lib/command.js"; +import { + getCommandApiReferences, + type RouteMap, + validateCommandApiReferences, +} from "../../src/lib/introspect.js"; +import { buildRouteMap } from "../../src/lib/route-map.js"; + +function command(relatedApi?: readonly ApiReference[]) { + return buildCommand({ + docs: { brief: "Example command", relatedApi }, + auth: false, + async *func() { + yield; + }, + }); +} + +function exampleRoutes() { + const list = command([{ fn: "queryEvents", note: "Uses the logs dataset." }]); + const view = command([ + { fn: "queryEvents", note: "Filters by log item ID." }, + ]); + const log = buildRouteMap({ + routes: { list, view }, + aliases: { details: "view" }, + docs: { brief: "Logs" }, + }); + const metrics = buildRouteMap({ + routes: { list: command([{ fn: "listDetectors" }]) }, + docs: { brief: "Metric alerts" }, + }); + return buildRouteMap({ + routes: { + log, + logs: list, + internal: command([{ fn: "notPublished" }]), + alert: buildRouteMap({ + routes: { metrics }, + docs: { brief: "Alerts" }, + }), + future: command([{ fn: "newOperation" }]), + local: command(), + }, + docs: { brief: "CLI", hideRoute: { logs: true, internal: true } }, + }) as unknown as RouteMap; +} + +describe("command API discovery", () => { + test("reads metadata from new public commands and full nested paths", () => { + const tree = exampleRoutes(); + expect(getCommandApiReferences(tree, "future")).toEqual([ + { command: "sentry future", fn: "newOperation" }, + ]); + expect(getCommandApiReferences(tree, "alert metrics list")).toEqual([ + { command: "sentry alert metrics list", fn: "listDetectors" }, + ]); + expect(getCommandApiReferences(tree, "alert metrics")).toEqual( + getCommandApiReferences(tree, "alert"), + ); + }); + + test("resolves registered and hidden public aliases to canonical command paths", () => { + const tree = exampleRoutes(); + const list = getCommandApiReferences(tree, "log list"); + expect(getCommandApiReferences(tree, "log ls")).toEqual(list); + expect(getCommandApiReferences(tree, "logs")).toEqual(list); + expect(getCommandApiReferences(tree, "log details")).toEqual( + getCommandApiReferences(tree, "log show"), + ); + expect(getCommandApiReferences(tree, "log show")[0]?.command).toBe( + "sentry log view", + ); + }); + + test("groups keep each command's context even when they share an API", () => { + expect(getCommandApiReferences(exampleRoutes(), "log")).toEqual([ + { + command: "sentry log list", + fn: "queryEvents", + note: "Uses the logs dataset.", + }, + { + command: "sentry log view", + fn: "queryEvents", + note: "Filters by log item ID.", + }, + ]); + }); + + test("does not expose internal commands or discard unmatched path segments", () => { + const tree = exampleRoutes(); + for (const query of [ + "internal", + "unknown", + "log view extra", + "local", + "", + ]) { + expect(getCommandApiReferences(tree, query)).toEqual([]); + } + }); + + test("rejects missing and ambiguous API references as the catalog changes", () => { + const tree = exampleRoutes(); + const endpoints = [ + { fn: "queryEvents" }, + { fn: "listDetectors" }, + { fn: "newOperation" }, + ]; + expect(() => validateCommandApiReferences(tree, endpoints)).not.toThrow(); + expect(() => + validateCommandApiReferences(tree, endpoints.slice(1)), + ).toThrow( + 'sentry log list: related API "queryEvents" matches 0 schema operations', + ); + expect(() => + validateCommandApiReferences(tree, [...endpoints, { fn: "queryEvents" }]), + ).toThrow( + 'sentry log list: related API "queryEvents" matches 2 schema operations', + ); + }); + + test("published command references resolve against the bundled API catalog", () => { + const tree = routes as unknown as RouteMap; + expect(() => + validateCommandApiReferences(tree, getAllEndpoints()), + ).not.toThrow(); + expect(getCommandApiReferences(tree, "logs")).toEqual( + getCommandApiReferences(tree, "log list"), + ); + expect(getCommandApiReferences(tree, "trace list")).toEqual([ + expect.objectContaining({ + fn: "listOrganizationEvents", + note: expect.stringContaining("is_transaction:true"), + }), + ]); + expect(getCommandApiReferences(tree, "log view")).toEqual([ + expect.objectContaining({ + fn: "listOrganizationEvents", + note: expect.stringContaining("dataset=logs"), + }), + ]); + expect(getCommandApiReferences(tree, "trace show")).toEqual([ + expect.objectContaining({ fn: "getOrganizationTrace" }), + ]); + expect( + getCommandApiReferences(tree, "alert metrics").map((reference) => ({ + command: reference.command, + fn: reference.fn, + })), + ).toEqual([ + { command: "sentry alert metrics list", fn: "listOrganizationDetectors" }, + { command: "sentry alert metrics view", fn: "getOrganizationDetector" }, + { + command: "sentry alert metrics create", + fn: "createOrganizationProjectDetector", + }, + { + command: "sentry alert metrics delete", + fn: "deleteOrganizationDetector", + }, + { command: "sentry alert metrics edit", fn: "getOrganizationDetector" }, + { + command: "sentry alert metrics edit", + fn: "updateOrganizationDetector", + }, + ]); + }); +}); diff --git a/packages/cli/test/script/generate-api-schema.test.ts b/packages/cli/test/script/generate-api-schema.test.ts new file mode 100644 index 000000000..468500e8c --- /dev/null +++ b/packages/cli/test/script/generate-api-schema.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "vitest"; +import { + extractApiEndpoints, + type OpenApiSpec, +} from "../../script/lib/api-schema.js"; + +const EVENTS_PATH = "/api/0/organizations/{organization_id_or_slug}/events/"; + +/** Reduced public OpenAPI Explore operation; no account or event data. */ +function exploreSpec(datasets = ["errors", "logs", "spans"]): OpenApiSpec { + return { + paths: { + [EVENTS_PATH]: { + get: { + operationId: "listOrganizationEvents", + summary: "Query Explore Events in Table Format", + description: "Retrieves explore data for a given organization.", + tags: ["Explore"], + parameters: [ + { + in: "query", + name: "dataset", + required: true, + description: + "Which dataset to query. The chosen dataset determines which fields are queryable.", + schema: { type: "string", enum: datasets }, + }, + { + in: "query", + name: "field", + required: true, + schema: { type: "array" }, + }, + { in: "query", name: "query", schema: { type: "string" } }, + ], + }, + }, + }, + }; +} + +describe("extractApiEndpoints", () => { + test("preserves Explore dataset vocabulary and the existing endpoint fields", () => { + const endpoints = extractApiEndpoints( + exploreSpec(), + new Map([[`GET:${EVENTS_PATH}`, "listOrganizationEvents"]]), + ); + + expect(endpoints).toEqual([ + { + fn: "listOrganizationEvents", + method: "GET", + path: EVENTS_PATH, + resource: "events", + operationId: "listOrganizationEvents", + summary: "Query Explore Events in Table Format", + description: "Retrieves explore data for a given organization.", + tags: ["Explore"], + deprecated: false, + pathParams: ["organization_id_or_slug"], + queryParams: ["dataset", "field", "query"], + queryParameters: [ + { + name: "dataset", + required: true, + description: + "Which dataset to query. The chosen dataset determines which fields are queryable.", + type: "string", + enum: ["errors", "logs", "spans"], + }, + { name: "field", required: true, type: "array" }, + { name: "query", required: false, type: "string" }, + ], + }, + ]); + }); + + test("includes new operations and datasets without adding vocabulary mappings", () => { + const spec = exploreSpec(["errors", "logs", "spans", "uptime_results"]); + // Based on the separate Explore timeseries operation: the API can add it + // before the installed SDK has a mapped function name. + const timeseriesPath = + "/api/0/organizations/{organization_id_or_slug}/events-timeseries/"; + spec.paths[timeseriesPath] = { + get: { + ...spec.paths[EVENTS_PATH]?.get, + operationId: "listOrganizationEventsTimeseries", + summary: "Query Explore Events in Timeseries Format", + }, + }; + + const endpoints = extractApiEndpoints(spec, new Map()); + + expect(endpoints).toHaveLength(2); + for (const endpoint of endpoints) { + expect(endpoint.fn).toBe(""); + expect(endpoint.queryParameters).toContainEqual( + expect.objectContaining({ + name: "dataset", + enum: ["errors", "logs", "spans", "uptime_results"], + }), + ); + } + expect(endpoints).toContainEqual( + expect.objectContaining({ + path: timeseriesPath, + operationId: "listOrganizationEventsTimeseries", + summary: "Query Explore Events in Timeseries Format", + tags: ["Explore"], + }), + ); + }); + + test("inherits path parameters and lets operations replace matching query parameters", () => { + const spec: OpenApiSpec = { + paths: { + [EVENTS_PATH]: { + parameters: [ + { in: "path", name: "organization_id_or_slug", required: true }, + { in: "query", name: "cursor", schema: { type: "string" } }, + { + in: "query", + name: "dataset", + schema: { type: "string", enum: ["errors"] }, + }, + ], + get: { + parameters: [ + { + in: "query", + name: "dataset", + required: true, + schema: { type: "string", enum: ["logs", "spans"] }, + }, + { in: "header", name: "cursor", required: true }, + ], + }, + post: {}, + }, + }, + }; + + const endpoints = extractApiEndpoints(spec, new Map()); + + expect(endpoints.find((endpoint) => endpoint.method === "GET")).toEqual( + expect.objectContaining({ + pathParams: ["organization_id_or_slug"], + queryParams: ["cursor", "dataset"], + queryParameters: [ + { name: "cursor", required: false, type: "string" }, + { + name: "dataset", + required: true, + type: "string", + enum: ["logs", "spans"], + }, + ], + }), + ); + expect(endpoints.find((endpoint) => endpoint.method === "POST")).toEqual( + expect.objectContaining({ + queryParameters: [ + { name: "cursor", required: false, type: "string" }, + { + name: "dataset", + required: false, + type: "string", + enum: ["errors"], + }, + ], + }), + ); + }); + + test("retains repeated query enum values", () => { + const spec: OpenApiSpec = { + paths: { + "/api/0/organizations/{organization_id_or_slug}/stats_v2/": { + get: { + parameters: [ + { + in: "query", + name: "groupBy", + required: true, + schema: { + type: "array", + items: { enum: ["outcome", "category", "reason", "project"] }, + }, + }, + ], + }, + }, + }, + }; + + const [endpoint] = extractApiEndpoints(spec, new Map()); + + expect(endpoint?.queryParameters).toEqual([ + { + name: "groupBy", + required: true, + type: "array", + enum: ["outcome", "category", "reason", "project"], + }, + ]); + }); +});