Skip to content
Open
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
13 changes: 11 additions & 2 deletions packages/cli/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<AutofixConflictResponse["code"]>,
"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.
Expand Down
49 changes: 37 additions & 12 deletions packages/cli/src/lib/formatters/seer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<AutofixConflictResponse["code"]>,
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<K extends string, V>(
reasons: Record<K, V>,
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.
Expand All @@ -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,
);
}
Expand All @@ -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;
}
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/test/lib/formatters/seer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
35 changes: 26 additions & 9 deletions packages/mcp-core/src/api-client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2087,7 +2087,11 @@ describe("listOrganizations", () => {
{ status: 400 },
);
}
const start = cursor === null ? 0 : Number(cursor);
// Model Sentry's OffsetPaginator: a `<page size>:<page>: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),
Expand All @@ -2096,7 +2100,7 @@ describe("listOrganizations", () => {
})),
{
headers: {
Link: `<https://sentry.io/api/0/organizations/?cursor=${start + perPage}>; rel="next"; results="true"; cursor="${start + perPage}"`,
Link: `<https://sentry.io/api/0/organizations/?cursor=${nextCursor}>; rel="next"; results="true"; cursor="${nextCursor}"`,
},
},
);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -2227,15 +2241,18 @@ 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,
id: String(start + offset),
})),
{
headers: {
Link: `<https://de.sentry.io/api/0/organizations/my-org/${resource}/?cursor=${start + perPage}>; rel="next"; results="true"; cursor="${start + perPage}"`,
Link: `<https://de.sentry.io/api/0/organizations/my-org/${resource}/?cursor=${nextCursor}>; rel="next"; results="true"; cursor="${nextCursor}"`,
},
},
);
Expand All @@ -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]);
},
);

Expand Down
49 changes: 49 additions & 0 deletions packages/mcp-core/src/internal/tool-helpers/seer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
},
);
});
});
60 changes: 46 additions & 14 deletions packages/mcp-core/src/internal/tool-helpers/seer.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<AutofixConflictResponse["code"]>,
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<K extends string, V>(
values: Record<K, V>,
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.
Expand All @@ -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;
Expand Down
Loading
Loading