From f6b9b726c6ac151cb66abf6debb3f7089b691a1f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 19:24:46 +0000 Subject: [PATCH 1/2] fix(dashboard): Align revision schema with actual API response The DashboardRevisionSchema expected numeric id, version, and dashboardId fields, but the Sentry API returns string id, title, createdBy, and source. This caused Zod validation failures when running `sentry dashboard revisions`. - Change id from z.number() to z.string() - Remove non-existent version and dashboardId fields - Add title, createdBy (nullable), and source fields - Update restore command to accept string revision IDs - Update revisions formatter to show title and author columns Fixes #935 Co-authored-by: Sergiy Dybskiy --- src/commands/dashboard/restore.ts | 12 +++---- src/commands/dashboard/revisions.ts | 19 ++++++---- src/lib/api/dashboards.ts | 5 +-- src/types/dashboard.ts | 18 ++++++++-- test/commands/dashboard/restore.test.ts | 28 +++++++-------- test/commands/dashboard/revisions.test.ts | 42 ++++++++++++++++------- 6 files changed, 80 insertions(+), 44 deletions(-) diff --git a/src/commands/dashboard/restore.ts b/src/commands/dashboard/restore.ts index ffbdad123..f1203f68b 100644 --- a/src/commands/dashboard/restore.ts +++ b/src/commands/dashboard/restore.ts @@ -23,7 +23,7 @@ import { } from "./resolve.js"; type RestoreFlags = { - readonly revision: number; + readonly revision: string; readonly json: boolean; readonly fields?: string[]; }; @@ -31,7 +31,7 @@ type RestoreFlags = { type RestoreResult = { dashboard: DashboardDetail; orgSlug: string; - revisionId: number; + revisionId: string; }; function formatRestoreHuman(result: RestoreResult): string { @@ -80,14 +80,14 @@ export const restoreCommand = buildCommand({ revision: { kind: "parsed", parse: (value: string) => { - const num = Number.parseInt(value, 10); - if (Number.isNaN(num) || num < 1) { + const revision = value.trim(); + if (!revision) { throw new ValidationError( - "--revision must be a positive integer.", + "--revision must be a non-empty revision ID.", "revision" ); } - return num; + return revision; }, brief: "Revision ID to restore", }, diff --git a/src/commands/dashboard/revisions.ts b/src/commands/dashboard/revisions.ts index 708ba44c2..398c3ced0 100644 --- a/src/commands/dashboard/revisions.ts +++ b/src/commands/dashboard/revisions.ts @@ -64,19 +64,26 @@ function formatRevisionsHuman(result: RevisionsResult): string { type RevisionRow = { id: string; - version: string; + title: string; + author: string; created: string; }; const rows: RevisionRow[] = result.revisions.map((r) => ({ - id: String(r.id), - version: String(r.version), + id: r.id, + title: escapeMarkdownCell(r.title), + author: + r.createdBy?.name ?? + r.createdBy?.email ?? + r.createdBy?.id ?? + "—", created: `${escapeMarkdownCell(formatRelativeTime(r.dateCreated))}\n${colorTag("muted", r.dateCreated)}`, })); const columns: Column[] = [ { header: "ID", value: (r) => r.id }, - { header: "VERSION", value: (r) => r.version }, + { header: "TITLE", value: (r) => r.title }, + { header: "AUTHOR", value: (r) => r.author }, { header: "CREATED", value: (r) => r.created }, ]; @@ -112,7 +119,7 @@ export const revisionsCommand = buildCommand({ brief: "List dashboard revisions", fullDescription: "List revision history for a Sentry dashboard.\n\n" + - "Shows all saved revisions with their version numbers and timestamps.\n" + + "Shows saved revisions with their IDs, titles, authors, and timestamps.\n" + "Use `sentry dashboard restore` to revert to a previous revision.\n\n" + "Examples:\n" + " sentry dashboard revisions 12345\n" + @@ -231,7 +238,7 @@ export const revisionsCommand = buildCommand({ return { hint: `Showing ${trimmed.length} revision(s) for dashboard ${dashboardId}.${navStr}\n` + - `Restore: sentry dashboard restore ${orgSlug}/ ${dashboardId} \n` + + `Restore: sentry dashboard restore ${orgSlug}/ ${dashboardId} --revision \n` + `Dashboard: ${url}`, }; }, diff --git a/src/lib/api/dashboards.ts b/src/lib/api/dashboards.ts index bdf9427fd..c3d8447f8 100644 --- a/src/lib/api/dashboards.ts +++ b/src/lib/api/dashboards.ts @@ -221,12 +221,13 @@ export async function listDashboardRevisionsPaginated( export async function restoreDashboardRevision( orgSlug: string, dashboardId: string, - revisionId: number + revisionId: string ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); + const encodedRevisionId = encodeURIComponent(revisionId); const { data } = await apiRequestToRegion( regionUrl, - `/organizations/${orgSlug}/dashboards/${dashboardId}/revisions/${revisionId}/`, + `/organizations/${orgSlug}/dashboards/${dashboardId}/revisions/${encodedRevisionId}/`, { method: "POST", schema: DashboardDetailSchema } ); return data; diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts index d3d6c3794..07b83188e 100644 --- a/src/types/dashboard.ts +++ b/src/types/dashboard.ts @@ -1036,13 +1036,25 @@ export const TABLE_DISPLAY_TYPES = new Set(["table", "top_n"]); // Dashboard revision types // --------------------------------------------------------------------------- +/** Schema for the createdBy field in a dashboard revision */ +const DashboardRevisionCreatedBySchema = z + .object({ + id: z.string().optional(), + name: z.string().nullish(), + email: z.string().nullish(), + avatarType: z.string().nullish(), + avatarUrl: z.string().nullish(), + }) + .passthrough(); + /** Schema for a dashboard revision (from GET /dashboards/{id}/revisions/) */ export const DashboardRevisionSchema = z .object({ - id: z.number(), - version: z.number(), + id: z.string(), + title: z.string(), dateCreated: z.string(), - dashboardId: z.number(), + createdBy: DashboardRevisionCreatedBySchema.nullable(), + source: z.string(), }) .passthrough(); diff --git a/test/commands/dashboard/restore.test.ts b/test/commands/dashboard/restore.test.ts index a4a8fc94d..7ae6bdcd4 100644 --- a/test/commands/dashboard/restore.test.ts +++ b/test/commands/dashboard/restore.test.ts @@ -34,7 +34,7 @@ function createMockContext(cwd = "/tmp") { } type RestoreFlags = { - readonly revision: number; + readonly revision: string; readonly json?: boolean; readonly fields?: string[]; }; @@ -42,7 +42,7 @@ type RestoreFlags = { function defaultFlags(overrides: Partial = {}): RestoreFlags { return { json: false, - revision: 1, + revision: "1", ...overrides, }; } @@ -110,26 +110,26 @@ describe("dashboard restore command", () => { test("restores dashboard and outputs JSON", async () => { const { context, stdoutWrite } = createMockContext(); const func = await restoreCommand.loader(); - await func.call(context, defaultFlags({ json: true, revision: 42 }), "123"); + await func.call(context, defaultFlags({ json: true, revision: "42" }), "123"); expect(restoreDashboardRevisionSpy).toHaveBeenCalledWith( "test-org", "123", - 42 + "42" ); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); const parsed = JSON.parse(output); expect(parsed.dashboard.id).toBe("123"); expect(parsed.dashboard.title).toBe("My Dashboard"); - expect(parsed.revisionId).toBe(42); + expect(parsed.revisionId).toBe("42"); expect(parsed.orgSlug).toBe("test-org"); }); test("restores dashboard and outputs human-readable format", async () => { const { context, stdoutWrite } = createMockContext(); const func = await restoreCommand.loader(); - await func.call(context, defaultFlags({ revision: 42 }), "123"); + await func.call(context, defaultFlags({ revision: "42" }), "123"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("Restored dashboard"); @@ -157,13 +157,13 @@ describe("dashboard restore command", () => { test("uses dashboard ID from positional argument", async () => { const { context } = createMockContext(); const func = await restoreCommand.loader(); - await func.call(context, defaultFlags({ json: true, revision: 5 }), "456"); + await func.call(context, defaultFlags({ json: true, revision: "5" }), "456"); expect(resolveDashboardIdSpy).toHaveBeenCalledWith("test-org", "456"); expect(restoreDashboardRevisionSpy).toHaveBeenCalledWith( "test-org", "123", - 5 + "5" ); }); @@ -172,7 +172,7 @@ describe("dashboard restore command", () => { const func = await restoreCommand.loader(); await func.call( context, - defaultFlags({ json: true, revision: 10 }), + defaultFlags({ json: true, revision: "10" }), "my-org/", "789" ); @@ -185,7 +185,7 @@ describe("dashboard restore command", () => { const func = await restoreCommand.loader(); await func.call( context, - defaultFlags({ json: true, revision: 3 }), + defaultFlags({ json: true, revision: "3" }), "My Dashboard Title" ); @@ -208,12 +208,12 @@ describe("dashboard restore command", () => { // We can't easily test the flag parsing directly, but we can verify // the API is called with the correct revision when valid - await func.call(context, defaultFlags({ revision: 1 }), "123"); + await func.call(context, defaultFlags({ revision: "1" }), "123"); expect(restoreDashboardRevisionSpy).toHaveBeenCalledWith( "test-org", "123", - 1 + "1" ); }); @@ -225,7 +225,7 @@ describe("dashboard restore command", () => { const func = await restoreCommand.loader(); await expect( - func.call(context, defaultFlags({ revision: 999 }), "123") + func.call(context, defaultFlags({ revision: "999" }), "123") ).rejects.toThrow(); }); @@ -236,7 +236,7 @@ describe("dashboard restore command", () => { test("shows progress message during restore", async () => { const { context } = createMockContext(); const func = await restoreCommand.loader(); - await func.call(context, defaultFlags({ revision: 42 }), "123"); + await func.call(context, defaultFlags({ revision: "42" }), "123"); expect(withProgressSpy).toHaveBeenCalled(); const [opts] = withProgressSpy.mock.calls[0] as [ diff --git a/test/commands/dashboard/revisions.test.ts b/test/commands/dashboard/revisions.test.ts index 3f42840c2..45ee1f752 100644 --- a/test/commands/dashboard/revisions.test.ts +++ b/test/commands/dashboard/revisions.test.ts @@ -55,24 +55,39 @@ function defaultFlags(overrides: Partial = {}): RevisionsFlags { // --------------------------------------------------------------------------- const REVISION_A: DashboardRevision = { - id: 1, - version: 1, + id: "1", + title: "My Dashboard", dateCreated: "2026-01-15T10:00:00Z", - dashboardId: 123, + createdBy: { + id: "u1", + name: "Alice", + email: "alice@example.com", + avatarType: "letter_avatar", + avatarUrl: null, + }, + source: "ui", }; const REVISION_B: DashboardRevision = { - id: 2, - version: 2, + id: "2", + title: "My Dashboard (updated)", dateCreated: "2026-02-20T12:00:00Z", - dashboardId: 123, + createdBy: { + id: "u2", + name: "Bob", + email: "bob@example.com", + avatarType: "letter_avatar", + avatarUrl: null, + }, + source: "ui", }; const REVISION_C: DashboardRevision = { - id: 3, - version: 3, + id: "3", + title: "My Dashboard (v3)", dateCreated: "2026-03-01T08:00:00Z", - dashboardId: 123, + createdBy: null, + source: "api", }; // --------------------------------------------------------------------------- @@ -149,9 +164,9 @@ describe("dashboard revisions command", () => { expect(parsed).toHaveProperty("hasMore", false); expect(parsed).toHaveProperty("hasPrev", false); expect(parsed.data).toHaveLength(2); - expect(parsed.data[0].id).toBe(1); - expect(parsed.data[0].version).toBe(1); - expect(parsed.data[1].id).toBe(2); + expect(parsed.data[0].id).toBe("1"); + expect(parsed.data[0].title).toBe("My Dashboard"); + expect(parsed.data[1].id).toBe("2"); }); test("outputs { data: [], hasMore: false } when no revisions exist", async () => { @@ -185,7 +200,8 @@ describe("dashboard revisions command", () => { const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("ID"); - expect(output).toContain("VERSION"); + expect(output).toContain("TITLE"); + expect(output).toContain("AUTHOR"); expect(output).toContain("CREATED"); }); From 45c6b5795579083e9a5cc6e2b875b5a2623f3cba Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 21 May 2026 19:35:24 +0000 Subject: [PATCH 2/2] style: fix biome formatting in revisions.ts and restore.test.ts --- src/commands/dashboard/revisions.ts | 6 +----- test/commands/dashboard/restore.test.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/commands/dashboard/revisions.ts b/src/commands/dashboard/revisions.ts index 398c3ced0..ed16dde50 100644 --- a/src/commands/dashboard/revisions.ts +++ b/src/commands/dashboard/revisions.ts @@ -72,11 +72,7 @@ function formatRevisionsHuman(result: RevisionsResult): string { const rows: RevisionRow[] = result.revisions.map((r) => ({ id: r.id, title: escapeMarkdownCell(r.title), - author: - r.createdBy?.name ?? - r.createdBy?.email ?? - r.createdBy?.id ?? - "—", + author: r.createdBy?.name ?? r.createdBy?.email ?? r.createdBy?.id ?? "—", created: `${escapeMarkdownCell(formatRelativeTime(r.dateCreated))}\n${colorTag("muted", r.dateCreated)}`, })); diff --git a/test/commands/dashboard/restore.test.ts b/test/commands/dashboard/restore.test.ts index 7ae6bdcd4..17881ec0c 100644 --- a/test/commands/dashboard/restore.test.ts +++ b/test/commands/dashboard/restore.test.ts @@ -110,7 +110,11 @@ describe("dashboard restore command", () => { test("restores dashboard and outputs JSON", async () => { const { context, stdoutWrite } = createMockContext(); const func = await restoreCommand.loader(); - await func.call(context, defaultFlags({ json: true, revision: "42" }), "123"); + await func.call( + context, + defaultFlags({ json: true, revision: "42" }), + "123" + ); expect(restoreDashboardRevisionSpy).toHaveBeenCalledWith( "test-org", @@ -157,7 +161,11 @@ describe("dashboard restore command", () => { test("uses dashboard ID from positional argument", async () => { const { context } = createMockContext(); const func = await restoreCommand.loader(); - await func.call(context, defaultFlags({ json: true, revision: "5" }), "456"); + await func.call( + context, + defaultFlags({ json: true, revision: "5" }), + "456" + ); expect(resolveDashboardIdSpy).toHaveBeenCalledWith("test-org", "456"); expect(restoreDashboardRevisionSpy).toHaveBeenCalledWith(