diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 3670f3e46..d029b1b08 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -38,6 +38,17 @@ import { type UpdateClientKeyParams, updateClientKey, } from "@sentry/toolkit-core/api-client/endpoints/client-keys"; +import { + getDashboard, + listDashboards, +} from "@sentry/toolkit-core/api-client/endpoints/dashboards"; +import { + getEventCommitters, + getRelease, + listReleaseCommits, + listReleaseDeploys, + listReleases, +} from "@sentry/toolkit-core/api-client/endpoints/releases"; import { linkProjectRepository, listRepositories, @@ -1629,30 +1640,12 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise<{ dashboards: DashboardListItem[]; nextCursor: string | null }> { - const queryParams = new URLSearchParams(); - queryParams.set("per_page", String(limit)); - if (query) { - queryParams.set("query", query); - } - if (sortBy) { - queryParams.set("sort", sortBy); - } - if (cursor) { - queryParams.set("cursor", cursor); - } - - const response = await this.request( - apiPath`/organizations/${organizationSlug}/dashboards/` + - `?${queryParams.toString()}`, - undefined, - opts, + const { items, nextCursor } = await listDashboards( + this.target(opts?.host), + { organizationSlug, query, sortBy, limit, cursor }, + DashboardListSchema, ); - const body = await readJsonResponse(response); - - return { - dashboards: DashboardListSchema.parse(body), - nextCursor: getNextCursor(response.headers.get("link")), - }; + return { dashboards: items, nextCursor }; } async getDashboard( @@ -1665,12 +1658,11 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/dashboards/${dashboardId}/`, - undefined, - opts, + return await getDashboard( + this.target(opts?.host), + { organizationSlug, dashboardId }, + DashboardSchema, ); - return DashboardSchema.parse(body); } /** @@ -2565,24 +2557,11 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - if (query) { - searchQuery.set("query", query); - } - if (limit !== undefined) { - searchQuery.set("per_page", String(limit)); - } - - const path = projectSlug - ? apiPath`/projects/${organizationSlug}/${projectSlug}/releases/` - : apiPath`/organizations/${organizationSlug}/releases/`; - - const body = await this.requestJSON( - searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, - undefined, - opts, + return await listReleases( + this.target(opts?.host), + { organizationSlug, projectSlug, query, limit }, + ReleaseListSchema, ); - return ReleaseListSchema.parse(body); } async getReleaseDetails( @@ -2599,20 +2578,11 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - if (includeHealth) { - searchQuery.set("health", "1"); - } - - const path = projectSlugOrId - ? apiPath`/projects/${organizationSlug}/${projectSlugOrId}/releases/${releaseVersion}/` - : apiPath`/organizations/${organizationSlug}/releases/${releaseVersion}/`; - const body = await this.requestJSON( - searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, - undefined, - opts, + return await getRelease( + this.target(opts?.host), + { organizationSlug, releaseVersion, projectSlugOrId, includeHealth }, + ReleaseDetailsSchema, ); - return ReleaseDetailsSchema.parse(body); } async listReleaseDeploys( @@ -2629,24 +2599,11 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - if (projectSlugOrId) { - searchQuery.set( - isNumericId(projectSlugOrId) ? "project" : "projectSlug", - projectSlugOrId, - ); - } - if (limit !== undefined) { - searchQuery.set("per_page", String(limit)); - } - - const path = apiPath`/organizations/${organizationSlug}/releases/${releaseVersion}/deploys/`; - const body = await this.requestJSON( - searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, - undefined, - opts, + return await listReleaseDeploys( + this.target(opts?.host), + { organizationSlug, releaseVersion, projectSlugOrId, limit }, + DeployListSchema, ); - return DeployListSchema.parse(body); } async listReleaseCommits( @@ -2663,20 +2620,11 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - if (limit !== undefined) { - searchQuery.set("per_page", String(limit)); - } - - const path = projectSlugOrId - ? apiPath`/projects/${organizationSlug}/${projectSlugOrId}/releases/${releaseVersion}/commits/` - : apiPath`/organizations/${organizationSlug}/releases/${releaseVersion}/commits/`; - const body = await this.requestJSON( - searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, - undefined, - opts, + return await listReleaseCommits( + this.target(opts?.host), + { organizationSlug, releaseVersion, projectSlugOrId, limit }, + CommitListSchema, ); - return CommitListSchema.parse(body); } /** @@ -2696,12 +2644,12 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - apiPath`/projects/${organizationSlug}/${projectSlug}/events/${eventId}/committers/`, - undefined, - opts, + const { committers } = await getEventCommitters( + this.target(opts?.host), + { organizationSlug, projectSlug, eventId }, + CommittersResponseSchema, ); - return CommittersResponseSchema.parse(body).committers; + return committers; } async listMonitors( diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index fee299168..09be4f8ce 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -27,7 +27,8 @@ the one exception. - `errors` holds `ApiError` and its status subclasses, `createApiError`, and the `UserInputError` and `ConfigurationError` the client throws. The MCP re-exports them from `packages/mcp-core/src/errors.ts`. -- `api-path` builds API paths with every identifier segment encoded. +- `api-path` builds API paths with every identifier segment encoded, and + `withQuery` appends a query string when it is not empty. - `validate` checks a response with any [Standard Schema](https://standardschema.dev) validator the host passes in: Zod in the MCP, valibot in the CLI. Shared code depends only on the @@ -63,6 +64,9 @@ the one exception. `updateClientKey`. - `endpoints/repositories`: `listRepositories` and `linkProjectRepository`. + - `endpoints/dashboards`: `listDashboards` and `getDashboard`. + - `endpoints/releases`: `listReleases`, `getRelease`, + `listReleaseDeploys`, `listReleaseCommits`, and `getEventCommitters`. - `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/api-path.test.ts b/packages/toolkit-core/src/api-client/api-path.test.ts index acd5a81cb..32806421e 100644 --- a/packages/toolkit-core/src/api-client/api-path.test.ts +++ b/packages/toolkit-core/src/api-client/api-path.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { UserInputError } from "./errors.js"; -import { apiPath } from "./api-path.js"; +import { apiPath, withQuery } from "./api-path.js"; /** * Resolves a path the way `fetch` does. Dot-segment normalization happens during URL @@ -89,3 +89,12 @@ describe("apiPath", () => { }); }); }); + +describe("withQuery", () => { + it("appends a non-empty query and leaves the path alone otherwise", () => { + expect(withQuery("/a/", new URLSearchParams({ q: "x y" }))).toBe( + "/a/?q=x+y", + ); + expect(withQuery("/a/", new URLSearchParams())).toBe("/a/"); + }); +}); diff --git a/packages/toolkit-core/src/api-client/api-path.ts b/packages/toolkit-core/src/api-client/api-path.ts index 4443a751a..571d35602 100644 --- a/packages/toolkit-core/src/api-client/api-path.ts +++ b/packages/toolkit-core/src/api-client/api-path.ts @@ -37,3 +37,9 @@ export function apiPath( return acc + literal + encoded; }, ""); } + +/** `path` with `query` appended, or `path` alone when `query` is empty. */ +export function withQuery(path: string, query: URLSearchParams): string { + const queryString = query.toString(); + return queryString ? `${path}?${queryString}` : path; +} diff --git a/packages/toolkit-core/src/api-client/endpoints/dashboards.test.ts b/packages/toolkit-core/src/api-client/endpoints/dashboards.test.ts new file mode 100644 index 000000000..33fd1e530 --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/dashboards.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + nextLink, + ORIGIN, + scriptedTarget, +} from "../../test-utils/api-target.js"; +import { getDashboard, listDashboards } from "./dashboards.js"; + +const DashboardZod = z.object({ id: z.string() }); + +describe("listDashboards", () => { + it("reads one page with the filters and returns the next cursor", async () => { + const path = "/organizations/acme/dashboards/"; + const { apiTarget, request } = scriptedTarget( + jsonResponse([{ id: "1" }], nextLink(path, "0:10:0")), + ); + + const page = await listDashboards( + apiTarget, + { + organizationSlug: "acme", + query: "web", + sortBy: "-dateCreated", + cursor: "0:0:0", + }, + z.array(DashboardZod), + ); + + expect(page).toEqual({ items: [{ id: "1" }], nextCursor: "0:10:0" }); + expect(request.mock.calls[0]?.[0]).toBe( + `${ORIGIN}/api/0${path}?per_page=10&query=web&sort=-dateCreated&cursor=0%3A0%3A0`, + ); + }); +}); + +describe("getDashboard", () => { + it("reads one dashboard", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({ id: "7" })); + + await getDashboard( + apiTarget, + { organizationSlug: "acme", dashboardId: "7" }, + DashboardZod, + ); + + expect(request.mock.calls[0]?.[0]).toBe( + `${ORIGIN}/api/0/organizations/acme/dashboards/7/`, + ); + }); +}); diff --git a/packages/toolkit-core/src/api-client/endpoints/dashboards.ts b/packages/toolkit-core/src/api-client/endpoints/dashboards.ts new file mode 100644 index 000000000..d34099341 --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/dashboards.ts @@ -0,0 +1,65 @@ +/** + * Dashboard endpoints. 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, type ListPage, nextPageCursor } from "../core.js"; +import { readJsonResponse } from "../response.js"; +import { type ResponseSchema, validateResponse } from "../validate.js"; + +export type ListDashboardsParams = { + organizationSlug: string; + /** Filters by title. */ + query?: string | null; + sortBy?: "title" | "-title" | "dateCreated" | "-dateCreated"; + /** Page size. Default: 10. */ + limit?: number; + /** Resumes from a previous call's `nextCursor`. */ + cursor?: string | null; +}; + +/** One page of the organization's dashboards. */ +export async function listDashboards( + { core, origin }: ApiTarget, + { organizationSlug, query, sortBy, limit = 10, cursor }: ListDashboardsParams, + schema: ResponseSchema, +): Promise> { + const params = new URLSearchParams(); + params.set("per_page", String(limit)); + if (query) { + params.set("query", query); + } + if (sortBy) { + params.set("sort", sortBy); + } + if (cursor) { + params.set("cursor", cursor); + } + const response = await core.request( + origin, + `${apiPath`/organizations/${organizationSlug}/dashboards/`}?${params.toString()}`, + ); + return { + items: validateResponse(schema, await readJsonResponse(response)), + nextCursor: nextPageCursor(response), + }; +} + +export type GetDashboardParams = { + organizationSlug: string; + dashboardId: string; +}; + +/** One dashboard with its widgets. */ +export async function getDashboard( + { core, origin }: ApiTarget, + { organizationSlug, dashboardId }: GetDashboardParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/dashboards/${dashboardId}/`, + ); + return validateResponse(schema, body); +} diff --git a/packages/toolkit-core/src/api-client/endpoints/organizations.ts b/packages/toolkit-core/src/api-client/endpoints/organizations.ts index 177231a3c..27d9a2634 100644 --- a/packages/toolkit-core/src/api-client/endpoints/organizations.ts +++ b/packages/toolkit-core/src/api-client/endpoints/organizations.ts @@ -6,7 +6,7 @@ */ import { listOrganizations as listOrganizationsOperation } from "@sentry/api"; -import { apiPath } from "../api-path.js"; +import { apiPath, withQuery } from "../api-path.js"; import { type ApiTarget, type ListPage, listPage } from "../core.js"; import { type ResponseSchema, validateResponse } from "../validate.js"; @@ -68,11 +68,9 @@ export async function getOrganization( if (params.detailed === false) { query.set("detailed", "0"); } - const queryString = query.toString(); - const path = apiPath`/organizations/${params.organizationSlug}/`; const body = await core.requestJson( origin, - `${path}${queryString ? `?${queryString}` : ""}`, + withQuery(apiPath`/organizations/${params.organizationSlug}/`, query), ); return validateResponse(schema, body); } diff --git a/packages/toolkit-core/src/api-client/endpoints/projects.ts b/packages/toolkit-core/src/api-client/endpoints/projects.ts index 4805dff34..1d309f1e0 100644 --- a/packages/toolkit-core/src/api-client/endpoints/projects.ts +++ b/packages/toolkit-core/src/api-client/endpoints/projects.ts @@ -5,7 +5,7 @@ */ import { listOrganizationProjects } from "@sentry/api"; -import { apiPath } from "../api-path.js"; +import { apiPath, withQuery } from "../api-path.js"; import { type ApiTarget, type ListPage, @@ -66,11 +66,12 @@ export async function getProject( if (collapse) { query.set("collapse", collapse); } - const queryString = query.toString(); - const path = apiPath`/projects/${organizationSlug}/${projectSlugOrId}/`; const body = await core.requestJson( origin, - `${path}${queryString ? `?${queryString}` : ""}`, + withQuery( + apiPath`/projects/${organizationSlug}/${projectSlugOrId}/`, + query, + ), ); return validateResponse(schema, body); } diff --git a/packages/toolkit-core/src/api-client/endpoints/releases.test.ts b/packages/toolkit-core/src/api-client/endpoints/releases.test.ts new file mode 100644 index 000000000..a364f91de --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/releases.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + ORIGIN, + scriptedTarget, +} from "../../test-utils/api-target.js"; +import { + getEventCommitters, + getRelease, + listReleaseCommits, + listReleaseDeploys, + listReleases, +} from "./releases.js"; + +const AnyJson = z.unknown(); +const API = `${ORIGIN}/api/0`; + +/** The URL a wrapper call requested. */ +async function requestedUrl( + call: (target: ReturnType["apiTarget"]) => unknown, +) { + const { apiTarget, request } = scriptedTarget(jsonResponse([])); + await call(apiTarget); + return request.mock.calls[0]?.[0]; +} + +describe("listReleases", () => { + it.each([ + [{}, `${API}/organizations/acme/releases/`], + [ + { projectSlug: "web", query: "1.0", limit: 5 }, + `${API}/projects/acme/web/releases/?query=1.0&per_page=5`, + ], + ])("sends %o to %s", async (params, url) => { + expect( + await requestedUrl((target) => + listReleases(target, { organizationSlug: "acme", ...params }, AnyJson), + ), + ).toBe(url); + }); +}); + +describe("getRelease", () => { + it.each([ + [{}, `${API}/organizations/acme/releases/1.0%2Bbuild/`], + [ + { projectSlugOrId: "web", includeHealth: true }, + `${API}/projects/acme/web/releases/1.0%2Bbuild/?health=1`, + ], + ])("sends %o to %s", async (params, url) => { + expect( + await requestedUrl((target) => + getRelease( + target, + { organizationSlug: "acme", releaseVersion: "1.0+build", ...params }, + AnyJson, + ), + ), + ).toBe(url); + }); +}); + +describe("listReleaseDeploys", () => { + it.each([ + ["42", "project=42"], + ["web", "projectSlug=web"], + ])("narrows project %s with %s", async (projectSlugOrId, query) => { + expect( + await requestedUrl((target) => + listReleaseDeploys( + target, + { + organizationSlug: "acme", + releaseVersion: "1.0", + projectSlugOrId, + limit: 3, + }, + AnyJson, + ), + ), + ).toBe( + `${API}/organizations/acme/releases/1.0/deploys/?${query}&per_page=3`, + ); + }); +}); + +describe("listReleaseCommits", () => { + it.each([ + [{}, `${API}/organizations/acme/releases/1.0/commits/`], + [ + { projectSlugOrId: "web", limit: 2 }, + `${API}/projects/acme/web/releases/1.0/commits/?per_page=2`, + ], + ])("sends %o to %s", async (params, url) => { + expect( + await requestedUrl((target) => + listReleaseCommits( + target, + { organizationSlug: "acme", releaseVersion: "1.0", ...params }, + AnyJson, + ), + ), + ).toBe(url); + }); +}); + +describe("getEventCommitters", () => { + it("reads the event's committers", async () => { + expect( + await requestedUrl((target) => + getEventCommitters( + target, + { organizationSlug: "acme", projectSlug: "web", eventId: "abc" }, + AnyJson, + ), + ), + ).toBe(`${API}/projects/acme/web/events/abc/committers/`); + }); +}); diff --git a/packages/toolkit-core/src/api-client/endpoints/releases.ts b/packages/toolkit-core/src/api-client/endpoints/releases.ts new file mode 100644 index 000000000..840097115 --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/releases.ts @@ -0,0 +1,138 @@ +/** + * Release endpoints: releases, their deploys and commits, and an event's + * suspect committers. With a project, release reads go to the project's + * endpoints, otherwise to the organization's. Each wrapper validates the + * response with the schema the host passes, so the host's schema decides the + * result type. + */ + +import { apiPath, withQuery } from "../api-path.js"; +import type { ApiTarget } from "../core.js"; +import { type ResponseSchema, validateResponse } from "../validate.js"; + +export type ListReleasesParams = { + organizationSlug: string; + projectSlug?: string; + /** Filters by version. */ + query?: string; + /** Page size. */ + limit?: number; +}; + +/** The first page of releases for the organization or one project. */ +export async function listReleases( + { core, origin }: ApiTarget, + { organizationSlug, projectSlug, query, limit }: ListReleasesParams, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (query) { + params.set("query", query); + } + if (limit !== undefined) { + params.set("per_page", String(limit)); + } + const path = projectSlug + ? apiPath`/projects/${organizationSlug}/${projectSlug}/releases/` + : apiPath`/organizations/${organizationSlug}/releases/`; + const body = await core.requestJson(origin, withQuery(path, params)); + return validateResponse(schema, body); +} + +export type ReleaseParams = { + organizationSlug: string; + releaseVersion: string; + projectSlugOrId?: string; +}; + +/** One release, optionally with its health data. */ +export async function getRelease( + { core, origin }: ApiTarget, + { + organizationSlug, + releaseVersion, + projectSlugOrId, + includeHealth, + }: ReleaseParams & { includeHealth?: boolean }, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (includeHealth) { + params.set("health", "1"); + } + const path = projectSlugOrId + ? apiPath`/projects/${organizationSlug}/${projectSlugOrId}/releases/${releaseVersion}/` + : apiPath`/organizations/${organizationSlug}/releases/${releaseVersion}/`; + const body = await core.requestJson(origin, withQuery(path, params)); + return validateResponse(schema, body); +} + +/** + * The first page of a release's deploys. A project narrows them, by ID when + * it is numeric and by slug otherwise. + */ +export async function listReleaseDeploys( + { core, origin }: ApiTarget, + { + organizationSlug, + releaseVersion, + projectSlugOrId, + limit, + }: ReleaseParams & { limit?: number }, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (projectSlugOrId) { + params.set( + /^\d+$/.test(projectSlugOrId) ? "project" : "projectSlug", + projectSlugOrId, + ); + } + if (limit !== undefined) { + params.set("per_page", String(limit)); + } + const path = apiPath`/organizations/${organizationSlug}/releases/${releaseVersion}/deploys/`; + const body = await core.requestJson(origin, withQuery(path, params)); + return validateResponse(schema, body); +} + +/** The first page of a release's commits. */ +export async function listReleaseCommits( + { core, origin }: ApiTarget, + { + organizationSlug, + releaseVersion, + projectSlugOrId, + limit, + }: ReleaseParams & { limit?: number }, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (limit !== undefined) { + params.set("per_page", String(limit)); + } + const path = projectSlugOrId + ? apiPath`/projects/${organizationSlug}/${projectSlugOrId}/releases/${releaseVersion}/commits/` + : apiPath`/organizations/${organizationSlug}/releases/${releaseVersion}/commits/`; + const body = await core.requestJson(origin, withQuery(path, params)); + return validateResponse(schema, body); +} + +export type GetEventCommittersParams = { + organizationSlug: string; + projectSlug: string; + eventId: string; +}; + +/** The suspect commits of an event's issue, grouped by committer. */ +export async function getEventCommitters( + { core, origin }: ApiTarget, + { organizationSlug, projectSlug, eventId }: GetEventCommittersParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/events/${eventId}/committers/`, + ); + return validateResponse(schema, body); +}