diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 05155a3db..3670f3e46 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -30,7 +30,18 @@ import { getAuthenticatedUser, getOrganization, listOrganizations, + updateAgenticOnboardingStatus, } from "@sentry/toolkit-core/api-client/endpoints/organizations"; +import { + createClientKey, + listClientKeys, + type UpdateClientKeyParams, + updateClientKey, +} from "@sentry/toolkit-core/api-client/endpoints/client-keys"; +import { + linkProjectRepository, + listRepositories, +} from "@sentry/toolkit-core/api-client/endpoints/repositories"; import { addProjectTeam, createProject, @@ -362,26 +373,6 @@ export const DEFAULT_MAX_INLINE_ATTACHMENT_BYTES = 30 * 1024 * 1024; export type TraceItemType = "spans" | "logs" | "tracemetrics"; -type ClientKeyRateLimit = { - window: number; - count: number; -}; - -type ClientKeyDynamicSdkLoaderOptions = { - hasReplay?: boolean; - hasPerformance?: boolean; - hasDebug?: boolean; - hasFeedback?: boolean; - hasLogsAndMetrics?: boolean; -}; - -type UpdateClientKeyRequest = { - name?: string; - isActive?: boolean; - rateLimit?: ClientKeyRateLimit | null; - browserSdkVersion?: string; - dynamicSdkLoaderOptions?: ClientKeyDynamicSdkLoaderOptions; -}; export type TraceItemAttributeType = "string" | "number" | "boolean" | "array"; export type TraceItemAttributeSourceType = "sentry" | "user"; @@ -1768,16 +1759,11 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/onboarding/agent/status/`, - { - method: "POST", - body: JSON.stringify(update), - }, - opts, + return await updateAgenticOnboardingStatus( + this.target(opts?.host), + { organizationSlug, update }, + AgenticOnboardingRunSchema, ); - - return AgenticOnboardingRunSchema.parse(body); } async updateProject( @@ -1813,31 +1799,11 @@ export class SentryApiService { }, opts?: RequestOptions, ) { - let cursor: string | null = null; - const repos: z.infer = []; - - do { - const params = new URLSearchParams(); - params.set("per_page", "100"); - if (query) { - params.set("query", query); - } - if (cursor) { - params.set("cursor", cursor); - } - - const response = await this.request( - apiPath`/organizations/${organizationSlug}/repos/` + - `?${params.toString()}`, - { method: "GET" }, - opts, - ); - const body = await readJsonResponse(response); - repos.push(...RepositoryListSchema.parse(body)); - cursor = getNextCursor(response.headers.get("link")); - } while (cursor); - - return repos; + return await listRepositories( + this.target(opts?.host), + { organizationSlug, query }, + RepositoryListSchema, + ); } async linkProjectRepository( @@ -1854,25 +1820,11 @@ export class SentryApiService { }, opts?: RequestOptions, ) { - const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/code-mappings/bulk/`, - { - method: "POST", - body: JSON.stringify({ - project: projectSlug, - repository, - provider, - mappings: [ - { - stackRoot: "", - sourceRoot: "", - }, - ], - }), - }, - opts, + return await linkProjectRepository( + this.target(opts?.host), + { organizationSlug, projectSlug, repository, provider }, + ProjectRepositoryMappingSchema, ); - return ProjectRepositoryMappingSchema.parse(body); } async listIssueAlertRules( @@ -2516,17 +2468,11 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - apiPath`/projects/${organizationSlug}/${projectSlug}/keys/`, - { - method: "POST", - body: JSON.stringify({ - name, - }), - }, - opts, + return await createClientKey( + this.target(opts?.host), + { organizationSlug, projectSlug, name }, + ClientKeySchema, ); - return ClientKeySchema.parse(body); } /** @@ -2545,48 +2491,14 @@ export class SentryApiService { * @returns Updated client key with DSN information */ async updateClientKey( - { - organizationSlug, - projectSlug, - keyId, - name, - isActive, - rateLimit, - browserSdkVersion, - dynamicSdkLoaderOptions, - }: { - organizationSlug: string; - projectSlug: string; - keyId: string | number; - } & UpdateClientKeyRequest, + params: UpdateClientKeyParams, opts?: RequestOptions, ): Promise { - const updateData: UpdateClientKeyRequest = {}; - if (name !== undefined) { - updateData.name = name; - } - if (isActive !== undefined) { - updateData.isActive = isActive; - } - if (rateLimit !== undefined) { - updateData.rateLimit = rateLimit; - } - if (browserSdkVersion !== undefined) { - updateData.browserSdkVersion = browserSdkVersion; - } - if (dynamicSdkLoaderOptions !== undefined) { - updateData.dynamicSdkLoaderOptions = dynamicSdkLoaderOptions; - } - - const body = await this.requestJSON( - apiPath`/projects/${organizationSlug}/${projectSlug}/keys/${keyId}/`, - { - method: "PUT", - body: JSON.stringify(updateData), - }, - opts, + return await updateClientKey( + this.target(opts?.host), + params, + ClientKeySchema, ); - return ClientKeySchema.parse(body); } /** @@ -2608,12 +2520,11 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - apiPath`/projects/${organizationSlug}/${projectSlug}/keys/`, - undefined, - opts, + return await listClientKeys( + this.target(opts?.host), + { organizationSlug, projectSlug }, + ClientKeyListSchema, ); - return ClientKeyListSchema.parse(body); } /** diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 5c4e3ec6e..fee299168 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -54,11 +54,15 @@ the one exception. Writes, and reads not yet on a `@sentry/api` operation, call the core directly. Wrappers by domain: - `endpoints/organizations`: `getAuthenticatedUser`, `listOrganizations`, - and `getOrganization`. + `getOrganization`, and `updateAgenticOnboardingStatus`. - `endpoints/teams`: `listTeams` and `createTeam`. - `endpoints/projects`: `listProjects`, `getProject`, `createProject`, `updateProject`, and the project's teams (`listProjectTeams`, `addProjectTeam`, `removeProjectTeam`). + - `endpoints/client-keys`: `listClientKeys`, `createClientKey`, and + `updateClientKey`. + - `endpoints/repositories`: `listRepositories` and + `linkProjectRepository`. - `pagination`, `retry`, `transport`, and `response-cache` are described in the sections below. The `node:zlib` body codec stays outside, at `response-cache/codec`, since browsers lack it. diff --git a/packages/toolkit-core/src/api-client/endpoints/client-keys.test.ts b/packages/toolkit-core/src/api-client/endpoints/client-keys.test.ts new file mode 100644 index 000000000..e08f9d5c4 --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/client-keys.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + ORIGIN, + scriptedTarget, +} from "../../test-utils/api-target.js"; +import { + createClientKey, + listClientKeys, + updateClientKey, +} from "./client-keys.js"; + +const KeyZod = z.object({ id: z.string() }); +const project = { organizationSlug: "acme", projectSlug: "web" }; +const keysUrl = `${ORIGIN}/api/0/projects/acme/web/keys/`; + +describe("listClientKeys", () => { + it("reads the project's keys", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse([{ id: "a" }])); + + const keys = await listClientKeys(apiTarget, project, z.array(KeyZod)); + + expect(keys).toEqual([{ id: "a" }]); + expect(request.mock.calls[0]?.[0]).toBe(keysUrl); + }); +}); + +describe("createClientKey", () => { + it("posts the name", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({ id: "a" })); + + await createClientKey(apiTarget, { ...project, name: "Prod" }, KeyZod); + + const [url, init] = request.mock.calls[0] ?? []; + expect(url).toBe(keysUrl); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ name: "Prod" })); + }); +}); + +describe("updateClientKey", () => { + it("puts only the fields that are set, keeping a null rate limit", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({ id: "a" })); + + await updateClientKey( + apiTarget, + { ...project, keyId: 7, isActive: false, rateLimit: null }, + KeyZod, + ); + + const [url, init] = request.mock.calls[0] ?? []; + expect(url).toBe(`${keysUrl}7/`); + expect(init?.method).toBe("PUT"); + expect(init?.body).toBe( + JSON.stringify({ isActive: false, rateLimit: null }), + ); + }); +}); diff --git a/packages/toolkit-core/src/api-client/endpoints/client-keys.ts b/packages/toolkit-core/src/api-client/endpoints/client-keys.ts new file mode 100644 index 000000000..4e6d248ca --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/client-keys.ts @@ -0,0 +1,103 @@ +/** + * Client key (DSN) endpoints for a project. Each wrapper validates the + * response with the schema the host passes, so the host's schema decides the + * result type. + */ + +import { apiPath } from "../api-path.js"; +import type { ApiTarget } from "../core.js"; +import { type ResponseSchema, validateResponse } from "../validate.js"; + +export type ClientKeyProjectParams = { + organizationSlug: string; + projectSlug: string; +}; + +/** Every client key of a project. */ +export async function listClientKeys( + { core, origin }: ApiTarget, + { organizationSlug, projectSlug }: ClientKeyProjectParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/keys/`, + ); + return validateResponse(schema, body); +} + +/** Create a client key, optionally named. */ +export async function createClientKey( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + name, + }: ClientKeyProjectParams & { name?: string }, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/keys/`, + { method: "POST", body: JSON.stringify({ name }) }, + ); + return validateResponse(schema, body); +} + +export type ClientKeyUpdate = { + name?: string; + isActive?: boolean; + /** Caps accepted errors per window; `null` removes the limit. */ + rateLimit?: { window: number; count: number } | null; + browserSdkVersion?: string; + dynamicSdkLoaderOptions?: { + hasReplay?: boolean; + hasPerformance?: boolean; + hasDebug?: boolean; + hasFeedback?: boolean; + hasLogsAndMetrics?: boolean; + }; +}; + +export type UpdateClientKeyParams = ClientKeyProjectParams & { + keyId: string | number; +} & ClientKeyUpdate; + +/** Update a client key. Only the fields that are set are sent. */ +export async function updateClientKey( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + keyId, + name, + isActive, + rateLimit, + browserSdkVersion, + dynamicSdkLoaderOptions, + }: UpdateClientKeyParams, + schema: ResponseSchema, +): Promise { + const data: ClientKeyUpdate = {}; + if (name !== undefined) { + data.name = name; + } + if (isActive !== undefined) { + data.isActive = isActive; + } + if (rateLimit !== undefined) { + data.rateLimit = rateLimit; + } + if (browserSdkVersion !== undefined) { + data.browserSdkVersion = browserSdkVersion; + } + if (dynamicSdkLoaderOptions !== undefined) { + data.dynamicSdkLoaderOptions = dynamicSdkLoaderOptions; + } + const body = await core.requestJson( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/keys/${keyId}/`, + { method: "PUT", body: JSON.stringify(data) }, + ); + return validateResponse(schema, body); +} diff --git a/packages/toolkit-core/src/api-client/endpoints/organizations.test.ts b/packages/toolkit-core/src/api-client/endpoints/organizations.test.ts index cfc1b3ba8..1a8398124 100644 --- a/packages/toolkit-core/src/api-client/endpoints/organizations.test.ts +++ b/packages/toolkit-core/src/api-client/endpoints/organizations.test.ts @@ -12,6 +12,7 @@ import { getAuthenticatedUser, getOrganization, listOrganizations, + updateAgenticOnboardingStatus, } from "./organizations.js"; const OrganizationZod = z.object({ slug: z.string() }); @@ -137,3 +138,22 @@ describe("listOrganizations", () => { ).toBe("0:25:0"); }); }); + +describe("updateAgenticOnboardingStatus", () => { + it("posts the update as the body", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({ id: "1" })); + + await updateAgenticOnboardingStatus( + apiTarget, + { organizationSlug: "acme", update: { step: "done" } }, + z.object({ id: z.string() }), + ); + + const [url, init] = request.mock.calls[0] ?? []; + expect(url).toBe( + `${ORIGIN}/api/0/organizations/acme/onboarding/agent/status/`, + ); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ step: "done" })); + }); +}); diff --git a/packages/toolkit-core/src/api-client/endpoints/organizations.ts b/packages/toolkit-core/src/api-client/endpoints/organizations.ts index 69691c8ca..177231a3c 100644 --- a/packages/toolkit-core/src/api-client/endpoints/organizations.ts +++ b/packages/toolkit-core/src/api-client/endpoints/organizations.ts @@ -76,3 +76,23 @@ export async function getOrganization( ); return validateResponse(schema, body); } + +export type UpdateAgenticOnboardingStatusParams = { + organizationSlug: string; + /** The status report, sent as the JSON body. */ + update: object; +}; + +/** Report progress of an agent-driven onboarding run. */ +export async function updateAgenticOnboardingStatus( + { core, origin }: ApiTarget, + { organizationSlug, update }: UpdateAgenticOnboardingStatusParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/onboarding/agent/status/`, + { method: "POST", body: JSON.stringify(update) }, + ); + return validateResponse(schema, body); +} diff --git a/packages/toolkit-core/src/api-client/endpoints/repositories.test.ts b/packages/toolkit-core/src/api-client/endpoints/repositories.test.ts new file mode 100644 index 000000000..2b45b6ed6 --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/repositories.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + nextLink, + ORIGIN, + scriptedTarget, +} from "../../test-utils/api-target.js"; +import { linkProjectRepository, listRepositories } from "./repositories.js"; + +describe("listRepositories", () => { + it("follows every page with the query", async () => { + const path = "/organizations/acme/repos/"; + const { apiTarget, request } = scriptedTarget( + jsonResponse([{ name: "a" }], nextLink(path, "0:100:0")), + jsonResponse([{ name: "b" }]), + ); + + const repos = await listRepositories( + apiTarget, + { organizationSlug: "acme", query: "sentry" }, + z.array(z.object({ name: z.string() })), + ); + + expect(repos).toEqual([{ name: "a" }, { name: "b" }]); + expect(request.mock.calls.map(([url]) => url)).toEqual([ + `${ORIGIN}/api/0${path}?per_page=100&query=sentry`, + `${ORIGIN}/api/0${path}?per_page=100&query=sentry&cursor=0%3A100%3A0`, + ]); + }); +}); + +describe("linkProjectRepository", () => { + it("posts one root code mapping for the project", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({ ok: true })); + + await linkProjectRepository( + apiTarget, + { + organizationSlug: "acme", + projectSlug: "web", + repository: "acme/web", + provider: "github", + }, + z.object({ ok: z.boolean() }), + ); + + const [url, init] = request.mock.calls[0] ?? []; + expect(url).toBe(`${ORIGIN}/api/0/organizations/acme/code-mappings/bulk/`); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe( + JSON.stringify({ + project: "web", + repository: "acme/web", + provider: "github", + mappings: [{ stackRoot: "", sourceRoot: "" }], + }), + ); + }); +}); diff --git a/packages/toolkit-core/src/api-client/endpoints/repositories.ts b/packages/toolkit-core/src/api-client/endpoints/repositories.ts new file mode 100644 index 000000000..e28e2713b --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/repositories.ts @@ -0,0 +1,83 @@ +/** + * Repository endpoints: the organization's connected repositories and + * linking one to a project with a code mapping. Each wrapper validates the + * response with the schema the host passes, so the host's schema decides the + * result type. + */ + +import { apiPath } from "../api-path.js"; +import { type ApiTarget, nextPageCursor } from "../core.js"; +import { readJsonResponse } from "../response.js"; +import { type ResponseSchema, validateResponse } from "../validate.js"; + +export type ListRepositoriesParams = { + organizationSlug: string; + /** Filters by repository name. */ + query?: string; +}; + +/** Every repository connected to the organization, across all pages. */ +export async function listRepositories( + { core, origin }: ApiTarget, + { organizationSlug, query }: ListRepositoriesParams, + schema: ResponseSchema, +): Promise { + const repos: T[] = []; + let cursor: string | null = null; + do { + const params = new URLSearchParams(); + params.set("per_page", "100"); + if (query) { + params.set("query", query); + } + if (cursor) { + params.set("cursor", cursor); + } + const response = await core.request( + origin, + `${apiPath`/organizations/${organizationSlug}/repos/`}?${params.toString()}`, + { method: "GET" }, + ); + repos.push(...validateResponse(schema, await readJsonResponse(response))); + cursor = nextPageCursor(response); + } while (cursor); + return repos; +} + +export type LinkProjectRepositoryParams = { + organizationSlug: string; + projectSlug: string; + /** The repository's full name, such as `getsentry/sentry`. */ + repository: string; + provider?: string | null; +}; + +/** + * Link a repository to a project with one code mapping whose stack and + * source roots are both the repository root. + */ +export async function linkProjectRepository( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + repository, + provider, + }: LinkProjectRepositoryParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/code-mappings/bulk/`, + { + method: "POST", + body: JSON.stringify({ + project: projectSlug, + repository, + provider, + mappings: [{ stackRoot: "", sourceRoot: "" }], + }), + }, + ); + return validateResponse(schema, body); +}