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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions apps/cli-docs/src/fragments/commands/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,22 @@ When querying the Events API (`/events/` endpoint), valid dataset values are: `s
Each invocation fetches one server page. Repeat the same GET endpoint and filters
with `--cursor next` (`-c next`) to continue, `--cursor prev` to revisit a page,
or `--cursor first` to start again. The shared history expires after five minutes
and is scoped to the request and authenticated identity. Keep the same query
parameters and headers when navigating. The endpoint remains required.
and is scoped to the request, authenticated identity, configured host, and working
directory. Keep the same query parameters and headers when navigating explicitly.

When there is exactly one recent paginated query in that scope, omit the endpoint
and request arguments with `--cursor next`, `prev`, or `first`. The CLI recovers
the original endpoint and filters, including repeated parameters. Detail requests
without pagination do not replace that query. If several paginated queries were
run recently, repeat the endpoint and filters to select one; the CLI does not
choose the most recent query or switch to another when a query reaches its end.

Only successful GETs save a query. The saved URL expires with its cursor history
after five minutes. Explicit headers (`-H`) and request bodies are not saved;
requests that need them must repeat their endpoint and options. Current configured
headers are used again and must match the saved query's configuration. Output
flags such as `--json`, `--fields`, and `--silent` apply only to the current call.
Request-changing flags require an explicit endpoint, as do raw or custom cursors.

Raw server cursors and custom cursors from the CLI's shared paginator are also
accepted. Custom cursors preserve the page size and skip rows already returned,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ View, list, and send Sentry events

Make an authenticated API request

- `sentry api <endpoint>` — Make an authenticated API request
- `sentry api [<endpoint>]` — Make an authenticated API request

→ Full flags and examples: `references/api.md`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ requires:

Make an authenticated API request

### `sentry api <endpoint>`
### `sentry api [<endpoint>]`

Make an authenticated API request

**Arguments:**

| Argument | Description |
|----------|-------------|
| `<endpoint>` | API endpoint relative to /api/0/, or an absolute HTTP(S) URL |
| `[<endpoint>]` | API endpoint relative to /api/0/, or an absolute HTTP(S) URL; omit to resume a recent query |

**Flags:**
- `-X, --method <value> - The HTTP method for the request - (default: "GET")`
Expand All @@ -42,11 +42,10 @@ sentry api organizations/
# Read a page and its pagination metadata
sentry api organizations/ -f per_page=2 --json

# Continue the same query using shared pagination
sentry api organizations/ -f per_page=2 --cursor next --json

# Return to the previously visited page
sentry api organizations/ -f per_page=2 --cursor prev --json
# Navigate a recent query without repeating its arguments
sentry api organizations/ -f per_page=2 --json
sentry api --cursor next --json
sentry api --cursor prev --json

# Get a specific issue
sentry api issues/123456789/
Expand Down
160 changes: 141 additions & 19 deletions packages/cli/src/commands/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node";
import type { SentryContext } from "../context.js";
Expand All @@ -26,10 +27,12 @@ import {
advancePaginationState,
buildPaginationContextKey,
type CursorDirection,
getPaginationRequests,
hasPreviousPage,
resolveCursor,
} from "../lib/db/pagination.js";
import {
ContextError,
OutputError,
stringifyUnknown,
ValidationError,
Expand All @@ -38,9 +41,10 @@ import { filterFields } from "../lib/formatters/json.js";
import { CommandOutput } from "../lib/formatters/output.js";
import { validateEndpoint } from "../lib/input-validation.js";
import { imageBytesToKitty } from "../lib/kitty-image.js";
import { LIST_CURSOR_FLAG } from "../lib/list-command.js";
import { CURSOR_KEYWORDS, LIST_CURSOR_FLAG } from "../lib/list-command.js";
import { logger } from "../lib/logger.js";
import { buildCacheKey } from "../lib/response-cache.js";
import { getDefaultSdkConfig } from "../lib/sentry-client.js";
import {
canRenderKitty,
canRenderSixel,
Expand Down Expand Up @@ -1255,6 +1259,10 @@ type ApiPagination = {
prepared: PreparedRawApiRequest;
/** Identity-scoped fingerprint of the caller's request, excluding cursor. */
contextKey: string;
/** Cursor-free caller URL, before any bookmark overrides the page size. */
requestUrl: string;
/** Directory, host and identity scope used to recover this query. */
requestScope: string;
/** Navigation operation to commit after a successful response. */
direction: CursorDirection;
/** Original cursor, including any bookmark, saved for backward navigation. */
Expand All @@ -1263,6 +1271,82 @@ type ApiPagination = {
position: PagePosition;
};

/** Scope resumable API queries to the current identity, configured host and directory. */
async function getApiRequestScope(cwd: string): Promise<string> {
return await buildCacheKey(
"GET",
getDefaultSdkConfig().baseUrl,
JSON.stringify([getIdentityFingerprint(), resolve(cwd)]),
);
}

/**
* Recover one recent query without choosing between concurrent walks.
* Request modifiers require an explicit endpoint; output flags belong to the
* current invocation. Stored URLs are revalidated by the normal request path.
*/
function restoreApiRequest(flags: ApiFlags, scope: string) {
if (!flags.cursor) {
throw new ContextError("API endpoint", "sentry api <endpoint>", []);
}
if (!CURSOR_KEYWORDS.has(flags.cursor)) {
throw new ValidationError(
"An endpoint is required unless --cursor is next, prev, or first.",
"endpoint",
);
}
if (
flags.data !== undefined ||
flags.input !== undefined ||
flags.field?.length ||
flags["raw-field"]?.length ||
flags.header?.length
) {
throw new ValidationError(
"Request options require an endpoint. To resume unchanged, use sentry api --cursor " +
flags.cursor +
".",
"endpoint",
);
}
const requests = getPaginationRequests(PAGINATION_KEY, scope);
if (requests.length > 1) {
throw new ValidationError(
"More than one recent API query is saved in this directory. Repeat the endpoint and filters to choose which query to continue.",
"endpoint",
);
}
const request = requests[0];
if (!request) {
throw new ValidationError(
"No recent API query is saved in this directory. Run the request with its endpoint and filters first.",
"endpoint",
);
}
if (!request.url) {
throw new ValidationError(
"The saved API query requires explicit headers or a body. Repeat the endpoint and request options to continue.",
"endpoint",
);
}
const url = URL.canParse(request.url) ? new URL(request.url) : undefined;
if (
!url ||
(url.protocol !== "https:" && url.protocol !== "http:") ||
url.username ||
url.password ||
url.hash ||
!url.pathname.includes(API_PREFIX_WITH_SLASH) ||
url.searchParams.has("cursor")
) {
throw new ValidationError(
"The saved API query is invalid. Repeat the endpoint and filters to continue.",
"endpoint",
);
}
return { endpoint: request.url, contextKey: request.contextKey };
}

/**
* Resolve CLI navigation and custom cursors while retaining a single request.
* Context uses the caller's query before cursor-derived page-size overrides, so
Expand All @@ -1273,6 +1357,8 @@ async function resolveApiNavigation(
endpoint: string,
cursorFlag: string | undefined,
request: NonNullable<Parameters<typeof rawApiRequest>[1]>,
scope: string,
expectedContextKey?: string,
): Promise<ApiPagination> {
const queryIndex = endpoint.indexOf("?");
const query = new URLSearchParams(
Expand All @@ -1288,15 +1374,17 @@ async function resolveApiNavigation(
effectiveHeaders.delete("authorization");
effectiveHeaders.delete("sentry-trace");
effectiveHeaders.delete("baggage");
const identity = JSON.stringify([
getIdentityFingerprint(),
[...effectiveHeaders],
request.body,
]);
const identity = JSON.stringify([scope, [...effectiveHeaders], request.body]);
const contextKey = buildPaginationContextKey(
"api",
await buildCacheKey("GET", contextUrl, identity),
);
if (expectedContextKey !== undefined && contextKey !== expectedContextKey) {
throw new ValidationError(
"The saved API query no longer matches the current request configuration. Repeat the endpoint and request options to continue.",
"endpoint",
);
}
const { cursor, direction } = resolveCursor(
cursorFlag,
PAGINATION_KEY,
Expand All @@ -1313,6 +1401,8 @@ async function resolveApiNavigation(
per_page: position.perPage,
}),
contextKey,
requestUrl: contextUrl,
requestScope: scope,
direction,
currentCursor,
position,
Expand Down Expand Up @@ -1596,7 +1686,9 @@ export const apiCommand = buildCommand({
"pagination when navigation is available. Repeat the same GET and " +
"filters with --cursor next, prev, or first, or pass a raw cursor. " +
"Shared custom cursors restore page size and skip rows already read. " +
"Each invocation fetches one page.\n\n" +
"Each invocation fetches one page. With one recent query in the current " +
"directory, omit the endpoint and filters when using next, prev, or first. " +
"If several queries are saved, repeat their endpoint and filters.\n\n" +
"Body options:\n" +
' --data/-d \'{"key":"value"}\' Inline JSON body (like curl -d)\n' +
' --input/-i file.json Read body from file (or "-" for stdin)\n\n' +
Expand All @@ -1616,12 +1708,12 @@ export const apiCommand = buildCommand({
command: "sentry api organizations/ -f per_page=2 --json",
},
{
description: "Continue the same query using shared pagination",
command: "sentry api organizations/ -f per_page=2 --cursor next --json",
},
{
description: "Return to the previously visited page",
command: "sentry api organizations/ -f per_page=2 --cursor prev --json",
description: "Navigate a recent query without repeating its arguments",
command: [
"sentry api organizations/ -f per_page=2 --json",
"sentry api --cursor next --json",
"sentry api --cursor prev --json",
],
},
{
description: "Get a specific issue",
Expand Down Expand Up @@ -1685,9 +1777,11 @@ export const apiCommand = buildCommand({
kind: "tuple",
parameters: [
{
brief: "API endpoint relative to /api/0/, or an absolute HTTP(S) URL",
brief:
"API endpoint relative to /api/0/, or an absolute HTTP(S) URL; omit to resume a recent query",
parse: String,
placeholder: "endpoint",
optional: true,
},
],
},
Expand Down Expand Up @@ -1762,7 +1856,7 @@ export const apiCommand = buildCommand({
n: "dry-run",
},
},
async *func(this: SentryContext, flags: ApiFlags, endpoint: string) {
async *func(this: SentryContext, flags: ApiFlags, endpoint?: string) {
const { stdin } = this;
if (flags.cursor !== undefined && flags.method !== "GET") {
throw new ValidationError(
Expand All @@ -1771,8 +1865,17 @@ export const apiCommand = buildCommand({
);
}

const scope =
flags.method === "GET" ? await getApiRequestScope(this.cwd) : undefined;
const target =
endpoint === undefined && scope !== undefined
? restoreApiRequest(flags, scope)
: { endpoint, contextKey: undefined };
if (target.endpoint === undefined) {
throw new ContextError("API endpoint", "sentry api <endpoint>", []);
}
const { normalizedEndpoint, requestBaseUrl, strippedApiPrefix } =
resolveApiTarget(endpoint);
resolveApiTarget(target.endpoint);
tagOrgFromApiPath(normalizedEndpoint);
if (strippedApiPrefix) {
// Silent auto-fix — not a warning. Users commonly copy/paste URLs
Expand All @@ -1797,8 +1900,14 @@ export const apiCommand = buildCommand({
baseUrl: requestBaseUrl,
};
const navigation =
flags.method === "GET"
? await resolveApiNavigation(composedEndpoint, flags.cursor, request)
scope !== undefined
? await resolveApiNavigation(
composedEndpoint,
flags.cursor,
request,
scope,
target.contextKey,
)
: undefined;
const prepared =
navigation?.prepared ??
Expand Down Expand Up @@ -1877,7 +1986,20 @@ export const apiCommand = buildCommand({
navigation.contextKey,
navigation.direction,
pagination.nextCursor,
navigation.currentCursor ?? "",
{
currentCursor: navigation.currentCursor ?? "",
request:
response.headers.has("link") ||
navigation.currentCursor !== undefined
? {
scope: navigation.requestScope,
url:
body === undefined && !flags.header?.length
? navigation.requestUrl
: undefined,
}
: undefined,
},
);
pagination.hasPrev = hasPreviousPage(
PAGINATION_KEY,
Expand Down
Loading
Loading