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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"sentry-cli/no-direct-target-resolution": "error",
"sentry-cli/no-generic-is-record": "error",
"sentry-cli/prefer-paginate-helper": "error",
"sentry-cli/no-manual-pagination": "error",
"sentry-cli/no-namespace-import": "error",
"no-restricted-imports": [
"error",
Expand Down
36 changes: 22 additions & 14 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,24 +411,32 @@ that fits your use case):
**Auto-pagination for large limits:**

When `--limit` exceeds `API_MAX_PER_PAGE` (100), list commands MUST transparently
fetch multiple pages to fill the requested limit. Cap `perPage` at
`Math.min(flags.limit, API_MAX_PER_PAGE)` and loop until `results.length >= limit`
or pages are exhausted. This matches the `listIssuesAllPages` pattern.
fetch multiple pages to fill the requested limit. Always do this with
`paginate()` from `src/lib/api/infrastructure.ts`; never write the cursor loop
yourself. The `sentry-cli/no-manual-pagination` lint rule rejects loops that
follow cursors and count toward a limit.

```typescript
const perPage = Math.min(flags.limit, API_MAX_PER_PAGE);
for (let page = 0; page < MAX_PAGINATION_PAGES; page++) {
const { data, nextCursor } = await listPaginated(org, { perPage, cursor });
results.push(...data);
if (results.length >= flags.limit || !nextCursor) break;
cursor = nextCursor;
}
const { data, nextCursor } = await paginate(
{ limit: flags.limit, cursor },
(perPage, pageCursor) =>
listPaginated(org, { perPage, cursor: pageCursor }),
);
```

Never pass a `per_page` value larger than `API_MAX_PER_PAGE` to the API — the
server silently caps it, causing the command to return fewer items than requested.

Reference template: `trace/list.ts`, `span/list.ts`, `dashboard/list.ts`
Pass `perPage` to the request unchanged. Sentry's page-number paginators
(`OffsetPaginator`, used by projects, teams, members, repositories, and
dashboard revisions) encode the page size in the cursor, so a cursor is only
valid with the `per_page` that produced it. `paginate()` therefore returns
opaque CLI cursors (`cli1~<perPage>~<skip>~<server cursor>`) that record the
walk's page size and how many rows of the current page were already returned.
A later `-c next` reuses that page size even when `--limit` changes, and
resumes mid-page by refetching the page and skipping returned rows. Always
pass these cursors back through `paginate()`; never send them to an API
request directly. Test pagination changes against the paginator models in
`test/lib/paginate.property.test.ts`.

Reference template: `trace/list.ts`, `span/list.ts`, `dashboard/revisions.ts`

### ID Validation

