diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index 1423027e3..92da2ad76 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -23,6 +23,7 @@ * @see https://cli.sentry.dev/exit-codes/ for full reference */ +import type { AutofixConflictResponse } from "@sentry/api"; import { ArgumentScannerError, ExitCode } from "@stricli/core"; import { redactCredentialText } from "./credential-redaction.js"; import { @@ -764,13 +765,21 @@ export class UpgradeTransportError extends UpgradeError { // Seer Errors +/** + * 409 `code`s Sentry sends when it won't start a run until Seer setup is done. + * Taken from the generated API types so a renamed code fails type-checking. + */ +export type SeerSetupRequiredCode = Extract< + NonNullable, + "scm_integration_required" | "repos_not_linked" +>; + export type SeerErrorReason = | "not_enabled" | "no_budget" | "ai_disabled" | "out_of_credits" - | "scm_integration_required" - | "repos_not_linked"; + | SeerSetupRequiredCode; /** * Seer-specific errors with actionable suggestions. diff --git a/packages/cli/src/lib/formatters/seer.ts b/packages/cli/src/lib/formatters/seer.ts index fd771e607..718861612 100644 --- a/packages/cli/src/lib/formatters/seer.ts +++ b/packages/cli/src/lib/formatters/seer.ts @@ -5,6 +5,10 @@ * output is built as markdown and rendered via renderMarkdown(). */ +import type { + AutofixConflictResponse, + AutofixPaymentRequiredResponse, +} from "@sentry/api"; import { text } from "@sentry/toolkit-core/markdown"; import type { AutofixState, @@ -266,20 +270,41 @@ export function jsonTransformIssueExplain( // Error Messages /** - * `code` Sentry sends with a 402 when the organization is out of Seer credits. - * Any other 402 means no budget, including one with no code from a Sentry - * version that predates the codes. + * The SeerError reason for each 402 `code` in the API schema. A 402 with no + * code, from a Sentry version that predates the codes, means no budget. + * Keyed by the generated type, so a code added to the schema fails + * type-checking until it is mapped here. */ -const OUT_OF_CREDITS_CODE = "seer_out_of_credits"; +const PAYMENT_REQUIRED_REASONS: Record< + AutofixPaymentRequiredResponse["code"], + SeerErrorReason +> = { + seer_out_of_credits: "out_of_credits", + seer_out_of_quota: "no_budget", +}; /** - * `code`s Sentry sends with a 409 when it refuses to start a run because the - * org has no GitHub/GitLab integration or the project has no linked repos. + * The SeerError reason for each 409 `code` in the API schema, or null to keep + * the API error (e.g. a run already in flight). Exhaustive for the same reason. */ -const SETUP_REQUIRED_REASONS: readonly SeerErrorReason[] = [ - "scm_integration_required", - "repos_not_linked", -]; +const CONFLICT_REASONS: Record< + NonNullable, + SeerErrorReason | null +> = { + scm_integration_required: "scm_integration_required", + repos_not_linked: "repos_not_linked", + run_in_flight: null, +}; + +/** Look up an untrusted `code` from an error body in a map keyed by known codes. */ +function reasonForCode( + reasons: Record, + code: string | undefined, +): V | undefined { + return code !== undefined && Object.hasOwn(reasons, code) + ? reasons[code as K] + : undefined; +} /** * Create a SeerError from an API error status code and detail. @@ -298,7 +323,7 @@ export function createSeerError( ): SeerError | null { if (status === 402) { return new SeerError( - code === OUT_OF_CREDITS_CODE ? "out_of_credits" : "no_budget", + reasonForCode(PAYMENT_REQUIRED_REASONS, code) ?? "no_budget", orgSlug, ); } @@ -314,7 +339,7 @@ export function createSeerError( return null; } if (status === 409) { - const reason = SETUP_REQUIRED_REASONS.find((r) => r === code); + const reason = reasonForCode(CONFLICT_REASONS, code); // Any other 409 (e.g. a run already in flight) keeps its API detail. return reason ? new SeerError(reason, orgSlug) : null; } diff --git a/packages/cli/test/lib/formatters/seer.test.ts b/packages/cli/test/lib/formatters/seer.test.ts index 1bb8875ed..b81054e7f 100644 --- a/packages/cli/test/lib/formatters/seer.test.ts +++ b/packages/cli/test/lib/formatters/seer.test.ts @@ -431,6 +431,13 @@ describe("createSeerError", () => { expect(error?.reason).toBe("no_budget"); }); + test("ignores a code that only matches an Object.prototype key", () => { + expect( + createSeerError(402, undefined, "my-org", "constructor")?.reason, + ).toBe("no_budget"); + expect(createSeerError(409, "Conflict", "my-org", "toString")).toBeNull(); + }); + test("returns SeerError for 403 with 'not enabled' detail", () => { const error = createSeerError(403, "Seer is not enabled", "my-org"); expect(error).toBeInstanceOf(SeerError); diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index 3ffc8f115..33d0a3906 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -2087,7 +2087,11 @@ describe("listOrganizations", () => { { status: 400 }, ); } - const start = cursor === null ? 0 : Number(cursor); + // Model Sentry's OffsetPaginator: a `::0` cursor + // selects rows by page number, so per_page must stay stable. + const page = cursor === null ? 0 : Number(cursor.split(":")[1]); + const start = page * perPage; + const nextCursor = `${perPage}:${page + 1}:0`; return HttpResponse.json( Array.from({ length: perPage }, (_, offset) => ({ id: String(start + offset), @@ -2096,7 +2100,7 @@ describe("listOrganizations", () => { })), { headers: { - Link: `; rel="next"; results="true"; cursor="${start + perPage}"`, + Link: `; rel="next"; results="true"; cursor="${nextCursor}"`, }, }, ); @@ -2107,9 +2111,19 @@ describe("listOrganizations", () => { const result = await api.listOrganizations({ limit: 205 }); expect(result.organizations).toHaveLength(205); expect(result.organizations[204]?.slug).toBe("org-204"); - expect(result.nextCursor).toBe("205"); - expect(pageSizes).toEqual([100, 100, 5]); - expect(cursors).toEqual([null, "100", "200"]); + expect(pageSizes).toEqual([69, 69, 69]); + expect(cursors).toEqual([null, "69:1:0", "69:2:0"]); + + // The cursor resumes partway through the last page, at the first row it + // didn't return. + const resumed = await api.listOrganizations({ + limit: 2, + cursor: result.nextCursor, + }); + expect(resumed.organizations.map((org) => org.slug)).toEqual([ + "org-205", + "org-206", + ]); }); it("should fetch from the organizations endpoint on the root host for SaaS", async () => { @@ -2227,7 +2241,10 @@ describe("organization SDK list pages", () => { { status: 400 }, ); } - const start = Number(url.searchParams.get("cursor") ?? "0"); + const cursor = url.searchParams.get("cursor"); + const page = cursor === null ? 0 : Number(cursor.split(":")[1]); + const start = page * perPage; + const nextCursor = `${perPage}:${page + 1}:0`; return HttpResponse.json( Array.from({ length: perPage }, (_, offset) => ({ ...fixture, @@ -2235,7 +2252,7 @@ describe("organization SDK list pages", () => { })), { headers: { - Link: `; rel="next"; results="true"; cursor="${start + perPage}"`, + Link: `; rel="next"; results="true"; cursor="${nextCursor}"`, }, }, ); @@ -2246,8 +2263,8 @@ describe("organization SDK list pages", () => { const result = await list(api); const rows = "teams" in result ? result.teams : result.projects; expect(rows).toHaveLength(201); - expect(result.nextCursor).toBe("201"); - expect(pageSizes).toEqual([100, 100, 1]); + expect(result.nextCursor).toBe("67:3:0"); + expect(pageSizes).toEqual([67, 67, 67]); }, ); diff --git a/packages/mcp-core/src/internal/tool-helpers/seer.test.ts b/packages/mcp-core/src/internal/tool-helpers/seer.test.ts index 3bae30217..a03d74769 100644 --- a/packages/mcp-core/src/internal/tool-helpers/seer.test.ts +++ b/packages/mcp-core/src/internal/tool-helpers/seer.test.ts @@ -8,6 +8,9 @@ import { getSeerUnsupportedIssueMessage, getOutputForAutofixRun, getAutofixArtifactSummaries, + getSeerPaymentRequiredMessage, + getSeerSetupRequiredMessage, + SEER_START_OUT_OF_CREDITS_MESSAGE, } from "./seer"; import type { AutofixRunState } from "../../api-client/index"; @@ -145,4 +148,50 @@ describe("seer-utils", () => { ); }); }); + + describe("getSeerPaymentRequiredMessage", () => { + it("explains each 402 code from the API schema", () => { + expect( + getSeerPaymentRequiredMessage({ + detail: "Out of credits", + code: "seer_out_of_credits", + }), + ).toBe(SEER_START_OUT_OF_CREDITS_MESSAGE); + expect( + getSeerPaymentRequiredMessage({ + detail: "No quota", + code: "seer_out_of_quota", + }), + ).toContain("not have enough Seer quota"); + }); + + it("treats a 402 without a known code as out of quota", () => { + expect(getSeerPaymentRequiredMessage("No budget")).toContain( + "not have enough Seer quota", + ); + expect( + getSeerPaymentRequiredMessage({ detail: "x", code: "constructor" }), + ).toContain("not have enough Seer quota"); + }); + }); + + describe("getSeerSetupRequiredMessage", () => { + it.each(["scm_integration_required", "repos_not_linked"])( + "explains a 409 with %s", + (code) => { + expect( + getSeerSetupRequiredMessage({ detail: "Set up Seer first.", code }), + ).toContain("Set up Seer first."); + }, + ); + + it.each(["run_in_flight", "toString", undefined])( + "returns null for a 409 with code %s", + (code) => { + expect( + getSeerSetupRequiredMessage({ detail: "Conflict", code }), + ).toBeNull(); + }, + ); + }); }); diff --git a/packages/mcp-core/src/internal/tool-helpers/seer.ts b/packages/mcp-core/src/internal/tool-helpers/seer.ts index f7cc0d7e9..225fa9a90 100644 --- a/packages/mcp-core/src/internal/tool-helpers/seer.ts +++ b/packages/mcp-core/src/internal/tool-helpers/seer.ts @@ -1,3 +1,7 @@ +import type { + AutofixConflictResponse, + AutofixPaymentRequiredResponse, +} from "@sentry/api"; import { z } from "zod"; import type { AutofixRunState } from "../../api-client/index"; import type { Issue } from "../../api-client/types"; @@ -95,26 +99,55 @@ export const SEER_CONTINUE_OUT_OF_CREDITS_MESSAGE = const SEER_NO_QUOTA_MESSAGE = "Your organization does not have enough Seer quota to start an Autofix run."; +/** + * The message for each 402 `code` in the API schema. Keyed by the generated + * type, so a code added to the schema fails type-checking until it is mapped. + */ +const SEER_PAYMENT_REQUIRED_MESSAGES: Record< + AutofixPaymentRequiredResponse["code"], + string +> = { + seer_out_of_credits: SEER_START_OUT_OF_CREDITS_MESSAGE, + seer_out_of_quota: SEER_NO_QUOTA_MESSAGE, +}; + +/** + * Whether each 409 `code` in the API schema means Seer setup is missing. + * Exhaustive for the same reason. + */ +const SEER_SETUP_REQUIRED_CODES: Record< + NonNullable, + boolean +> = { + scm_integration_required: true, + repos_not_linked: true, + run_in_flight: false, +}; + +/** Look up the untrusted `code` of an error body in a map keyed by known codes. */ +function valueForCode( + values: Record, + responseBody: unknown, +): V | undefined { + if (!isPlainObject(responseBody)) return undefined; + const { code } = responseBody; + return typeof code === "string" && Object.hasOwn(values, code) + ? values[code as K] + : undefined; +} + /** * Explain a 402 from starting a run. Sentry tags the 402 with a `code`. Older * Sentry versions send the out-of-quota 402 as a bare string with no code, so * anything that isn't out of credits is treated as out of quota. */ export function getSeerPaymentRequiredMessage(responseBody: unknown): string { - if ( - isPlainObject(responseBody) && - responseBody.code === "seer_out_of_credits" - ) { - return SEER_START_OUT_OF_CREDITS_MESSAGE; - } - return SEER_NO_QUOTA_MESSAGE; + return ( + valueForCode(SEER_PAYMENT_REQUIRED_MESSAGES, responseBody) ?? + SEER_NO_QUOTA_MESSAGE + ); } -const SEER_SETUP_REQUIRED_CODES = new Set([ - "scm_integration_required", - "repos_not_linked", -]); - /** * Explain a 409 from starting a run when Seer is not set up for the issue's * project. Returns null for any other 409, such as a run already in flight. @@ -123,9 +156,8 @@ export function getSeerSetupRequiredMessage( responseBody: unknown, ): string | null { if ( + !valueForCode(SEER_SETUP_REQUIRED_CODES, responseBody) || !isPlainObject(responseBody) || - typeof responseBody.code !== "string" || - !SEER_SETUP_REQUIRED_CODES.has(responseBody.code) || typeof responseBody.detail !== "string" ) { return null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5c12c315..82be6e25e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,8 +43,8 @@ catalogs: specifier: ^1.2.3 version: 1.2.3 '@sentry/api': - specifier: 2.0.0 - version: 2.0.0 + specifier: 2.1.0 + version: 2.1.0 '@sentry/cloudflare': specifier: 11.12.0 version: 11.12.0 @@ -504,7 +504,7 @@ importers: version: 2.1.7(hono@4.13.18) '@sentry/api': specifier: 'catalog:' - version: 2.0.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 2.1.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.12.0 @@ -838,7 +838,7 @@ importers: version: 2.4.0 '@sentry/api': specifier: 'catalog:' - version: 2.0.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 2.1.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.12.0 @@ -1015,7 +1015,7 @@ importers: dependencies: '@sentry/api': specifier: 'catalog:' - version: 2.0.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 2.1.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/search-syntax': specifier: 'catalog:' version: 1.0.0 @@ -4149,8 +4149,8 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sentry/api@2.0.0': - resolution: {integrity: sha512-G7QUnqRExbwywo2P/q2XCmpO+FxKxDYyK85uflfFtRE2LfGst0rDgyzxyk2NIUmRm1KvSbvd8bU7NndTbMNctg==} + '@sentry/api@2.1.0': + resolution: {integrity: sha512-QeugH+eTHtujldYMsOMWfqfUpAwkYE/pe11O7eT5YCz7DkHiEtT3/L3OjhxMQWsS8aB+AVxltMxuq/PjV8h6Uw==} engines: {node: '>=22'} peerDependencies: typescript: '*' @@ -11415,7 +11415,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sentry/api@2.0.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': + '@sentry/api@2.1.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': dependencies: http-cache-semantics: 4.2.0 p-limit: 7.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 26ea6b6da..4ff9217c1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -51,7 +51,7 @@ catalog: "@modelcontextprotocol/server": 2.4.0 "@radix-ui/react-accordion": ^1.2.20 "@radix-ui/react-slot": ^1.2.3 - "@sentry/api": 2.0.0 + "@sentry/api": 2.1.0 "@sentry/cloudflare": 11.12.0 "@sentry/core": 11.12.0 "@sentry/node": 11.12.0 @@ -169,7 +169,7 @@ minimumReleaseAgeExclude: - "@oxlint/binding-win32-x64-msvc@1.87.0" - oxfmt@0.72.0 - oxlint@1.87.0 - - "@sentry/api@2.0.0" + - "@sentry/api@2.1.0" - miniflare@5.20261101.0-beta - wrangler@4.156.1 - "@sentry/symbolic@14.0.0-alpha.5"