From f395d687aab078536ae0ce839f354e9853528c57 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:15:08 +0000 Subject: [PATCH] ref(cli): Bump @sentry/api to 1.50.0 and use org auth token, previous-release and shared-issue operations @sentry/api 1.50.0 publishes the org auth token, previous release with commits, and shared issue endpoints. Move their call sites onto the generated operations: - token list/create/delete: list/create/deleteOrganizationAuthToken(s), still on the control silo and still validated by OrgAuthTokenSchema. - release set-commits: getOrganizationPreviousReleaseWithCommits. - issue share links: getOrganizationSharedIssue, sent through customFetch rather than the authenticated transport so the share URL's host never gets a bearer token. Custom headers stay URL-scoped, and the 404, TLS and invalid JSON errors are unchanged. --- .../skills/sentry-cli/references/event.md | 12 +-- packages/cli/src/lib/api/issues.ts | 84 +++++++++------- packages/cli/src/lib/api/org-auth-tokens.ts | 50 ++++++---- packages/cli/src/lib/api/releases.ts | 22 ++--- .../cli/test/lib/api/org-auth-tokens.test.ts | 98 ++++++++++++++----- pnpm-lock.yaml | 16 +-- pnpm-workspace.yaml | 4 +- 7 files changed, 180 insertions(+), 106 deletions(-) diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md index 4f93c0875..4cae2fe8c 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -37,22 +37,22 @@ View details of one or more events | `message` | string \| null | Event message | | `title` | string | Event title | | `location` | string \| null | Source location of the event, such as a file and line | -| `user` | object \| null | User context of the event | -| `tags` | array | Tags attached to the event | | `platform` | string \| null | Platform of the event, such as python or javascript | | `dateReceived` | string \| null | When Sentry received the event (ISO 8601) | -| `contexts` | object \| null | Contexts of the event, such as device, os, browser and runtime | | `size` | number \| null | Size of the event payload in bytes | | `entries` | array | Event interfaces, such as exception, breadcrumbs and request, each with a type and data | | `dist` | string \| null | Distribution of the release the event was sent with | -| `sdk` | object \| null | SDK that sent the event | -| `context` | object \| null | Additional data sent with the event in its extra field | | `packages` | object | Modules loaded when the event occurred, keyed by module name with their versions | | `type` | string | Event type, such as error, default or transaction | | `metadata` | object | Event metadata | -| `errors` | array | Errors Sentry found while processing the event | | `occurrence` | object \| null | | | `_meta` | object | Metadata about changes Sentry made to the event's fields, such as data scrubbing or trimming | +| `user` | object \| null | User context of the event | +| `tags` | array | Tags attached to the event | +| `contexts` | object \| null | Contexts of the event, such as device, os, browser and runtime | +| `sdk` | object \| null | SDK that sent the event | +| `context` | object \| null | Additional data sent with the event in its extra field | +| `errors` | array | Errors Sentry found while processing the event | | `crashFile` | string \| null | Crash report attachment of the event, if any | | `culprit` | string \| null | Function or module that caused the event | | `dateCreated` | string | When the event occurred (ISO 8601) | diff --git a/packages/cli/src/lib/api/issues.ts b/packages/cli/src/lib/api/issues.ts index f47a4db3c..a3db4a90d 100644 --- a/packages/cli/src/lib/api/issues.ts +++ b/packages/cli/src/lib/api/issues.ts @@ -10,6 +10,7 @@ import type { } from "@sentry/api"; import { getOrganizationIssue, + getOrganizationSharedIssue, listOrganizationIssues, resolveOrganizationShortId, updateOrganizationIssue, @@ -727,30 +728,62 @@ export async function getSharedIssue( shareId: string, ): Promise<{ id: string }> { const path = `organizations/${encodeURIComponent(orgSlug)}/shared/issues/${encodeURIComponent(shareId)}/`; - const url = `${baseUrl.replace(TRAILING_SLASH_RE, "")}/api/0/${path}`; + const normalizedBase = baseUrl.replace(TRAILING_SLASH_RE, ""); + const url = `${normalizedBase}/api/0/${path}`; const headers = new Headers({ "Content-Type": "application/json" }); // URL-scoped: headers only attach when `url`'s origin matches the trusted // host, so IAP tokens etc. can't leak to an attacker-controlled share URL. applyCustomHeaders(headers, url); warnIfSaasWithEnvCa(url); - let response: Response; + // The share URL's host may not be the logged-in one, so this goes out + // without the authenticated transport and its bearer token. + let response: Response | undefined; + const fetchUnauthenticated = async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + response = await customFetch(input, init); + return response; + }; + + let result: Awaited>; try { - response = await customFetch(url, { headers }); + result = await getOrganizationSharedIssue({ + baseUrl: normalizedBase, + fetch: fetchUnauthenticated as unknown as typeof fetch, + headers, + parseAs: "json", + path: { organization_id_or_slug: orgSlug, share_id: shareId }, + throwOnError: false, + }); } catch (error) { - if (error instanceof Error && isTlsCertError(error)) { - throw new ApiError( - "TLS certificate error", - 0, - buildTlsErrorDetail(error), - path, - ); + if (!(error instanceof SyntaxError)) { + throw error; } - throw error; + throw new ApiError( + "Share link returned invalid JSON", + response?.status ?? 0, + undefined, + path, + ); } - if (!response.ok) { - if (response.status === 404) { + if ("error" in result) { + const status = result.response?.status; + if (status === undefined) { + const { error } = result; + if (error instanceof Error && isTlsCertError(error)) { + throw new ApiError( + "TLS certificate error", + 0, + buildTlsErrorDetail(error), + path, + ); + } + throw error; + } + if (status === 404) { throw new ApiError( "Share link not found or expired", 404, @@ -759,29 +792,10 @@ export async function getSharedIssue( path, ); } - throw new ApiError( - "Failed to resolve share link", - response.status, - undefined, - path, - ); - } - - let data: unknown; - try { - data = await response.json(); - } catch (error) { - if (!(error instanceof SyntaxError)) { - throw error; - } - throw new ApiError( - "Share link returned invalid JSON", - response.status, - undefined, - path, - ); + throw new ApiError("Failed to resolve share link", status, undefined, path); } + const data: unknown = result.data; if ( typeof data !== "object" || data === null || @@ -791,7 +805,7 @@ export async function getSharedIssue( ) { throw new ApiError( "Share link response missing a valid issue ID", - response.status, + result.response?.status ?? 0, undefined, path, ); diff --git a/packages/cli/src/lib/api/org-auth-tokens.ts b/packages/cli/src/lib/api/org-auth-tokens.ts index bb4effcd6..43f4eb08f 100644 --- a/packages/cli/src/lib/api/org-auth-tokens.ts +++ b/packages/cli/src/lib/api/org-auth-tokens.ts @@ -9,14 +9,17 @@ * login (`sentry auth login`) but reject personal and org auth tokens. */ +import { + createOrganizationAuthToken, + deleteOrganizationAuthToken, + listOrganizationAuthTokens, +} from "@sentry/api"; import { array } from "valibot"; import { type OrgAuthToken, OrgAuthTokenSchema } from "../../types/index.js"; import { getControlSiloUrl } from "../sentry-client.js"; -import { - apiRequestToRegion, - apiRequestToRegionNoContent, -} from "./infrastructure.js"; +import { getSdkClientConfig } from "../sentry-sdk-client.js"; +import { unwrapResult, unwrapValidatedResult } from "./infrastructure.js"; function tokensPath(orgSlug: string): string { return `/organizations/${encodeURIComponent(orgSlug)}/org-auth-tokens/`; @@ -34,12 +37,16 @@ function tokensPath(orgSlug: string): string { export async function listOrgAuthTokens( orgSlug: string, ): Promise { - const { data } = await apiRequestToRegion( - getControlSiloUrl(), + const result = await listOrganizationAuthTokens({ + ...getSdkClientConfig(getControlSiloUrl()), + path: { organization_id_or_slug: orgSlug }, + }); + return unwrapValidatedResult( + result, + "Failed to list org auth tokens", + array(OrgAuthTokenSchema), tokensPath(orgSlug), - { schema: array(OrgAuthTokenSchema) }, ); - return data; } /** @@ -56,12 +63,17 @@ export async function createOrgAuthToken( orgSlug: string, name: string, ): Promise { - const { data } = await apiRequestToRegion( - getControlSiloUrl(), + const result = await createOrganizationAuthToken({ + ...getSdkClientConfig(getControlSiloUrl()), + path: { organization_id_or_slug: orgSlug }, + body: { name }, + }); + return unwrapValidatedResult( + result, + "Failed to create org auth token", + OrgAuthTokenSchema, tokensPath(orgSlug), - { method: "POST", body: { name }, schema: OrgAuthTokenSchema }, ); - return data; } /** @@ -74,9 +86,13 @@ export async function deleteOrgAuthToken( orgSlug: string, tokenId: string, ): Promise { - await apiRequestToRegionNoContent( - getControlSiloUrl(), - `${tokensPath(orgSlug)}${encodeURIComponent(tokenId)}/`, - { method: "DELETE" }, - ); + const result = await deleteOrganizationAuthToken({ + ...getSdkClientConfig(getControlSiloUrl()), + path: { + organization_id_or_slug: orgSlug, + // Sentry returns IDs as numeric strings; the path carries them as-is. + token_id: tokenId as unknown as number, + }, + }); + unwrapResult(result, "Failed to revoke org auth token"); } diff --git a/packages/cli/src/lib/api/releases.ts b/packages/cli/src/lib/api/releases.ts index c0b053d19..844ada6e9 100644 --- a/packages/cli/src/lib/api/releases.ts +++ b/packages/cli/src/lib/api/releases.ts @@ -9,6 +9,7 @@ import { createOrganizationRelease, createOrganizationReleaseDeploy, deleteOrganizationRelease, + getOrganizationPreviousReleaseWithCommits, getOrganizationRelease, listOrganizationReleaseDeploys, listOrganizationReleases, @@ -37,14 +38,12 @@ import { isSameRepository, } from "../git.js"; import { logger } from "../logger.js"; -import { resolveOrgRegion } from "../region.js"; import { buildRepositorySettingsUrl, buildSourceCodeIntegrationsUrl, } from "../sentry-web-urls.js"; import { API_MAX_PER_PAGE, - apiRequestToRegion, getOrgSdkConfig, MAX_PAGINATION_PAGES, type PaginatedResponse, @@ -421,16 +420,17 @@ async function getPreviousReleaseCommit( ): Promise { // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { - const regionUrl = await resolveOrgRegion(orgSlug); - const encodedVersion = encodeURIComponent(version); - const { data } = await apiRequestToRegion<{ - lastCommit?: { id: string } | null; - }>( - regionUrl, - `organizations/${orgSlug}/releases/${encodedVersion}/previous-with-commits/`, - { method: "GET" }, + const result = await getOrganizationPreviousReleaseWithCommits({ + ...getOrgSdkConfig(), + path: { organization_id_or_slug: orgSlug, version }, + }); + // Sentry answers `{}` when no earlier release has commits. + const data = unwrapResult<{ lastCommit?: { id?: unknown } | null }>( + result, + "Failed to get previous release commit", ); - return data?.lastCommit?.id; + const id = data.lastCommit?.id; + return typeof id === "string" ? id : undefined; } catch { // Not critical — if we can't get the previous commit, we still send // refs without previousCommit. Sentry will try to determine the range diff --git a/packages/cli/test/lib/api/org-auth-tokens.test.ts b/packages/cli/test/lib/api/org-auth-tokens.test.ts index be465646e..c7d234919 100644 --- a/packages/cli/test/lib/api/org-auth-tokens.test.ts +++ b/packages/cli/test/lib/api/org-auth-tokens.test.ts @@ -1,23 +1,38 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -vi.mock("../../../src/lib/api/infrastructure.js", () => ({ - apiRequestToRegion: vi.fn(), - apiRequestToRegionNoContent: vi.fn(), +const { + createOrganizationAuthTokenMock, + deleteOrganizationAuthTokenMock, + listOrganizationAuthTokensMock, +} = vi.hoisted(() => ({ + createOrganizationAuthTokenMock: vi.fn(), + deleteOrganizationAuthTokenMock: vi.fn(), + listOrganizationAuthTokensMock: vi.fn(), })); -vi.mock("../../../src/lib/sentry-client.js", () => ({ +vi.mock("@sentry/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createOrganizationAuthToken: createOrganizationAuthTokenMock, + deleteOrganizationAuthToken: deleteOrganizationAuthTokenMock, + listOrganizationAuthTokens: listOrganizationAuthTokensMock, + }; +}); + +vi.mock("../../../src/lib/sentry-client.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../src/lib/sentry-client.js") + >()), getControlSiloUrl: () => "https://sentry.io", })); -import { - apiRequestToRegion, - apiRequestToRegionNoContent, -} from "../../../src/lib/api/infrastructure.js"; import { createOrgAuthToken, deleteOrgAuthToken, listOrgAuthTokens, } from "../../../src/lib/api/org-auth-tokens.js"; +import { ApiError } from "../../../src/lib/errors.js"; const token = { id: "7", @@ -30,47 +45,76 @@ const token = { describe("org auth token API", () => { beforeEach(() => { - vi.mocked(apiRequestToRegion).mockReset(); - vi.mocked(apiRequestToRegionNoContent).mockReset(); + createOrganizationAuthTokenMock.mockReset(); + deleteOrganizationAuthTokenMock.mockReset(); + listOrganizationAuthTokensMock.mockReset(); }); test("lists tokens from the control silo", async () => { - vi.mocked(apiRequestToRegion).mockResolvedValue({ + listOrganizationAuthTokensMock.mockResolvedValue({ data: [token], - headers: new Headers(), + response: new Response(), }); await expect(listOrgAuthTokens("my-org")).resolves.toEqual([token]); - expect(apiRequestToRegion).toHaveBeenCalledWith( - "https://sentry.io", - "/organizations/my-org/org-auth-tokens/", - expect.objectContaining({ schema: expect.any(Object) }), + expect(listOrganizationAuthTokensMock).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "https://sentry.io", + path: { organization_id_or_slug: "my-org" }, + }), ); }); + test("rejects a token list that doesn't match the schema", async () => { + listOrganizationAuthTokensMock.mockResolvedValue({ + data: [{ id: 7 }], + response: new Response(), + }); + + await expect(listOrgAuthTokens("my-org")).rejects.toThrow(); + }); + test("creates a token with only a name", async () => { - vi.mocked(apiRequestToRegion).mockResolvedValue({ + createOrganizationAuthTokenMock.mockResolvedValue({ data: { ...token, token: "sntrys_secret" }, - headers: new Headers(), + response: new Response(null, { status: 201 }), }); const created = await createOrgAuthToken("my-org", "ci"); expect(created.token).toBe("sntrys_secret"); - expect(apiRequestToRegion).toHaveBeenCalledWith( - "https://sentry.io", - "/organizations/my-org/org-auth-tokens/", - expect.objectContaining({ method: "POST", body: { name: "ci" } }), + expect(createOrganizationAuthTokenMock).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "https://sentry.io", + path: { organization_id_or_slug: "my-org" }, + body: { name: "ci" }, + }), ); }); test("deletes a token by ID", async () => { - vi.mocked(apiRequestToRegionNoContent).mockResolvedValue(); + deleteOrganizationAuthTokenMock.mockResolvedValue({ + data: {}, + response: new Response(null, { status: 204 }), + }); await deleteOrgAuthToken("my-org", "7"); - expect(apiRequestToRegionNoContent).toHaveBeenCalledWith( - "https://sentry.io", - "/organizations/my-org/org-auth-tokens/7/", - { method: "DELETE" }, + expect(deleteOrganizationAuthTokenMock).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "https://sentry.io", + path: { organization_id_or_slug: "my-org", token_id: "7" }, + }), ); }); + + test("keeps the HTTP status on errors", async () => { + deleteOrganizationAuthTokenMock.mockResolvedValue({ + error: { detail: "You do not have permission" }, + response: new Response(null, { status: 403, statusText: "Forbidden" }), + }); + + await expect(deleteOrgAuthToken("my-org", "7")).rejects.toMatchObject({ + name: ApiError.name, + status: 403, + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0916d347d..bcf9a3eaf 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: 1.49.0 - version: 1.49.0 + specifier: 1.50.0 + version: 1.50.0 '@sentry/cloudflare': specifier: 11.6.0 version: 11.6.0 @@ -513,7 +513,7 @@ importers: version: 2.1.5(hono@4.13.13) '@sentry/api': specifier: 'catalog:' - version: 1.49.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 1.50.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -865,7 +865,7 @@ importers: version: 2.4.0 '@sentry/api': specifier: 'catalog:' - version: 1.49.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 1.50.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -1048,7 +1048,7 @@ importers: dependencies: '@sentry/api': specifier: 'catalog:' - version: 1.49.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 1.50.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 @@ -4275,8 +4275,8 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sentry/api@1.49.0': - resolution: {integrity: sha512-cvBLSuh3+6CJiVKmKJrh7ZQLXznwvHdI7kG5fryyHak4UhVsyF65kUFb4EqztgaEgPZcMckA04Pa/X+QTW9rdw==} + '@sentry/api@1.50.0': + resolution: {integrity: sha512-kUGaKGSmyJwupr6r6VPyPissG7g3+fGkf7JSy8Pdz8zDD7QOWOeCvDsUOTXgXEojWW+OuYf4maVgH5huuwgclw==} engines: {node: '>=22'} peerDependencies: typescript: '*' @@ -12112,7 +12112,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sentry/api@1.49.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': + '@sentry/api@1.50.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 637ee0bcd..3d9bb4623 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": 1.49.0 + "@sentry/api": 1.50.0 "@sentry/cloudflare": 11.6.0 "@sentry/core": 11.6.0 "@sentry/node": 11.6.0 @@ -168,7 +168,7 @@ minimumReleaseAgeExclude: - "@oxlint/binding-win32-x64-msvc@1.87.0" - oxfmt@0.72.0 - oxlint@1.87.0 - - "@sentry/api@1.49.0" + - "@sentry/api@1.50.0" - miniflare@5.20261101.0-beta - wrangler@4.156.1 - "@sentry/symbolic@14.0.0-alpha.5"