Expand Down
28 changes: 26 additions & 2 deletions packages/cli/lint-rules/cli-oxlint-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,18 +226,42 @@ const rules = {
"prefer-paginate-helper": rule(
"Use paginate() from infrastructure.js instead of a raw limit.",
(_context, file, report) =>
file.includes("/src/lib/api/") && !file.endsWith("/infrastructure.ts")
file.includes("/src/") && !file.endsWith("/lib/api/infrastructure.ts")
? {
CallExpression(node) {
const limit = node.arguments[1];
if (
node.callee.name === "autoPaginate" &&
node.arguments[1]?.name === "limit"
(limit?.name === "limit" || member(limit, "limit"))
)
report(node);
},
}
: {},
),
"no-manual-pagination": rule(
"Use paginate() from infrastructure.js instead of a limit-bounded cursor loop.",
(_context, file, report) => {
if (
!file.includes("/src/") ||
file.endsWith("/lib/api/infrastructure.ts")
)
return {};
// A loop that follows cursors and also counts toward a limit is
// re-deriving per_page and cursor-after-trim rules that paginate() owns.
const check = (node) => {
const followsCursors =
containsIdentifier(node, "MAX_PAGINATION_PAGES") ||
containsIdentifier(node, "nextCursor");
if (followsCursors && containsIdentifier(node, "limit")) report(node);
};
return {
ForStatement: check,
WhileStatement: check,
DoWhileStatement: check,
};
},
),
"no-namespace-import": rule(
"Import only the names used by production code.",
(_context, file, report) =>
Expand Down
53 changes: 11 additions & 42 deletions packages/cli/src/commands/alert/issues/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,8 @@
import type { SentryContext } from "../../../context.js";
import { buildProjectAliasMap } from "../../../lib/alias.js";
import type { IssueAlertRule } from "../../../lib/api/alerts.js";
import { MAX_PAGINATION_PAGES } from "../../../lib/api/infrastructure.js";
import {
API_MAX_PER_PAGE,
listIssueAlertsPaginated,
} from "../../../lib/api-client.js";
import { paginate } from "../../../lib/api/infrastructure.js";
import { listIssueAlertsPaginated } from "../../../lib/api-client.js";
import { parseOrgProjectArg } from "../../../lib/arg-parsing.js";
import { openInBrowser } from "../../../lib/browser.js";
import {
Expand Down Expand Up @@ -137,43 +134,15 @@ async function fetchRulesForTarget(
options: { limit: number; startCursor?: string },
): Promise<FetchResult> {
const result = await withAuthGuard(async () => {
const rules: IssueAlertRule[] = [];
let serverCursor = options.startCursor;

for (let page = 0; page < MAX_PAGINATION_PAGES; page++) {
const { data, nextCursor } = await listIssueAlertsPaginated(
target.org,
target.project,
{
perPage: Math.min(options.limit - rules.length, API_MAX_PER_PAGE),
cursor: serverCursor,
},
);

for (const rule of data) {
rules.push(rule);
if (rules.length >= options.limit) {
return {
target,
rules,
hasMore: !!nextCursor,
nextCursor: nextCursor ?? undefined,
};
}
}

if (!nextCursor) {
return { target, rules, hasMore: false };
}
serverCursor = nextCursor;
}

return {
target,
rules,
hasMore: !!serverCursor,
nextCursor: serverCursor ?? undefined,
};
const { data: rules, nextCursor } = await paginate(
{ limit: options.limit, cursor: options.startCursor },
(perPage, cursor) =>
listIssueAlertsPaginated(target.org, target.project, {
perPage,
cursor,
}),
);
return { target, rules, hasMore: !!nextCursor, nextCursor };
});

if (!result.ok) {
Expand Down
46 changes: 8 additions & 38 deletions packages/cli/src/commands/alert/metrics/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,8 @@

import type { SentryContext } from "../../../context.js";
import type { MetricAlertRule } from "../../../lib/api/alerts.js";
import { MAX_PAGINATION_PAGES } from "../../../lib/api/infrastructure.js";
import {
API_MAX_PER_PAGE,
listMetricAlertsPaginated,
} from "../../../lib/api-client.js";
import { paginate } from "../../../lib/api/infrastructure.js";
import { listMetricAlertsPaginated } from "../../../lib/api-client.js";
import { parseOrgProjectArg } from "../../../lib/arg-parsing.js";
import { openInBrowser } from "../../../lib/browser.js";
import {
Expand Down Expand Up @@ -143,39 +140,12 @@ async function fetchRulesForOrg(
options: { limit: number; startCursor?: string },
): Promise<FetchResult> {
const result = await withAuthGuard(async () => {
const rules: MetricAlertRule[] = [];
let serverCursor = options.startCursor;

for (let page = 0; page < MAX_PAGINATION_PAGES; page++) {
const { data, nextCursor } = await listMetricAlertsPaginated(orgSlug, {
perPage: Math.min(options.limit - rules.length, API_MAX_PER_PAGE),
cursor: serverCursor,
});

for (const rule of data) {
rules.push(rule);
if (rules.length >= options.limit) {
return {
orgSlug,
rules,
hasMore: !!nextCursor,
nextCursor: nextCursor ?? undefined,
};
}
}

if (!nextCursor) {
return { orgSlug, rules, hasMore: false };
}
serverCursor = nextCursor;
}

return {
orgSlug,
rules,
hasMore: !!serverCursor,
nextCursor: serverCursor,
};
const { data: rules, nextCursor } = await paginate(
{ limit: options.limit, cursor: options.startCursor },
(perPage, cursor) =>
listMetricAlertsPaginated(orgSlug, { perPage, cursor }),
);
return { orgSlug, rules, hasMore: !!nextCursor, nextCursor };
});

if (!result.ok) {
Expand Down
12 changes: 7 additions & 5 deletions packages/cli/src/commands/dashboard/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,9 @@ async function fetchDashboards(
const allTitles: string[] = [];
let cursorToStore: string | undefined;

// Filters titles client-side, so it cannot count rows with paginate(). It
// always uses one page size and resumes mid-page via afterId bookmarks.
// oxlint-disable-next-line sentry-cli/no-manual-pagination
for (let page = 0; page < MAX_PAGINATION_PAGES; page++) {
const { data, nextCursor } = await listDashboardsPaginated(orgSlug, {
perPage: opts.perPage,
Expand Down Expand Up @@ -439,11 +442,10 @@ export const listCommand = buildListCommand("dashboard", {
? picomatch(titleFilter.toLowerCase(), { dot: true })
: undefined;

// When filtering, fetch max-size pages to minimize round trips.
// When not filtering, cap at the smaller of limit and API max.
const perPage = glob
? API_MAX_PER_PAGE
: Math.min(flags.limit, API_MAX_PER_PAGE);
// Dashboards use a page-number paginator whose cursor is only valid with
// the page size that produced it, so every call uses the same size, even
// when `-c next` changes --limit. afterId bookmarks resume mid-page.
const perPage = API_MAX_PER_PAGE;

const {
dashboards: results,
Expand Down
64 changes: 18 additions & 46 deletions packages/cli/src/commands/dashboard/revisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,8 @@
*/

import type { SentryContext } from "../../context.js";
import { MAX_PAGINATION_PAGES } from "../../lib/api/infrastructure.js";
import {
API_MAX_PER_PAGE,
listDashboardRevisionsPaginated,
} from "../../lib/api-client.js";
import { paginate } from "../../lib/api/infrastructure.js";
import { listDashboardRevisionsPaginated } from "../../lib/api-client.js";
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
import { buildCommand } from "../../lib/command.js";
import { parseOrgProjectTarget } from "../../lib/completion-target.js";
Expand Down Expand Up @@ -169,11 +166,7 @@ export const revisionsCommand = buildCommand({
contextKey,
);

const results: DashboardRevision[] = [];
let cursor = rawCursor;
let nextCursor: string | undefined;

await withDashboardErrors(
const { data: revisions, nextCursor } = await withDashboardErrors(
{
orgSlug,
dashboardId,
Expand All @@ -182,50 +175,29 @@ export const revisionsCommand = buildCommand({
() =>
withProgress(
{ message: "Fetching revisions...", json: flags.json },
async () => {
for (let page = 0; page < MAX_PAGINATION_PAGES; page++) {
const { data, nextCursor: nc } =
await listDashboardRevisionsPaginated(orgSlug, dashboardId, {
// Ask only for the remaining budget so the last page's
// cursor points at the first row we did not return.
perPage: Math.min(
flags.limit - results.length,
API_MAX_PER_PAGE,
),
cursor,
});
results.push(...data);
nextCursor = nc;
if (results.length >= flags.limit || !nc) {
break;
}
cursor = nc;
}
},
() =>
paginate(
{ limit: flags.limit, cursor: rawCursor },
(perPage, pageCursor) =>
listDashboardRevisionsPaginated(orgSlug, dashboardId, {
perPage,
cursor: pageCursor,
}),
),
),
);
const hasMore = nextCursor !== undefined;

const trimmed = results.slice(0, flags.limit);
// A page larger than requested would make the server cursor skip the
// trimmed rows, so drop it (same as autoPaginate).
const cursorToStore = results.length > flags.limit ? undefined : nextCursor;
const hasMore = cursorToStore !== undefined;

advancePaginationState(
PAGINATION_KEY,
contextKey,
direction,
cursorToStore,
);
advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor);
const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey);

const outputData: RevisionsResult = {
revisions: trimmed,
revisions,
orgSlug,
dashboardId,
hasMore,
hasPrev: hasPrev || undefined,
nextCursor: cursorToStore,
nextCursor,
};
yield new CommandOutput(outputData);

Expand All @@ -238,13 +210,13 @@ export const revisionsCommand = buildCommand({
});
const navStr = nav ? ` ${nav}` : "";

if (trimmed.length === 0) {
if (revisions.length === 0) {
return { hint: nav ? `No revisions found.${navStr}` : undefined };
}

return {
hint:
`Showing ${trimmed.length} revision(s) for dashboard ${dashboardId}.${navStr}\n` +
`Showing ${revisions.length} revision(s) for dashboard ${dashboardId}.${navStr}\n` +
`Restore: sentry dashboard restore ${orgSlug}/ ${dashboardId} --revision <revision-id>\n` +
`Dashboard: ${url}`,
};
Expand Down
Loading
Loading