From 455901b0dc594a6cd4132682c95b52fbe2a2c5d3 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 13 Mar 2026 10:39:38 +0100 Subject: [PATCH 01/10] feat(dashboard): add dashboard list, view, and create commands Dashboard API functions now live in src/lib/api/dashboards.ts following the domain module pattern from #405. Co-Authored-By: Claude Opus 4.6 --- src/app.ts | 6 + src/commands/dashboard/create.ts | 296 +++++++++++++++ src/commands/dashboard/index.ts | 22 ++ src/commands/dashboard/list.ts | 128 +++++++ src/commands/dashboard/resolve.ts | 121 ++++++ src/commands/dashboard/view.ts | 88 +++++ src/lib/api-client.ts | 8 +- src/lib/api/dashboards.ts | 94 +++++ src/lib/formatters/human.ts | 81 +++++ src/lib/sentry-urls.ts | 32 ++ src/types/dashboard.ts | 587 ++++++++++++++++++++++++++++++ src/types/index.ts | 16 +- test/types/dashboard.test.ts | 551 ++++++++++++++++++++++++++++ 13 files changed, 2027 insertions(+), 3 deletions(-) create mode 100644 src/commands/dashboard/create.ts create mode 100644 src/commands/dashboard/index.ts create mode 100644 src/commands/dashboard/list.ts create mode 100644 src/commands/dashboard/resolve.ts create mode 100644 src/commands/dashboard/view.ts create mode 100644 src/lib/api/dashboards.ts create mode 100644 src/types/dashboard.ts create mode 100644 test/types/dashboard.test.ts diff --git a/src/app.ts b/src/app.ts index a3350b1ee..009b4a309 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,8 @@ import { apiCommand } from "./commands/api.js"; import { authRoute } from "./commands/auth/index.js"; import { whoamiCommand } from "./commands/auth/whoami.js"; import { cliRoute } from "./commands/cli/index.js"; +import { dashboardRoute } from "./commands/dashboard/index.js"; +import { listCommand as dashboardListCommand } from "./commands/dashboard/list.js"; import { eventRoute } from "./commands/event/index.js"; import { helpCommand } from "./commands/help.js"; import { initCommand } from "./commands/init.js"; @@ -47,6 +49,7 @@ import { error as errorColor, warning } from "./lib/formatters/colors.js"; * Used to suggest the correct command when users type e.g. `sentry projects view cli`. */ const PLURAL_TO_SINGULAR: Record = { + dashboards: "dashboard", issues: "issue", orgs: "org", projects: "project", @@ -64,6 +67,7 @@ export const routes = buildRouteMap({ help: helpCommand, auth: authRoute, cli: cliRoute, + dashboard: dashboardRoute, org: orgRoute, project: projectRoute, repo: repoRoute, @@ -77,6 +81,7 @@ export const routes = buildRouteMap({ init: initCommand, api: apiCommand, schema: schemaCommand, + dashboards: dashboardListCommand, issues: issueListCommand, orgs: orgListCommand, projects: projectListCommand, @@ -95,6 +100,7 @@ export const routes = buildRouteMap({ "sentry is a command-line interface for interacting with Sentry. " + "It provides commands for authentication, viewing issues, and making API calls.", hideRoute: { + dashboards: true, issues: true, orgs: true, projects: true, diff --git a/src/commands/dashboard/create.ts b/src/commands/dashboard/create.ts new file mode 100644 index 000000000..99c43c8dd --- /dev/null +++ b/src/commands/dashboard/create.ts @@ -0,0 +1,296 @@ +/** + * sentry dashboard create + * + * Create a new dashboard in a Sentry organization. + */ + +import type { SentryContext } from "../../context.js"; +import { createDashboard, getProject } from "../../lib/api-client.js"; +import { + type ParsedOrgProject, + parseOrgProjectArg, +} from "../../lib/arg-parsing.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; +import { formatDashboardCreated } from "../../lib/formatters/human.js"; +import { + fetchProjectId, + resolveAllTargets, + resolveOrg, + resolveProjectBySlug, + toNumericId, +} from "../../lib/resolve-target.js"; +import { buildDashboardUrl } from "../../lib/sentry-urls.js"; +import { + assignDefaultLayout, + type DashboardDetail, + type DashboardWidget, + DISPLAY_TYPES, + parseAggregate, + parseSortExpression, + parseWidgetInput, + prepareWidgetQueries, +} from "../../types/dashboard.js"; + +type CreateFlags = { + readonly "widget-title"?: string; + readonly "widget-display"?: string; + readonly "widget-dataset"?: string; + readonly "widget-query"?: string[]; + readonly "widget-where"?: string; + readonly "widget-group-by"?: string[]; + readonly "widget-sort"?: string; + readonly "widget-limit"?: number; + readonly json: boolean; + readonly fields?: string[]; +}; + +type CreateResult = DashboardDetail & { url: string }; + +/** + * Parse array positional args for `dashboard create`. + * + * Handles: + * - `` — title only (auto-detect org/project) + * - `<target> <title>` — explicit target + title + */ +function parsePositionalArgs(args: string[]): { + title: string; + targetArg: string | undefined; +} { + if (args.length === 0) { + throw new ValidationError("Dashboard title is required.", "title"); + } + if (args.length === 1) { + return { title: args[0] as string, targetArg: undefined }; + } + // Two args: first is target, second is title + return { title: args[1] as string, targetArg: args[0] as string }; +} + +/** Result of resolving org + project IDs from the parsed target */ +type ResolvedDashboardTarget = { + orgSlug: string; + projectIds: number[]; +}; + +/** Enrich targets that lack a projectId by calling the project API */ +async function enrichTargetProjectIds( + targets: { org: string; project: string; projectId?: number }[] +): Promise<number[]> { + const enriched = await Promise.all( + targets.map(async (t) => { + if (t.projectId !== undefined) { + return t.projectId; + } + try { + const info = await getProject(t.org, t.project); + return toNumericId(info.id); + } catch { + return; + } + }) + ); + return enriched.filter((id): id is number => id !== undefined); +} + +/** Resolve org and project IDs from the parsed target argument */ +async function resolveDashboardTarget( + parsed: ParsedOrgProject, + cwd: string +): Promise<ResolvedDashboardTarget> { + switch (parsed.type) { + case "explicit": { + const pid = await fetchProjectId(parsed.org, parsed.project); + return { + orgSlug: parsed.org, + projectIds: pid !== undefined ? [pid] : [], + }; + } + case "org-all": + return { orgSlug: parsed.org, projectIds: [] }; + + case "project-search": { + const found = await resolveProjectBySlug( + parsed.projectSlug, + "sentry dashboard create <org>/<project> <title>" + ); + const pid = await fetchProjectId(found.org, found.project); + return { + orgSlug: found.org, + projectIds: pid !== undefined ? [pid] : [], + }; + } + case "auto-detect": { + const result = await resolveAllTargets({ cwd }); + if (result.targets.length === 0) { + const resolved = await resolveOrg({ cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + "sentry dashboard create <org>/ <title>" + ); + } + return { orgSlug: resolved.org, projectIds: [] }; + } + const orgSlug = (result.targets[0] as (typeof result.targets)[0]).org; + const projectIds = await enrichTargetProjectIds(result.targets); + return { orgSlug, projectIds }; + } + default: { + const _exhaustive: never = parsed; + throw new Error( + `Unexpected parsed type: ${(_exhaustive as { type: string }).type}` + ); + } + } +} + +/** Build an inline widget from --widget-* flags */ +function buildInlineWidget(flags: CreateFlags): DashboardWidget { + if (!flags["widget-title"]) { + throw new ValidationError( + "Missing --widget-title. Both --widget-title and --widget-display are required for inline widgets.\n\n" + + "Example:\n" + + " sentry dashboard create 'My Dashboard' --widget-title \"Error Count\" --widget-display big_number --widget-query count", + "widget-title" + ); + } + + const aggregates = (flags["widget-query"] ?? ["count"]).map(parseAggregate); + const columns = flags["widget-group-by"] ?? []; + const orderby = flags["widget-sort"] + ? parseSortExpression(flags["widget-sort"]) + : undefined; + + const rawWidget = { + title: flags["widget-title"], + displayType: flags["widget-display"] as string, + ...(flags["widget-dataset"] && { widgetType: flags["widget-dataset"] }), + queries: [ + { + aggregates, + columns, + conditions: flags["widget-where"] ?? "", + ...(orderby && { orderby }), + name: "", + }, + ], + ...(flags["widget-limit"] !== undefined && { + limit: flags["widget-limit"], + }), + }; + return prepareWidgetQueries(parseWidgetInput(rawWidget)); +} + +export const createCommand = buildCommand({ + docs: { + brief: "Create a dashboard", + fullDescription: + "Create a new Sentry dashboard.\n\n" + + "Examples:\n" + + " sentry dashboard create 'My Dashboard'\n" + + " sentry dashboard create my-org/ 'My Dashboard'\n" + + " sentry dashboard create my-org/my-project 'My Dashboard'\n\n" + + "With an inline widget:\n" + + " sentry dashboard create 'My Dashboard' \\\n" + + ' --widget-title "Error Count" --widget-display big_number --widget-query count', + }, + output: { + json: true, + human: formatDashboardCreated, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "[<org/project>] <title>", + parse: String, + }, + }, + flags: { + "widget-title": { + kind: "parsed", + parse: String, + brief: "Inline widget title", + optional: true, + }, + "widget-display": { + kind: "parsed", + parse: String, + brief: "Inline widget display type (line, bar, table, big_number, ...)", + optional: true, + }, + "widget-dataset": { + kind: "parsed", + parse: String, + brief: "Inline widget dataset (default: spans)", + optional: true, + }, + "widget-query": { + kind: "parsed", + parse: String, + brief: "Inline widget aggregate (e.g. count, p95:span.duration)", + variadic: true, + optional: true, + }, + "widget-where": { + kind: "parsed", + parse: String, + brief: "Inline widget search conditions filter", + optional: true, + }, + "widget-group-by": { + kind: "parsed", + parse: String, + brief: "Inline widget group-by column (repeatable)", + variadic: true, + optional: true, + }, + "widget-sort": { + kind: "parsed", + parse: String, + brief: "Inline widget order by (prefix - for desc)", + optional: true, + }, + "widget-limit": { + kind: "parsed", + parse: numberParser, + brief: "Inline widget result limit", + optional: true, + }, + }, + }, + async func(this: SentryContext, flags: CreateFlags, ...args: string[]) { + const { cwd } = this; + + const { title, targetArg } = parsePositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + const { orgSlug, projectIds } = await resolveDashboardTarget(parsed, cwd); + + const widgets: DashboardWidget[] = []; + if (flags["widget-display"]) { + const validated = buildInlineWidget(flags); + widgets.push(assignDefaultLayout(validated, widgets)); + } else if (flags["widget-title"]) { + throw new ValidationError( + "Missing --widget-display. Both --widget-title and --widget-display are required for inline widgets.\n\n" + + "Example:\n" + + " sentry dashboard create 'My Dashboard' --widget-title \"Error Count\" --widget-display big_number --widget-query count\n\n" + + `Valid display types: ${DISPLAY_TYPES.join(", ")}`, + "widget-display" + ); + } + + const dashboard = await createDashboard(orgSlug, { + title, + widgets, + projects: projectIds.length > 0 ? projectIds : undefined, + }); + const url = buildDashboardUrl(orgSlug, dashboard.id); + + return { + data: { ...dashboard, url } as CreateResult, + }; + }, +}); diff --git a/src/commands/dashboard/index.ts b/src/commands/dashboard/index.ts new file mode 100644 index 000000000..adcf750fd --- /dev/null +++ b/src/commands/dashboard/index.ts @@ -0,0 +1,22 @@ +import { buildRouteMap } from "@stricli/core"; +import { createCommand } from "./create.js"; +import { listCommand } from "./list.js"; +import { viewCommand } from "./view.js"; + +export const dashboardRoute = buildRouteMap({ + routes: { + list: listCommand, + view: viewCommand, + create: createCommand, + }, + docs: { + brief: "Manage Sentry dashboards", + fullDescription: + "View and manage dashboards in your Sentry organization.\n\n" + + "Commands:\n" + + " list List dashboards\n" + + " view View a dashboard\n" + + " create Create a dashboard", + hideRoute: {}, + }, +}); diff --git a/src/commands/dashboard/list.ts b/src/commands/dashboard/list.ts new file mode 100644 index 000000000..516eb12c2 --- /dev/null +++ b/src/commands/dashboard/list.ts @@ -0,0 +1,128 @@ +/** + * sentry dashboard list + * + * List dashboards in a Sentry organization. + */ + +import type { SentryContext } from "../../context.js"; +import { listDashboards } from "../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { openInBrowser } from "../../lib/browser.js"; +import { ContextError } from "../../lib/errors.js"; +import { writeFooter, writeJson } from "../../lib/formatters/index.js"; +import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { type Column, writeTable } from "../../lib/formatters/table.js"; +import { + buildListCommand, + LIST_TARGET_POSITIONAL, +} from "../../lib/list-command.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { buildDashboardsListUrl } from "../../lib/sentry-urls.js"; +import type { DashboardListItem } from "../../types/dashboard.js"; + +type ListFlags = { + readonly web: boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +/** Resolve org slug from parsed target argument */ +async function resolveOrgFromTarget( + parsed: ReturnType<typeof parseOrgProjectArg>, + cwd: string +): Promise<string> { + switch (parsed.type) { + case "explicit": + case "org-all": + return parsed.org; + case "project-search": + case "auto-detect": { + const resolved = await resolveOrg({ cwd }); + if (!resolved) { + throw new ContextError("Organization", "sentry dashboard list <org>/"); + } + return resolved.org; + } + default: { + const _exhaustive: never = parsed; + throw new Error( + `Unexpected parsed type: ${(_exhaustive as { type: string }).type}` + ); + } + } +} + +export const listCommand = buildListCommand("dashboard", { + docs: { + brief: "List dashboards", + fullDescription: + "List dashboards in a Sentry organization.\n\n" + + "Examples:\n" + + " sentry dashboard list\n" + + " sentry dashboard list my-org/\n" + + " sentry dashboard list --json\n" + + " sentry dashboard list --web", + }, + output: "json", + parameters: { + positional: LIST_TARGET_POSITIONAL, + flags: { + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + }, + aliases: { w: "web" }, + }, + async func( + this: SentryContext, + flags: ListFlags, + target?: string + ): Promise<void> { + const { stdout, cwd } = this; + + const parsed = parseOrgProjectArg(target); + const orgSlug = await resolveOrgFromTarget(parsed, cwd); + + if (flags.web) { + await openInBrowser(buildDashboardsListUrl(orgSlug), "dashboards"); + return; + } + + const dashboards = await listDashboards(orgSlug); + + if (flags.json) { + writeJson(stdout, dashboards, flags.fields); + return; + } + + if (dashboards.length === 0) { + stdout.write("No dashboards found.\n"); + return; + } + + type DashboardRow = { + id: string; + title: string; + widgets: string; + }; + + const rows: DashboardRow[] = dashboards.map((d: DashboardListItem) => ({ + id: d.id, + title: escapeMarkdownCell(d.title), + widgets: String(d.widgetDisplay?.length ?? 0), + })); + + const columns: Column<DashboardRow>[] = [ + { header: "ID", value: (r) => r.id }, + { header: "TITLE", value: (r) => r.title }, + { header: "WIDGETS", value: (r) => r.widgets }, + ]; + + writeTable(stdout, rows, columns); + + const url = buildDashboardsListUrl(orgSlug); + writeFooter(stdout, `Dashboards: ${url}`); + }, +}); diff --git a/src/commands/dashboard/resolve.ts b/src/commands/dashboard/resolve.ts new file mode 100644 index 000000000..f0615c195 --- /dev/null +++ b/src/commands/dashboard/resolve.ts @@ -0,0 +1,121 @@ +/** + * Shared dashboard resolution utilities + * + * Provides org resolution from parsed target arguments and dashboard + * ID resolution from numeric IDs or title strings. + */ + +import { listDashboards } from "../../lib/api-client.js"; +import type { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { isAllDigits } from "../../lib/utils.js"; + +/** + * Resolve org slug from a parsed org/project target argument. + * + * Dashboard commands only need the org (dashboards are org-scoped), so + * explicit, org-all, project-search, and auto-detect all resolve to just + * the org slug. + * + * @param parsed - Parsed org/project argument + * @param cwd - Current working directory for auto-detection + * @param usageHint - Usage example for error messages + * @returns Organization slug + */ +export async function resolveOrgFromTarget( + parsed: ReturnType<typeof parseOrgProjectArg>, + cwd: string, + usageHint: string +): Promise<string> { + switch (parsed.type) { + case "explicit": + case "org-all": + return parsed.org; + case "project-search": + case "auto-detect": { + const resolved = await resolveOrg({ cwd }); + if (!resolved) { + throw new ContextError("Organization", usageHint); + } + return resolved.org; + } + default: { + const _exhaustive: never = parsed; + throw new Error( + `Unexpected parsed type: ${(_exhaustive as { type: string }).type}` + ); + } + } +} + +/** + * Parse a dashboard reference and optional target from array positional args. + * + * Handles: + * - `<id-or-title>` — single arg (auto-detect org) + * - `<target> <id-or-title>` — explicit target + dashboard ref + * + * @param args - Raw positional arguments + * @param usageHint - Error message label (e.g. "Dashboard ID or title") + * @returns Dashboard reference string and optional target arg + */ +export function parseDashboardPositionalArgs(args: string[]): { + dashboardRef: string; + targetArg: string | undefined; +} { + if (args.length === 0) { + throw new ValidationError( + "Dashboard ID or title is required.", + "dashboard" + ); + } + if (args.length === 1) { + return { + dashboardRef: args[0] as string, + targetArg: undefined, + }; + } + return { + dashboardRef: args.at(-1) as string, + targetArg: args[0] as string, + }; +} + +/** + * Resolve a dashboard reference (numeric ID or title) to a numeric ID string. + * + * If the reference is all digits, returns it directly. Otherwise, lists + * dashboards in the org and finds a case-insensitive title match. + * + * @param orgSlug - Organization slug + * @param ref - Dashboard reference (numeric ID or title) + * @returns Numeric dashboard ID as a string + */ +export async function resolveDashboardId( + orgSlug: string, + ref: string +): Promise<string> { + if (isAllDigits(ref)) { + return ref; + } + + const dashboards = await listDashboards(orgSlug); + const lowerRef = ref.toLowerCase(); + const match = dashboards.find((d) => d.title.toLowerCase() === lowerRef); + + if (!match) { + const available = dashboards + .slice(0, 5) + .map((d) => ` ${d.id} ${d.title}`) + .join("\n"); + const suffix = + dashboards.length > 5 ? `\n ... and ${dashboards.length - 5} more` : ""; + throw new ValidationError( + `No dashboard with title '${ref}' found in '${orgSlug}'.\n\n` + + `Available dashboards:\n${available}${suffix}` + ); + } + + return match.id; +} diff --git a/src/commands/dashboard/view.ts b/src/commands/dashboard/view.ts new file mode 100644 index 000000000..6a4f53e1c --- /dev/null +++ b/src/commands/dashboard/view.ts @@ -0,0 +1,88 @@ +/** + * sentry dashboard view + * + * View details of a specific dashboard. + */ + +import type { SentryContext } from "../../context.js"; +import { getDashboard } from "../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { openInBrowser } from "../../lib/browser.js"; +import { buildCommand } from "../../lib/command.js"; +import { formatDashboardView } from "../../lib/formatters/human.js"; +import { buildDashboardUrl } from "../../lib/sentry-urls.js"; +import type { DashboardDetail } from "../../types/dashboard.js"; +import { + parseDashboardPositionalArgs, + resolveDashboardId, + resolveOrgFromTarget, +} from "./resolve.js"; + +type ViewFlags = { + readonly web: boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +type ViewResult = DashboardDetail & { url: string }; + +export const viewCommand = buildCommand({ + docs: { + brief: "View a dashboard", + fullDescription: + "View details of a specific Sentry dashboard.\n\n" + + "The dashboard can be specified by numeric ID or title.\n\n" + + "Examples:\n" + + " sentry dashboard view 12345\n" + + " sentry dashboard view 'My Dashboard'\n" + + " sentry dashboard view my-org/ 12345\n" + + " sentry dashboard view 12345 --json\n" + + " sentry dashboard view 12345 --web", + }, + output: { + json: true, + human: formatDashboardView, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "[<org/project>] <dashboard-id-or-title>", + parse: String, + }, + }, + flags: { + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + }, + aliases: { w: "web" }, + }, + async func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + const { cwd } = this; + + const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + const orgSlug = await resolveOrgFromTarget( + parsed, + cwd, + "sentry dashboard view <org>/ <id>" + ); + const dashboardId = await resolveDashboardId(orgSlug, dashboardRef); + + const url = buildDashboardUrl(orgSlug, dashboardId); + + if (flags.web) { + await openInBrowser(url, "dashboard"); + return; + } + + const dashboard = await getDashboard(orgSlug, dashboardId); + + return { + data: { ...dashboard, url } as ViewResult, + }; + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e71e39fbc..bcb28d30b 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -19,6 +19,12 @@ * - users: current user info */ +export { + createDashboard, + getDashboard, + listDashboards, + updateDashboard, +} from "./api/dashboards.js"; export { findEventAcrossOrgs, getEvent, @@ -93,11 +99,9 @@ export { listTransactions, normalizeTraceSpan, } from "./api/traces.js"; - export { getCustomerTrialInfo, getProductTrials, startProductTrial, } from "./api/trials.js"; - export { getCurrentUser } from "./api/users.js"; diff --git a/src/lib/api/dashboards.ts b/src/lib/api/dashboards.ts new file mode 100644 index 000000000..31930548c --- /dev/null +++ b/src/lib/api/dashboards.ts @@ -0,0 +1,94 @@ +/** + * Dashboard API functions + * + * CRUD operations for Sentry dashboards. + */ + +import type { + DashboardDetail, + DashboardListItem, + DashboardWidget, +} from "../../types/dashboard.js"; + +import { resolveOrgRegion } from "../region.js"; + +import { apiRequestToRegion } from "./infrastructure.js"; + +/** + * List dashboards in an organization. + * + * @param orgSlug - Organization slug + * @returns Array of dashboard list items + */ +export async function listDashboards( + orgSlug: string +): Promise<DashboardListItem[]> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion<DashboardListItem[]>( + regionUrl, + `/organizations/${orgSlug}/dashboards/` + ); + return data; +} + +/** + * Get a dashboard by ID. + * + * @param orgSlug - Organization slug + * @param dashboardId - Dashboard ID + * @returns Full dashboard detail with widgets + */ +export async function getDashboard( + orgSlug: string, + dashboardId: string +): Promise<DashboardDetail> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion<DashboardDetail>( + regionUrl, + `/organizations/${orgSlug}/dashboards/${dashboardId}/` + ); + return data; +} + +/** + * Create a new dashboard. + * + * @param orgSlug - Organization slug + * @param body - Dashboard creation body (title, optional widgets) + * @returns Created dashboard detail + */ +export async function createDashboard( + orgSlug: string, + body: { title: string; widgets?: DashboardWidget[]; projects?: number[] } +): Promise<DashboardDetail> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion<DashboardDetail>( + regionUrl, + `/organizations/${orgSlug}/dashboards/`, + { method: "POST", body } + ); + return data; +} + +/** + * Update a dashboard (full PUT — replaces all widgets). + * Always GET first, modify, then PUT the full widget list. + * + * @param orgSlug - Organization slug + * @param dashboardId - Dashboard ID + * @param body - Dashboard update body (title, widgets) + * @returns Updated dashboard detail + */ +export async function updateDashboard( + orgSlug: string, + dashboardId: string, + body: { title: string; widgets: DashboardWidget[]; projects?: number[] } +): Promise<DashboardDetail> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion<DashboardDetail>( + regionUrl, + `/organizations/${orgSlug}/dashboards/${dashboardId}/`, + { method: "PUT", body } + ); + return data; +} diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index bcb3856a6..d98ad783f 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -2082,3 +2082,84 @@ export function formatUpgradeResult(data: UpgradeResult): string { return renderMarkdown(lines.join("\n")); } + +// Dashboard formatters + +/** + * Format a created dashboard for human-readable output. + */ +export function formatDashboardCreated(result: { + id: string; + title: string; + url: string; +}): string { + const lines: string[] = [ + `Created dashboard '${escapeMarkdownInline(result.title)}' (ID: ${result.id})`, + "", + `URL: ${result.url}`, + ]; + return renderMarkdown(lines.join("\n")); +} + +/** + * Format a dashboard view for human-readable output. + */ +export function formatDashboardView(result: { + id: string; + title: string; + widgets?: Array<{ + title: string; + displayType: string; + widgetType?: string; + layout?: { x: number; y: number; w: number; h: number }; + }>; + dateCreated?: string; + url: string; +}): string { + const lines: string[] = []; + + const kvRows: [string, string][] = [ + ["Title", escapeMarkdownInline(result.title)], + ["ID", result.id], + ]; + if (result.dateCreated) { + kvRows.push(["Created", result.dateCreated]); + } + kvRows.push(["URL", result.url]); + + lines.push(mdKvTable(kvRows)); + + const widgets = result.widgets ?? []; + if (widgets.length > 0) { + lines.push(""); + lines.push(`**Widgets** (${widgets.length}):`); + lines.push(""); + + type WidgetRow = { + title: string; + displayType: string; + widgetType: string; + layout: string; + }; + const widgetRows: WidgetRow[] = widgets.map((w) => ({ + title: escapeMarkdownCell(w.title), + displayType: w.displayType, + widgetType: w.widgetType ?? "", + layout: w.layout + ? `(${w.layout.x},${w.layout.y}) ${w.layout.w}×${w.layout.h}` + : "", + })); + + lines.push(mdTableHeader(["TITLE", "DISPLAY", "TYPE", "LAYOUT"])); + for (const row of widgetRows) { + lines.push( + mdRow([row.title, row.displayType, row.widgetType, row.layout]) + ); + } + } else { + lines.push(""); + lines.push("No widgets."); + } + + return renderMarkdown(lines.join("\n")); +} diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index b2aac728a..49b764bb1 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -162,6 +162,38 @@ export function buildLogsUrl(orgSlug: string, logId?: string): string { return logId ? `${base}?query=sentry.item_id:${logId}` : base; } +// Dashboard URLs + +/** + * Build URL to the dashboards list page. + * + * @param orgSlug - Organization slug + * @returns Full URL to the dashboards list page + */ +export function buildDashboardsListUrl(orgSlug: string): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/dashboards/`; + } + return `${getSentryBaseUrl()}/organizations/${orgSlug}/dashboards/`; +} + +/** + * Build URL to view a specific dashboard. + * + * @param orgSlug - Organization slug + * @param dashboardId - Dashboard ID + * @returns Full URL to the dashboard view page + */ +export function buildDashboardUrl( + orgSlug: string, + dashboardId: string +): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/dashboard/${dashboardId}/`; + } + return `${getSentryBaseUrl()}/organizations/${orgSlug}/dashboard/${dashboardId}/`; +} + /** * Build URL to view a trace in Sentry. * diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts new file mode 100644 index 000000000..d9b1f9267 --- /dev/null +++ b/src/types/dashboard.ts @@ -0,0 +1,587 @@ +/** + * Dashboard types and schemas + * + * Zod schemas and TypeScript types for Sentry Dashboard API responses. + * Includes utility functions for stripping server-generated fields + * before PUT requests, and strict input validation for user-authored widgets. + */ + +import { z } from "zod"; + +import { ValidationError } from "../lib/errors.js"; + +// --------------------------------------------------------------------------- +// Widget type and display type enums +// +// Source: sentry/src/sentry/models/dashboard_widget.py +// Also in: @sentry/api types (cli/node_modules/@sentry/api/dist/types.gen.d.ts) +// --------------------------------------------------------------------------- + +/** + * Valid widget types (dataset selectors). + * + * Source: sentry/src/sentry/models/dashboard_widget.py DashboardWidgetTypes.TYPES + */ +export const WIDGET_TYPES = [ + "discover", + "issue", + "metrics", + "error-events", + "transaction-like", + "spans", + "logs", + "tracemetrics", + "preprod-app-size", +] as const; + +export type WidgetType = (typeof WIDGET_TYPES)[number]; + +/** Default widgetType — the modern spans dataset covers most use cases */ +export const DEFAULT_WIDGET_TYPE: WidgetType = "spans"; + +/** + * Valid widget display types (visualization formats). + * + * Source: sentry/src/sentry/models/dashboard_widget.py DashboardWidgetDisplayTypes.TYPES + */ +export const DISPLAY_TYPES = [ + "line", + "area", + "stacked_area", + "bar", + "table", + "big_number", + "top_n", + "details", + "categorical_bar", + "wheel", + "rage_and_dead_clicks", + "server_tree", + "text", + "agents_traces_table", +] as const; + +export type DisplayType = (typeof DISPLAY_TYPES)[number]; + +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +/** Schema for a single query within a dashboard widget */ +export const DashboardWidgetQuerySchema = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + conditions: z.string().optional(), + columns: z.array(z.string()).optional(), + aggregates: z.array(z.string()).optional(), + fieldAliases: z.array(z.string()).optional(), + orderby: z.string().optional(), + fields: z.array(z.string()).optional(), + widgetId: z.string().optional(), + dateCreated: z.string().optional(), + }) + .passthrough(); + +/** Schema for widget layout position */ +export const DashboardWidgetLayoutSchema = z + .object({ + x: z.number(), + y: z.number(), + w: z.number(), + h: z.number(), + minH: z.number().optional(), + isResizable: z.boolean().optional(), + }) + .passthrough(); + +/** Schema for a single dashboard widget */ +export const DashboardWidgetSchema = z + .object({ + id: z.string().optional(), + title: z.string(), + displayType: z.string(), + widgetType: z.string().optional(), + interval: z.string().optional(), + queries: z.array(DashboardWidgetQuerySchema).optional(), + layout: DashboardWidgetLayoutSchema.optional(), + thresholds: z.unknown().optional(), + limit: z.number().nullable().optional(), + dashboardId: z.string().optional(), + dateCreated: z.string().optional(), + }) + .passthrough(); + +/** Schema for dashboard list items (lightweight, from GET /dashboards/) */ +export const DashboardListItemSchema = z + .object({ + id: z.string(), + title: z.string(), + dateCreated: z.string().optional(), + createdBy: z + .object({ + name: z.string().optional(), + email: z.string().optional(), + }) + .optional(), + widgetDisplay: z.array(z.string()).optional(), + }) + .passthrough(); + +/** Schema for full dashboard detail (from GET /dashboards/{id}/) */ +export const DashboardDetailSchema = z + .object({ + id: z.string(), + title: z.string(), + widgets: z.array(DashboardWidgetSchema).optional(), + dateCreated: z.string().optional(), + createdBy: z + .object({ + name: z.string().optional(), + email: z.string().optional(), + }) + .optional(), + projects: z.array(z.number()).optional(), + environment: z.array(z.string()).optional(), + period: z.string().nullable().optional(), + }) + .passthrough(); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DashboardWidgetQuery = z.infer<typeof DashboardWidgetQuerySchema>; +export type DashboardWidgetLayout = z.infer<typeof DashboardWidgetLayoutSchema>; +export type DashboardWidget = z.infer<typeof DashboardWidgetSchema>; +export type DashboardListItem = z.infer<typeof DashboardListItemSchema>; +export type DashboardDetail = z.infer<typeof DashboardDetailSchema>; + +// --------------------------------------------------------------------------- +// Strict input schema for user-authored widgets +// --------------------------------------------------------------------------- + +/** + * Strict schema for user-authored widget JSON (create/add/edit input). + * Validates displayType and widgetType against known Sentry enums. + * Defaults widgetType to "spans" when not provided. + * + * Use DashboardWidgetSchema (permissive) for parsing server responses. + */ +export const DashboardWidgetInputSchema = z + .object({ + title: z.string(), + displayType: z.enum(DISPLAY_TYPES), + widgetType: z.enum(WIDGET_TYPES).default(DEFAULT_WIDGET_TYPE), + interval: z.string().optional(), + queries: z.array(DashboardWidgetQuerySchema).optional(), + layout: DashboardWidgetLayoutSchema.optional(), + thresholds: z.unknown().optional(), + limit: z.number().nullable().optional(), + }) + .passthrough(); + +/** + * Parse and validate user-authored widget JSON with strict enum checks. + * Throws ValidationError with actionable messages listing valid values. + * + * @param raw - Raw parsed JSON from user's widget file + * @returns Validated widget with widgetType defaulted to "spans" if omitted + */ +export function parseWidgetInput(raw: unknown): DashboardWidget { + const result = DashboardWidgetInputSchema.safeParse(raw); + if (result.success) { + return result.data; + } + + const issues = result.error.issues.map((issue) => { + if (issue.path.includes("displayType")) { + return `Invalid displayType. Valid values: ${DISPLAY_TYPES.join(", ")}`; + } + if (issue.path.includes("widgetType")) { + return `Invalid widgetType. Valid values: ${WIDGET_TYPES.join(", ")}`; + } + return `${issue.path.join(".")}: ${issue.message}`; + }); + throw new ValidationError( + `Invalid widget definition:\n${issues.join("\n")}`, + "widget-json" + ); +} + +// --------------------------------------------------------------------------- +// Aggregate functions & search filter enums +// --------------------------------------------------------------------------- + +/** + * Public aggregate functions available in the spans dataset (default for dashboard widgets). + * These are the function names users pass to --query (e.g. `--query count`, `--query p95:span.duration`). + * + * Source: getsentry/sentry spans_indexed.py SpansIndexedDatasetConfig.function_converter + * https://github.com/getsentry/sentry/blob/master/src/sentry/search/events/datasets/spans_indexed.py#L89-L363 + * + * Aliases (sps→eps, spm→epm) defined in constants.py SPAN_FUNCTION_ALIASES: + * https://github.com/getsentry/sentry/blob/master/src/sentry/search/events/constants.py#L334 + */ +export const SPAN_AGGREGATE_FUNCTIONS = [ + "count", + "count_unique", + "sum", + "avg", + "percentile", + "p50", + "p75", + "p90", + "p95", + "p99", + "p100", + "eps", + "epm", + "sps", + "spm", + "any", + "min", + "max", +] as const; + +export type SpanAggregateFunction = (typeof SPAN_AGGREGATE_FUNCTIONS)[number]; + +/** + * Additional aggregate functions from the discover dataset. + * Available when widgetType is "discover" or "error-events". + * + * Source: getsentry/sentry discover.py DiscoverDatasetConfig.function_converter + * https://github.com/getsentry/sentry/blob/master/src/sentry/search/events/datasets/discover.py#L188-L1095 + * + * Aliases (tpm→epm, tps→eps) defined in constants.py FUNCTION_ALIASES: + * https://github.com/getsentry/sentry/blob/master/src/sentry/search/events/constants.py#L325-L328 + */ +export const DISCOVER_AGGREGATE_FUNCTIONS = [ + ...SPAN_AGGREGATE_FUNCTIONS, + "failure_count", + "failure_rate", + "apdex", + "count_miserable", + "user_misery", + "count_web_vitals", + "count_if", + "count_at_least", + "last_seen", + "latest_event", + "var", + "stddev", + "cov", + "corr", + "performance_score", + "opportunity_score", + "count_scores", + "tpm", + "tps", +] as const; + +export type DiscoverAggregateFunction = + (typeof DISCOVER_AGGREGATE_FUNCTIONS)[number]; + +/** Zod schema for validating a span aggregate function name */ +export const SpanAggregateFunctionSchema = z.enum(SPAN_AGGREGATE_FUNCTIONS); + +/** Zod schema for validating a discover aggregate function name */ +export const DiscoverAggregateFunctionSchema = z.enum( + DISCOVER_AGGREGATE_FUNCTIONS +); + +/** + * Valid `is:` filter values for issue search conditions (--where flag). + * Only valid when widgetType is "issue". Other datasets don't support `is:`. + * + * Status values from GroupStatus: + * https://github.com/getsentry/sentry/blob/master/src/sentry/models/group.py#L196-L204 + * + * Substatus values from SUBSTATUS_UPDATE_CHOICES: + * https://github.com/getsentry/sentry/blob/master/src/sentry/types/group.py#L33-L41 + * + * Assignment/link filters from is_filter_translation: + * https://github.com/getsentry/sentry/blob/master/src/sentry/issues/issue_search.py#L45-L51 + */ +export const IS_FILTER_VALUES = [ + // Status (GroupStatus) + "resolved", + "unresolved", + "ignored", + "archived", + "muted", + "reprocessing", + // Substatus (GroupSubStatus) + "escalating", + "ongoing", + "regressed", + "new", + "archived_until_escalating", + "archived_until_condition_met", + "archived_forever", + // Assignment & linking + "assigned", + "unassigned", + "for_review", + "linked", + "unlinked", +] as const; + +export type IsFilterValue = (typeof IS_FILTER_VALUES)[number]; + +/** Zod schema for validating an `is:` filter value */ +export const IsFilterValueSchema = z.enum(IS_FILTER_VALUES); + +// --------------------------------------------------------------------------- +// Aggregate & sort parsing (quote-free CLI shorthand) +// --------------------------------------------------------------------------- + +/** + * Parse a shorthand aggregate expression into Sentry query syntax. + * Accepts three formats: + * "count" → "count()" + * "p95:span.duration" → "p95(span.duration)" + * "count()" → "count()" (passthrough if already has parens) + */ +export function parseAggregate(input: string): string { + if (input.includes("(")) { + return input; + } + const colonIdx = input.indexOf(":"); + if (colonIdx > 0) { + return `${input.slice(0, colonIdx)}(${input.slice(colonIdx + 1)})`; + } + return `${input}()`; +} + +/** + * Parse a sort expression with optional `-` prefix for descending. + * Uses the same shorthand as {@link parseAggregate}. + * "-count" → "-count()" + * "p95:span.duration" → "p95(span.duration)" + * "-p95:span.duration" → "-p95(span.duration)" + */ +export function parseSortExpression(input: string): string { + if (input.startsWith("-")) { + return `-${parseAggregate(input.slice(1))}`; + } + return parseAggregate(input); +} + +// --------------------------------------------------------------------------- +// Query preparation for Sentry API +// --------------------------------------------------------------------------- + +/** Maximum result limits by display type */ +const MAX_LIMITS: Partial<Record<string, number>> = { + table: 10, + bar: 10, +}; + +/** + * Prepare widget queries for the Sentry API. + * Auto-computes `fields` from columns + aggregates. + * Defaults `conditions` to "" when missing. + * Enforces per-display-type limit maximums. + */ +export function prepareWidgetQueries(widget: DashboardWidget): DashboardWidget { + // Enforce limit maximums + const maxLimit = MAX_LIMITS[widget.displayType]; + if ( + maxLimit !== undefined && + widget.limit !== undefined && + widget.limit !== null && + widget.limit > maxLimit + ) { + throw new ValidationError( + `The maximum limit for ${widget.displayType} widgets is ${maxLimit}. Got: ${widget.limit}.`, + "limit" + ); + } + + if (!widget.queries) { + return widget; + } + return { + ...widget, + queries: widget.queries.map((q) => ({ + ...q, + conditions: q.conditions ?? "", + fields: q.fields ?? [...(q.columns ?? []), ...(q.aggregates ?? [])], + })), + }; +} + +// --------------------------------------------------------------------------- +// Auto-layout utilities +// --------------------------------------------------------------------------- + +/** Sentry dashboard grid column count */ +const GRID_COLUMNS = 6; + +/** Default widget dimensions by displayType */ +const DEFAULT_WIDGET_SIZE: Partial< + Record<DisplayType, { w: number; h: number; minH: number }> +> = { + big_number: { w: 2, h: 1, minH: 1 }, + line: { w: 3, h: 2, minH: 2 }, + area: { w: 3, h: 2, minH: 2 }, + bar: { w: 3, h: 2, minH: 2 }, + table: { w: 6, h: 2, minH: 2 }, +}; +const FALLBACK_SIZE = { w: 3, h: 2, minH: 2 }; + +/** Build a set of occupied grid cells and the max bottom edge from existing layouts. */ +function buildOccupiedGrid(widgets: DashboardWidget[]): { + occupied: Set<string>; + maxY: number; +} { + const occupied = new Set<string>(); + let maxY = 0; + for (const w of widgets) { + if (!w.layout) { + continue; + } + const bottom = w.layout.y + w.layout.h; + if (bottom > maxY) { + maxY = bottom; + } + for (let y = w.layout.y; y < bottom; y++) { + for (let x = w.layout.x; x < w.layout.x + w.layout.w; x++) { + occupied.add(`${x},${y}`); + } + } + } + return { occupied, maxY }; +} + +/** Check whether a rectangle fits at a position without overlapping occupied cells. */ +function regionFits( + occupied: Set<string>, + rect: { px: number; py: number; w: number; h: number } +): boolean { + for (let dy = 0; dy < rect.h; dy++) { + for (let dx = 0; dx < rect.w; dx++) { + if (occupied.has(`${rect.px + dx},${rect.py + dy}`)) { + return false; + } + } + } + return true; +} + +/** + * Assign a default layout to a widget if it doesn't already have one. + * Packs the widget into the first available space in a 6-column grid, + * scanning rows top-to-bottom and left-to-right. + * + * @param widget - Widget that may be missing a layout + * @param existingWidgets - Widgets already in the dashboard (used to compute placement) + * @returns Widget with layout guaranteed + */ +export function assignDefaultLayout( + widget: DashboardWidget, + existingWidgets: DashboardWidget[] +): DashboardWidget { + if (widget.layout) { + return widget; + } + + const { w, h, minH } = + DEFAULT_WIDGET_SIZE[widget.displayType as DisplayType] ?? FALLBACK_SIZE; + + const { occupied, maxY } = buildOccupiedGrid(existingWidgets); + + // Scan rows to find the first position where the widget fits + for (let y = 0; y <= maxY; y++) { + for (let x = 0; x <= GRID_COLUMNS - w; x++) { + if (regionFits(occupied, { px: x, py: y, w, h })) { + return { ...widget, layout: { x, y, w, h, minH } }; + } + } + } + + // No gap found — place below everything + return { ...widget, layout: { x: 0, y: maxY, w, h, minH } }; +} + +// --------------------------------------------------------------------------- +// Server field stripping utilities +// --------------------------------------------------------------------------- + +/** + * Server-generated fields on widget queries that must be stripped before PUT. + * NEVER strip user-controlled fields like conditions, columns, aggregates. + */ +const QUERY_SERVER_FIELDS = ["id", "widgetId", "dateCreated"] as const; + +/** + * Server-generated fields on widgets that must be stripped before PUT. + * CRITICAL: Never strip widgetType, displayType, or layout — these are + * user-controlled and stripping them causes widgets to reset to defaults. + */ +const WIDGET_SERVER_FIELDS = ["id", "dashboardId", "dateCreated"] as const; + +/** + * Server-generated fields on widget layout that must be stripped before PUT. + */ +const LAYOUT_SERVER_FIELDS = ["isResizable"] as const; + +/** + * Strip server-generated fields from a single widget for PUT requests. + * + * @param widget - Widget object from GET response + * @returns Widget safe for PUT (widgetType, displayType, layout preserved) + */ +export function stripWidgetServerFields( + widget: DashboardWidget +): DashboardWidget { + const cleaned = { ...widget }; + + // Strip widget-level server fields + for (const field of WIDGET_SERVER_FIELDS) { + delete (cleaned as Record<string, unknown>)[field]; + } + + // Strip query-level server fields + if (cleaned.queries) { + cleaned.queries = cleaned.queries.map((q) => { + const cleanedQuery = { ...q }; + for (const field of QUERY_SERVER_FIELDS) { + delete (cleanedQuery as Record<string, unknown>)[field]; + } + return cleanedQuery; + }); + } + + // Strip layout server fields + if (cleaned.layout) { + const cleanedLayout = { ...cleaned.layout }; + for (const field of LAYOUT_SERVER_FIELDS) { + delete (cleanedLayout as Record<string, unknown>)[field]; + } + cleaned.layout = cleanedLayout; + } + + return cleaned; +} + +/** + * Prepare a full dashboard for PUT update. + * Strips server-generated fields from all widgets while preserving + * widgetType, displayType, and layout. + * + * @param dashboard - Dashboard detail from GET response + * @returns Object with title and cleaned widgets, ready for PUT body + */ +export function prepareDashboardForUpdate(dashboard: DashboardDetail): { + title: string; + widgets: DashboardWidget[]; + projects?: number[]; +} { + return { + title: dashboard.title, + widgets: (dashboard.widgets ?? []).map(stripWidgetServerFields), + projects: dashboard.projects, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index de47b5de3..8ae5b2e6a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,6 +19,21 @@ export { ProjectAliasesSchema, SentryConfigSchema, } from "./config.js"; +// Dashboard types +export type { + DashboardDetail, + DashboardListItem, + DashboardWidget, + DashboardWidgetLayout, + DashboardWidgetQuery, +} from "./dashboard.js"; +export { + DashboardDetailSchema, + DashboardListItemSchema, + DashboardWidgetLayoutSchema, + DashboardWidgetQuerySchema, + DashboardWidgetSchema, +} from "./dashboard.js"; // OAuth types and schemas export type { DeviceCodeResponse, @@ -85,7 +100,6 @@ export type { TransactionsResponse, UserRegionsResponse, } from "./sentry.js"; - export { CustomerTrialInfoSchema, DetailedLogsResponseSchema, diff --git a/test/types/dashboard.test.ts b/test/types/dashboard.test.ts new file mode 100644 index 000000000..c7491e565 --- /dev/null +++ b/test/types/dashboard.test.ts @@ -0,0 +1,551 @@ +/** + * Dashboard Type & Validation Tests + * + * Tests for enum constants, strict input schema, and parseWidgetInput() + * in src/types/dashboard.ts. + */ + +import { describe, expect, test } from "bun:test"; +import { + DashboardWidgetInputSchema, + DEFAULT_WIDGET_TYPE, + DISCOVER_AGGREGATE_FUNCTIONS, + DISPLAY_TYPES, + DiscoverAggregateFunctionSchema, + type DisplayType, + IS_FILTER_VALUES, + IsFilterValueSchema, + parseAggregate, + parseSortExpression, + parseWidgetInput, + prepareWidgetQueries, + SPAN_AGGREGATE_FUNCTIONS, + SpanAggregateFunctionSchema, + WIDGET_TYPES, + type WidgetType, +} from "../../src/types/dashboard.js"; + +// --------------------------------------------------------------------------- +// Enum constants +// --------------------------------------------------------------------------- + +describe("WIDGET_TYPES", () => { + test("contains spans as default", () => { + expect(WIDGET_TYPES).toContain("spans"); + expect(DEFAULT_WIDGET_TYPE).toBe("spans"); + }); + + test("contains all expected dataset types", () => { + const expected: WidgetType[] = [ + "discover", + "issue", + "metrics", + "error-events", + "transaction-like", + "spans", + "logs", + "tracemetrics", + "preprod-app-size", + ]; + for (const t of expected) { + expect(WIDGET_TYPES).toContain(t); + } + }); +}); + +describe("DISPLAY_TYPES", () => { + test("contains common visualization types", () => { + const common: DisplayType[] = [ + "line", + "area", + "bar", + "table", + "big_number", + ]; + for (const t of common) { + expect(DISPLAY_TYPES).toContain(t); + } + }); + + test("contains all expected display types", () => { + const expected: DisplayType[] = [ + "line", + "area", + "stacked_area", + "bar", + "table", + "big_number", + "top_n", + "details", + "categorical_bar", + "wheel", + "rage_and_dead_clicks", + "server_tree", + "text", + "agents_traces_table", + ]; + for (const t of expected) { + expect(DISPLAY_TYPES).toContain(t); + } + }); +}); + +// --------------------------------------------------------------------------- +// SPAN_AGGREGATE_FUNCTIONS / DISCOVER_AGGREGATE_FUNCTIONS +// --------------------------------------------------------------------------- + +describe("SPAN_AGGREGATE_FUNCTIONS", () => { + test("contains core aggregate functions", () => { + const core = [ + "count", + "avg", + "sum", + "min", + "max", + "p50", + "p75", + "p95", + "p99", + ]; + for (const fn of core) { + expect(SPAN_AGGREGATE_FUNCTIONS).toContain(fn); + } + }); + + test("contains rate functions and aliases", () => { + expect(SPAN_AGGREGATE_FUNCTIONS).toContain("eps"); + expect(SPAN_AGGREGATE_FUNCTIONS).toContain("epm"); + expect(SPAN_AGGREGATE_FUNCTIONS).toContain("sps"); + expect(SPAN_AGGREGATE_FUNCTIONS).toContain("spm"); + }); + + test("zod schema validates known functions", () => { + expect(SpanAggregateFunctionSchema.safeParse("count").success).toBe(true); + expect(SpanAggregateFunctionSchema.safeParse("p95").success).toBe(true); + }); + + test("zod schema rejects unknown functions", () => { + expect(SpanAggregateFunctionSchema.safeParse("bogus").success).toBe(false); + }); +}); + +describe("DISCOVER_AGGREGATE_FUNCTIONS", () => { + test("is a superset of span functions", () => { + for (const fn of SPAN_AGGREGATE_FUNCTIONS) { + expect(DISCOVER_AGGREGATE_FUNCTIONS).toContain(fn); + } + }); + + test("contains discover-specific functions", () => { + const extras = [ + "failure_count", + "failure_rate", + "apdex", + "user_misery", + "count_if", + "last_seen", + ]; + for (const fn of extras) { + expect(DISCOVER_AGGREGATE_FUNCTIONS).toContain(fn); + } + }); + + test("zod schema validates discover functions", () => { + expect(DiscoverAggregateFunctionSchema.safeParse("apdex").success).toBe( + true + ); + expect( + DiscoverAggregateFunctionSchema.safeParse("failure_rate").success + ).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// IS_FILTER_VALUES +// --------------------------------------------------------------------------- + +describe("IS_FILTER_VALUES", () => { + test("contains status values", () => { + const statuses = ["resolved", "unresolved", "ignored", "archived"]; + for (const s of statuses) { + expect(IS_FILTER_VALUES).toContain(s); + } + }); + + test("contains substatus values", () => { + const substatuses = ["escalating", "ongoing", "regressed", "new"]; + for (const s of substatuses) { + expect(IS_FILTER_VALUES).toContain(s); + } + }); + + test("contains assignment values", () => { + const assignments = [ + "assigned", + "unassigned", + "for_review", + "linked", + "unlinked", + ]; + for (const s of assignments) { + expect(IS_FILTER_VALUES).toContain(s); + } + }); + + test("zod schema validates known values", () => { + expect(IsFilterValueSchema.safeParse("unresolved").success).toBe(true); + expect(IsFilterValueSchema.safeParse("escalating").success).toBe(true); + expect(IsFilterValueSchema.safeParse("assigned").success).toBe(true); + }); + + test("zod schema rejects unknown values", () => { + expect(IsFilterValueSchema.safeParse("bogus").success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// DashboardWidgetInputSchema +// --------------------------------------------------------------------------- + +describe("DashboardWidgetInputSchema", () => { + const minimalWidget = { + title: "My Widget", + displayType: "line", + }; + + test("accepts minimal widget and defaults widgetType to spans", () => { + const result = DashboardWidgetInputSchema.safeParse(minimalWidget); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.widgetType).toBe("spans"); + } + }); + + test("accepts explicit widgetType", () => { + const result = DashboardWidgetInputSchema.safeParse({ + ...minimalWidget, + widgetType: "error-events", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.widgetType).toBe("error-events"); + } + }); + + test("accepts all valid widgetType values", () => { + for (const wt of WIDGET_TYPES) { + const result = DashboardWidgetInputSchema.safeParse({ + ...minimalWidget, + widgetType: wt, + }); + expect(result.success).toBe(true); + } + }); + + test("accepts all valid displayType values", () => { + for (const dt of DISPLAY_TYPES) { + const result = DashboardWidgetInputSchema.safeParse({ + title: "Test", + displayType: dt, + }); + expect(result.success).toBe(true); + } + }); + + test("rejects invalid displayType", () => { + const result = DashboardWidgetInputSchema.safeParse({ + ...minimalWidget, + displayType: "chart", + }); + expect(result.success).toBe(false); + }); + + test("rejects invalid widgetType", () => { + const result = DashboardWidgetInputSchema.safeParse({ + ...minimalWidget, + widgetType: "span", + }); + expect(result.success).toBe(false); + }); + + test("rejects missing title", () => { + const result = DashboardWidgetInputSchema.safeParse({ + displayType: "line", + }); + expect(result.success).toBe(false); + }); + + test("rejects missing displayType", () => { + const result = DashboardWidgetInputSchema.safeParse({ + title: "My Widget", + }); + expect(result.success).toBe(false); + }); + + test("preserves extra fields via passthrough", () => { + const result = DashboardWidgetInputSchema.safeParse({ + ...minimalWidget, + customField: "hello", + }); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data as Record<string, unknown>).customField).toBe( + "hello" + ); + } + }); + + test("accepts widget with queries", () => { + const result = DashboardWidgetInputSchema.safeParse({ + ...minimalWidget, + queries: [ + { + conditions: "transaction.op:http", + aggregates: ["count()"], + columns: [], + }, + ], + }); + expect(result.success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// parseWidgetInput +// --------------------------------------------------------------------------- + +describe("parseWidgetInput", () => { + test("returns validated widget with defaults", () => { + const widget = parseWidgetInput({ + title: "Error Count", + displayType: "big_number", + }); + expect(widget.title).toBe("Error Count"); + expect(widget.displayType).toBe("big_number"); + expect(widget.widgetType).toBe("spans"); + }); + + test("preserves explicit widgetType", () => { + const widget = parseWidgetInput({ + title: "Errors", + displayType: "line", + widgetType: "error-events", + }); + expect(widget.widgetType).toBe("error-events"); + }); + + test("throws ValidationError for invalid displayType with valid values listed", () => { + expect(() => + parseWidgetInput({ + title: "Bad Widget", + displayType: "invalid_chart", + }) + ).toThrow(/Invalid displayType/); + expect(() => + parseWidgetInput({ + title: "Bad Widget", + displayType: "invalid_chart", + }) + ).toThrow(/line/); + }); + + test("throws ValidationError for invalid widgetType with valid values listed", () => { + expect(() => + parseWidgetInput({ + title: "Bad Widget", + displayType: "line", + widgetType: "span", + }) + ).toThrow(/Invalid widgetType/); + expect(() => + parseWidgetInput({ + title: "Bad Widget", + displayType: "line", + widgetType: "span", + }) + ).toThrow(/spans/); + }); + + test("throws ValidationError for missing required fields", () => { + expect(() => parseWidgetInput({})).toThrow(/Invalid widget definition/); + }); + + test("throws ValidationError for non-object input", () => { + expect(() => parseWidgetInput("not an object")).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// parseAggregate +// --------------------------------------------------------------------------- + +describe("parseAggregate", () => { + test("bare name becomes no-arg function call", () => { + expect(parseAggregate("count")).toBe("count()"); + }); + + test("colon syntax becomes function with arg", () => { + expect(parseAggregate("p95:span.duration")).toBe("p95(span.duration)"); + }); + + test("passthrough when already has parens", () => { + expect(parseAggregate("count()")).toBe("count()"); + }); + + test("passthrough for function with args in parens", () => { + expect(parseAggregate("avg(span.self_time)")).toBe("avg(span.self_time)"); + }); + + test("colon with dotted column name", () => { + expect(parseAggregate("avg:span.self_time")).toBe("avg(span.self_time)"); + }); + + test("single word functions", () => { + expect(parseAggregate("p50")).toBe("p50()"); + expect(parseAggregate("p75")).toBe("p75()"); + expect(parseAggregate("p99")).toBe("p99()"); + }); +}); + +// --------------------------------------------------------------------------- +// parseSortExpression +// --------------------------------------------------------------------------- + +describe("parseSortExpression", () => { + test("ascending bare name", () => { + expect(parseSortExpression("count")).toBe("count()"); + }); + + test("descending bare name", () => { + expect(parseSortExpression("-count")).toBe("-count()"); + }); + + test("ascending colon syntax", () => { + expect(parseSortExpression("p95:span.duration")).toBe("p95(span.duration)"); + }); + + test("descending colon syntax", () => { + expect(parseSortExpression("-p95:span.duration")).toBe( + "-p95(span.duration)" + ); + }); + + test("passthrough with parens", () => { + expect(parseSortExpression("count()")).toBe("count()"); + expect(parseSortExpression("-count()")).toBe("-count()"); + }); +}); + +// --------------------------------------------------------------------------- +// prepareWidgetQueries +// --------------------------------------------------------------------------- + +describe("prepareWidgetQueries", () => { + test("auto-computes fields from aggregates + columns", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "line", + queries: [ + { + aggregates: ["count()"], + columns: ["browser.name"], + }, + ], + }); + expect(widget.queries?.[0]?.fields).toEqual(["browser.name", "count()"]); + }); + + test("does not overwrite existing fields", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "line", + queries: [ + { + aggregates: ["count()"], + columns: ["browser.name"], + fields: ["custom_field"], + }, + ], + }); + expect(widget.queries?.[0]?.fields).toEqual(["custom_field"]); + }); + + test("defaults conditions to empty string when missing", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "line", + queries: [{ aggregates: ["count()"] }], + }); + expect(widget.queries?.[0]?.conditions).toBe(""); + }); + + test("preserves existing conditions", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "line", + queries: [ + { + aggregates: ["count()"], + conditions: "is:unresolved", + }, + ], + }); + expect(widget.queries?.[0]?.conditions).toBe("is:unresolved"); + }); + + test("handles widget with no queries", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "big_number", + }); + expect(widget.queries).toBeUndefined(); + }); + + test("handles empty aggregates and columns", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "line", + queries: [{}], + }); + expect(widget.queries?.[0]?.fields).toEqual([]); + expect(widget.queries?.[0]?.conditions).toBe(""); + }); + + test("throws for table widget with limit exceeding max", () => { + expect(() => + prepareWidgetQueries({ + title: "Test", + displayType: "table", + limit: 25, + }) + ).toThrow(/maximum limit for table widgets is 10/); + }); + + test("throws for bar widget with limit exceeding max", () => { + expect(() => + prepareWidgetQueries({ + title: "Test", + displayType: "bar", + limit: 15, + }) + ).toThrow(/maximum limit for bar widgets is 10/); + }); + + test("accepts table widget with limit within max", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "table", + limit: 5, + }); + expect(widget.limit).toBe(5); + }); + + test("accepts line widget with any limit", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "line", + limit: 100, + }); + expect(widget.limit).toBe(100); + }); +}); From 1d264627ed18c9e158d26c8b5f1e6b565d4ce9f3 Mon Sep 17 00:00:00 2001 From: betegon <miguelbetegongarcia@gmail.com> Date: Fri, 13 Mar 2026 11:28:53 +0100 Subject: [PATCH 02/10] test(dashboard): add tests for list, create, resolve commands and extend existing test suites Add ~46 new tests covering dashboard commands (list, create, resolve), URL builders (property-based), human formatters, and type utilities (assignDefaultLayout, stripWidgetServerFields). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- test/commands/dashboard/create.test.ts | 234 ++++++++++++++++++++++++ test/commands/dashboard/list.test.ts | 191 +++++++++++++++++++ test/commands/dashboard/resolve.test.ts | 148 +++++++++++++++ test/lib/formatters/human.test.ts | 66 +++++++ test/lib/sentry-urls.property.test.ts | 92 ++++++++++ test/types/dashboard.test.ts | 122 ++++++++++++ 6 files changed, 853 insertions(+) create mode 100644 test/commands/dashboard/create.test.ts create mode 100644 test/commands/dashboard/list.test.ts create mode 100644 test/commands/dashboard/resolve.test.ts diff --git a/test/commands/dashboard/create.test.ts b/test/commands/dashboard/create.test.ts new file mode 100644 index 000000000..fb78cc173 --- /dev/null +++ b/test/commands/dashboard/create.test.ts @@ -0,0 +1,234 @@ +/** + * Dashboard Create Command Tests + * + * Tests for the dashboard create command in src/commands/dashboard/create.ts. + * Uses spyOn pattern to mock API client and resolve-target. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; + +import { createCommand } from "../../../src/commands/dashboard/create.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import type { DashboardDetail } from "../../../src/types/dashboard.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockContext(cwd = "/tmp") { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd, + setContext: mock(() => { + // no-op for test + }), + }, + stdoutWrite, + }; +} + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const sampleDashboard: DashboardDetail = { + id: "123", + title: "My Dashboard", + widgets: [], + dateCreated: "2026-03-01T10:00:00Z", +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("dashboard create", () => { + let createDashboardSpy: ReturnType<typeof spyOn>; + let resolveOrgSpy: ReturnType<typeof spyOn>; + let resolveAllTargetsSpy: ReturnType<typeof spyOn>; + let fetchProjectIdSpy: ReturnType<typeof spyOn>; + + beforeEach(() => { + createDashboardSpy = spyOn(apiClient, "createDashboard"); + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + resolveAllTargetsSpy = spyOn(resolveTarget, "resolveAllTargets"); + fetchProjectIdSpy = spyOn(resolveTarget, "fetchProjectId"); + + // Default mocks + resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); + resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); + createDashboardSpy.mockResolvedValue(sampleDashboard); + fetchProjectIdSpy.mockResolvedValue(999); + }); + + afterEach(() => { + createDashboardSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + resolveAllTargetsSpy.mockRestore(); + fetchProjectIdSpy.mockRestore(); + }); + + test("creates dashboard with title and verifies API args", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "My Dashboard"); + + expect(createDashboardSpy).toHaveBeenCalledWith("acme-corp", { + title: "My Dashboard", + widgets: [], + projects: undefined, + }); + }); + + test("JSON output contains dashboard data and url", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: true }, "My Dashboard"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.id).toBe("123"); + expect(parsed.title).toBe("My Dashboard"); + expect(parsed.url).toContain("dashboard/123"); + }); + + test("human output contains 'Created dashboard' and title", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "My Dashboard"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Created dashboard"); + expect(output).toContain("My Dashboard"); + }); + + test("throws ValidationError when title is missing", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false }) + .catch((e: Error) => e); + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain("Dashboard title is required"); + }); + + test("two args parses target + title correctly", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-org/", "My Dashboard"); + + expect(createDashboardSpy).toHaveBeenCalledWith("my-org", { + title: "My Dashboard", + widgets: [], + projects: undefined, + }); + }); + + test("--widget-display and --widget-title creates dashboard with widget", async () => { + createDashboardSpy.mockResolvedValue({ + ...sampleDashboard, + widgets: [ + { + title: "Error Count", + displayType: "big_number", + widgetType: "spans", + }, + ], + }); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call( + context, + { + json: false, + "widget-title": "Error Count", + "widget-display": "big_number", + }, + "My Dashboard" + ); + + expect(createDashboardSpy).toHaveBeenCalledWith( + "acme-corp", + expect.objectContaining({ + title: "My Dashboard", + widgets: expect.arrayContaining([ + expect.objectContaining({ + title: "Error Count", + displayType: "big_number", + }), + ]), + }) + ); + }); + + test("--widget-title without --widget-display throws ValidationError", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call( + context, + { json: false, "widget-title": "Error Count" }, + "My Dashboard" + ) + .catch((e: Error) => e); + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain("--widget-display"); + }); + + test("--widget-display without --widget-title throws ValidationError", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call( + context, + { json: false, "widget-display": "big_number" }, + "My Dashboard" + ) + .catch((e: Error) => e); + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain("--widget-title"); + }); + + test("throws ContextError when org cannot be resolved", async () => { + resolveOrgSpy.mockResolvedValue(null); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + await expect( + func.call(context, { json: false }, "My Dashboard") + ).rejects.toThrow(ContextError); + }); + + test("explicit org/project target calls fetchProjectId", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call( + context, + { json: false }, + "my-org/my-project", + "My Dashboard" + ); + + expect(fetchProjectIdSpy).toHaveBeenCalledWith("my-org", "my-project"); + }); +}); diff --git a/test/commands/dashboard/list.test.ts b/test/commands/dashboard/list.test.ts new file mode 100644 index 000000000..94f8ec629 --- /dev/null +++ b/test/commands/dashboard/list.test.ts @@ -0,0 +1,191 @@ +/** + * Dashboard List Command Tests + * + * Tests for the dashboard list command in src/commands/dashboard/list.ts. + * Uses spyOn pattern to mock API client, resolve-target, and browser. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; + +import { listCommand } from "../../../src/commands/dashboard/list.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as browser from "../../../src/lib/browser.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import type { DashboardListItem } from "../../../src/types/dashboard.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockContext(cwd = "/tmp") { + const stdoutWrite = mock(() => true); + const stderrWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd, + setContext: mock(() => { + // no-op for test + }), + }, + stdoutWrite, + stderrWrite, + }; +} + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const DASHBOARD_A: DashboardListItem = { + id: "1", + title: "Errors Overview", + widgetDisplay: ["big_number", "line"], + dateCreated: "2026-01-15T10:00:00Z", +}; + +const DASHBOARD_B: DashboardListItem = { + id: "42", + title: "Performance", + widgetDisplay: ["table"], + dateCreated: "2026-02-20T12:00:00Z", +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("dashboard list command", () => { + let listDashboardsSpy: ReturnType<typeof spyOn>; + let resolveOrgSpy: ReturnType<typeof spyOn>; + let openInBrowserSpy: ReturnType<typeof spyOn>; + + beforeEach(() => { + listDashboardsSpy = spyOn(apiClient, "listDashboards"); + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + openInBrowserSpy = spyOn(browser, "openInBrowser").mockResolvedValue( + undefined as never + ); + }); + + afterEach(() => { + listDashboardsSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + openInBrowserSpy.mockRestore(); + }); + + test("outputs JSON array of dashboards with --json", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsSpy.mockResolvedValue([DASHBOARD_A, DASHBOARD_B]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { json: true, web: false }, undefined); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(2); + expect(parsed[0].id).toBe("1"); + expect(parsed[0].title).toBe("Errors Overview"); + expect(parsed[1].id).toBe("42"); + }); + + test("outputs empty JSON array when no dashboards exist", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { json: true, web: false }, undefined); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(JSON.parse(output)).toEqual([]); + }); + + test("outputs human-readable table with column headers", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsSpy.mockResolvedValue([DASHBOARD_A, DASHBOARD_B]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { json: false, web: false }, undefined); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("ID"); + expect(output).toContain("TITLE"); + expect(output).toContain("WIDGETS"); + expect(output).toContain("Errors Overview"); + expect(output).toContain("Performance"); + }); + + test("shows empty state message when no dashboards exist", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { json: false, web: false }, undefined); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No dashboards found."); + }); + + test("human output footer contains dashboards URL", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsSpy.mockResolvedValue([DASHBOARD_A]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { json: false, web: false }, undefined); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("dashboards"); + expect(output).toContain("test-org"); + }); + + test("uses org from positional argument", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + listDashboardsSpy.mockResolvedValue([DASHBOARD_A]); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { json: true, web: false }, "my-org/"); + + expect(listDashboardsSpy).toHaveBeenCalledWith("my-org"); + }); + + test("throws ContextError when org cannot be resolved", async () => { + resolveOrgSpy.mockResolvedValue(null); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + + await expect( + func.call(context, { json: false, web: false }, undefined) + ).rejects.toThrow("Organization"); + }); + + test("--web flag opens browser instead of listing", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { json: false, web: true }, undefined); + + expect(openInBrowserSpy).toHaveBeenCalled(); + expect(listDashboardsSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/test/commands/dashboard/resolve.test.ts b/test/commands/dashboard/resolve.test.ts new file mode 100644 index 000000000..c67ef2342 --- /dev/null +++ b/test/commands/dashboard/resolve.test.ts @@ -0,0 +1,148 @@ +/** + * Dashboard Resolution Utility Tests + * + * Tests for positional argument parsing, dashboard ID resolution, + * and org resolution in src/commands/dashboard/resolve.ts. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { + parseDashboardPositionalArgs, + resolveDashboardId, + resolveOrgFromTarget, +} from "../../../src/commands/dashboard/resolve.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { parseOrgProjectArg } from "../../../src/lib/arg-parsing.js"; +import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +// --------------------------------------------------------------------------- +// parseDashboardPositionalArgs +// --------------------------------------------------------------------------- + +describe("parseDashboardPositionalArgs", () => { + test("throws ValidationError for empty args", () => { + expect(() => parseDashboardPositionalArgs([])).toThrow(ValidationError); + }); + + test("error message contains 'Dashboard ID or title'", () => { + try { + parseDashboardPositionalArgs([]); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).message).toContain( + "Dashboard ID or title" + ); + } + }); + + test("single arg returns dashboardRef only", () => { + const result = parseDashboardPositionalArgs(["123"]); + expect(result.dashboardRef).toBe("123"); + expect(result.targetArg).toBeUndefined(); + }); + + test("two args returns target + dashboardRef", () => { + const result = parseDashboardPositionalArgs(["my-org/", "My Dashboard"]); + expect(result.dashboardRef).toBe("My Dashboard"); + expect(result.targetArg).toBe("my-org/"); + }); +}); + +// --------------------------------------------------------------------------- +// resolveDashboardId +// --------------------------------------------------------------------------- + +describe("resolveDashboardId", () => { + let listDashboardsSpy: ReturnType<typeof spyOn>; + + beforeEach(() => { + listDashboardsSpy = spyOn(apiClient, "listDashboards"); + }); + + afterEach(() => { + listDashboardsSpy.mockRestore(); + }); + + test("numeric string returns directly without API call", async () => { + const id = await resolveDashboardId("test-org", "42"); + expect(id).toBe("42"); + expect(listDashboardsSpy).not.toHaveBeenCalled(); + }); + + test("title match returns matching dashboard ID", async () => { + listDashboardsSpy.mockResolvedValue([ + { id: "10", title: "Errors Overview" }, + { id: "20", title: "Performance" }, + ]); + + const id = await resolveDashboardId("test-org", "Performance"); + expect(id).toBe("20"); + }); + + test("title match is case-insensitive", async () => { + listDashboardsSpy.mockResolvedValue([ + { id: "10", title: "Errors Overview" }, + ]); + + const id = await resolveDashboardId("test-org", "errors overview"); + expect(id).toBe("10"); + }); + + test("no match throws ValidationError with available dashboards", async () => { + listDashboardsSpy.mockResolvedValue([ + { id: "10", title: "Errors Overview" }, + { id: "20", title: "Performance" }, + ]); + + try { + await resolveDashboardId("test-org", "Missing Dashboard"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const message = (error as ValidationError).message; + expect(message).toContain("Missing Dashboard"); + expect(message).toContain("Errors Overview"); + expect(message).toContain("Performance"); + } + }); +}); + +// --------------------------------------------------------------------------- +// resolveOrgFromTarget +// --------------------------------------------------------------------------- + +describe("resolveOrgFromTarget", () => { + let resolveOrgSpy: ReturnType<typeof spyOn>; + + beforeEach(() => { + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + }); + + afterEach(() => { + resolveOrgSpy.mockRestore(); + }); + + test("explicit type returns org directly", async () => { + const parsed = parseOrgProjectArg("my-org/my-project"); + const org = await resolveOrgFromTarget( + parsed, + "/tmp", + "sentry dashboard view" + ); + expect(org).toBe("my-org"); + expect(resolveOrgSpy).not.toHaveBeenCalled(); + }); + + test("auto-detect with null resolveOrg throws ContextError", async () => { + resolveOrgSpy.mockResolvedValue(null); + const parsed = parseOrgProjectArg(undefined); + + await expect( + resolveOrgFromTarget(parsed, "/tmp", "sentry dashboard view") + ).rejects.toThrow(ContextError); + }); +}); diff --git a/test/lib/formatters/human.test.ts b/test/lib/formatters/human.test.ts index 88ec47b1f..ecdad7457 100644 --- a/test/lib/formatters/human.test.ts +++ b/test/lib/formatters/human.test.ts @@ -9,6 +9,8 @@ import { describe, expect, test } from "bun:test"; import { extractStatsPoints, + formatDashboardCreated, + formatDashboardView, formatIssueSubtitle, formatProjectCreated, formatShortId, @@ -672,3 +674,67 @@ describe("formatProjectCreated", () => { expect(result).toContain("sentry project view my-org/my-project"); }); }); + +describe("formatDashboardCreated", () => { + test("output contains title, ID, and URL", () => { + const result = stripAnsi( + formatDashboardCreated({ + id: "42", + title: "My Dashboard", + url: "https://acme.sentry.io/dashboard/42/", + }) + ); + expect(result).toContain("My Dashboard"); + expect(result).toContain("42"); + expect(result).toContain("https://acme.sentry.io/dashboard/42/"); + }); + + test("title with special chars is escaped", () => { + const result = stripAnsi( + formatDashboardCreated({ + id: "1", + title: "Dash | with * special", + url: "https://acme.sentry.io/dashboard/1/", + }) + ); + expect(result).toContain("Dash"); + expect(result).toContain("special"); + }); +}); + +describe("formatDashboardView", () => { + test("with widgets shows widget table headers", () => { + const result = stripAnsi( + formatDashboardView({ + id: "42", + title: "My Dashboard", + url: "https://acme.sentry.io/dashboard/42/", + widgets: [ + { + title: "Error Count", + displayType: "big_number", + widgetType: "spans", + layout: { x: 0, y: 0, w: 2, h: 1 }, + }, + ], + }) + ); + expect(result).toContain("TITLE"); + expect(result).toContain("DISPLAY"); + expect(result).toContain("TYPE"); + expect(result).toContain("LAYOUT"); + expect(result).toContain("Error Count"); + }); + + test("without widgets shows 'No widgets.'", () => { + const result = stripAnsi( + formatDashboardView({ + id: "42", + title: "Empty Dashboard", + url: "https://acme.sentry.io/dashboard/42/", + widgets: [], + }) + ); + expect(result).toContain("No widgets."); + }); +}); diff --git a/test/lib/sentry-urls.property.test.ts b/test/lib/sentry-urls.property.test.ts index 32f2c7afe..07428ed10 100644 --- a/test/lib/sentry-urls.property.test.ts +++ b/test/lib/sentry-urls.property.test.ts @@ -16,6 +16,8 @@ import { } from "fast-check"; import { buildBillingUrl, + buildDashboardsListUrl, + buildDashboardUrl, buildEventSearchUrl, buildLogsUrl, buildOrgSettingsUrl, @@ -74,6 +76,9 @@ const logIdArb = stringMatching(/^[a-f0-9]{32}$/); /** Valid trace IDs (32-char hex) */ const traceIdArb = stringMatching(/^[a-f0-9]{32}$/); +/** Valid dashboard IDs (numeric strings) */ +const dashboardIdArb = stringMatching(/^[1-9][0-9]{0,8}$/); + /** Common Sentry regions */ const sentryRegionArb = constantFrom("us", "de", "eu", "staging"); @@ -464,6 +469,71 @@ describe("buildTraceUrl properties", () => { }); }); +describe("buildDashboardsListUrl properties", () => { + test("output contains /dashboards/ path", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildDashboardsListUrl(orgSlug); + expect(result).toContain("/dashboards/"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains the org slug", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildDashboardsListUrl(orgSlug); + expect(result).toContain(orgSlug); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildDashboardsListUrl(orgSlug); + expect(() => new URL(result)).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildDashboardUrl properties", () => { + test("output contains /dashboard/{id}/ path", async () => { + await fcAssert( + property(tuple(slugArb, dashboardIdArb), ([orgSlug, dashboardId]) => { + const result = buildDashboardUrl(orgSlug, dashboardId); + expect(result).toContain(`/dashboard/${dashboardId}/`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains org slug and dashboard ID", async () => { + await fcAssert( + property(tuple(slugArb, dashboardIdArb), ([orgSlug, dashboardId]) => { + const result = buildDashboardUrl(orgSlug, dashboardId); + expect(result).toContain(orgSlug); + expect(result).toContain(dashboardId); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property(tuple(slugArb, dashboardIdArb), ([orgSlug, dashboardId]) => { + const result = buildDashboardUrl(orgSlug, dashboardId); + expect(() => new URL(result)).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + describe("SENTRY_HOST precedence", () => { test("SENTRY_HOST takes precedence over SENTRY_URL for URL builders", () => { process.env.SENTRY_HOST = "https://host.company.com"; @@ -518,6 +588,18 @@ describe("self-hosted URLs", () => { ); }); + test("buildDashboardsListUrl uses path-based pattern", () => { + expect(buildDashboardsListUrl("my-org")).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/dashboards/` + ); + }); + + test("buildDashboardUrl uses path-based pattern", () => { + expect(buildDashboardUrl("my-org", "42")).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/dashboard/42/` + ); + }); + test("buildProjectUrl uses path-based pattern", () => { expect(buildProjectUrl("my-org", "my-project")).toBe( `${SELF_HOSTED_URL}/settings/my-org/projects/my-project/` @@ -556,6 +638,8 @@ describe("self-hosted URLs", () => { buildBillingUrl(orgSlug), buildLogsUrl(orgSlug), buildTraceUrl(orgSlug, eventId), + buildDashboardsListUrl(orgSlug), + buildDashboardUrl(orgSlug, "1"), ]; for (const url of urls) { @@ -584,6 +668,8 @@ describe("URL building cross-function properties", () => { buildSeerSettingsUrl(orgSlug), buildBillingUrl(orgSlug), buildBillingUrl(orgSlug, product), + buildDashboardsListUrl(orgSlug), + buildDashboardUrl(orgSlug, "42"), ]; for (const url of urls) { @@ -616,6 +702,12 @@ describe("URL building cross-function properties", () => { expect(buildBillingUrl(orgSlug, product)).toBe( buildBillingUrl(orgSlug, product) ); + expect(buildDashboardsListUrl(orgSlug)).toBe( + buildDashboardsListUrl(orgSlug) + ); + expect(buildDashboardUrl(orgSlug, "42")).toBe( + buildDashboardUrl(orgSlug, "42") + ); } ), { numRuns: DEFAULT_NUM_RUNS } diff --git a/test/types/dashboard.test.ts b/test/types/dashboard.test.ts index c7491e565..fcea10581 100644 --- a/test/types/dashboard.test.ts +++ b/test/types/dashboard.test.ts @@ -7,6 +7,8 @@ import { describe, expect, test } from "bun:test"; import { + assignDefaultLayout, + type DashboardWidget, DashboardWidgetInputSchema, DEFAULT_WIDGET_TYPE, DISCOVER_AGGREGATE_FUNCTIONS, @@ -21,6 +23,7 @@ import { prepareWidgetQueries, SPAN_AGGREGATE_FUNCTIONS, SpanAggregateFunctionSchema, + stripWidgetServerFields, WIDGET_TYPES, type WidgetType, } from "../../src/types/dashboard.js"; @@ -549,3 +552,122 @@ describe("prepareWidgetQueries", () => { expect(widget.limit).toBe(100); }); }); + +// --------------------------------------------------------------------------- +// assignDefaultLayout +// --------------------------------------------------------------------------- + +describe("assignDefaultLayout", () => { + test("widget with existing layout returns unchanged", () => { + const widget: DashboardWidget = { + title: "Test", + displayType: "line", + layout: { x: 1, y: 2, w: 3, h: 2 }, + }; + const result = assignDefaultLayout(widget, []); + expect(result.layout).toEqual({ x: 1, y: 2, w: 3, h: 2 }); + }); + + test("widget without layout assigns default size at (0,0)", () => { + const widget: DashboardWidget = { + title: "Test", + displayType: "big_number", + }; + const result = assignDefaultLayout(widget, []); + expect(result.layout).toBeDefined(); + expect(result.layout!.x).toBe(0); + expect(result.layout!.y).toBe(0); + expect(result.layout!.w).toBe(2); + expect(result.layout!.h).toBe(1); + }); + + test("widget in partially filled grid finds first gap", () => { + const existing: DashboardWidget[] = [ + { + title: "Existing", + displayType: "big_number", + layout: { x: 0, y: 0, w: 2, h: 1 }, + }, + ]; + const widget: DashboardWidget = { + title: "New", + displayType: "big_number", + }; + const result = assignDefaultLayout(widget, existing); + expect(result.layout).toBeDefined(); + // Should be placed after the existing widget, not overlapping + expect(result.layout!.x).toBe(2); + expect(result.layout!.y).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// stripWidgetServerFields +// --------------------------------------------------------------------------- + +describe("stripWidgetServerFields", () => { + test("strips id, dashboardId, dateCreated from widget", () => { + const widget: DashboardWidget = { + id: "100", + dashboardId: "42", + dateCreated: "2026-01-01T00:00:00Z", + title: "Test", + displayType: "line", + widgetType: "spans", + }; + const result = stripWidgetServerFields(widget); + expect(result).not.toHaveProperty("id"); + expect(result).not.toHaveProperty("dashboardId"); + expect(result).not.toHaveProperty("dateCreated"); + expect(result.title).toBe("Test"); + expect(result.displayType).toBe("line"); + expect(result.widgetType).toBe("spans"); + }); + + test("strips server fields from queries", () => { + const widget: DashboardWidget = { + title: "Test", + displayType: "line", + queries: [ + { + id: "q1", + widgetId: "w1", + dateCreated: "2026-01-01T00:00:00Z", + aggregates: ["count()"], + conditions: "", + name: "Query 1", + }, + ], + }; + const result = stripWidgetServerFields(widget); + const query = result.queries![0]!; + expect(query).not.toHaveProperty("id"); + expect(query).not.toHaveProperty("widgetId"); + expect(query).not.toHaveProperty("dateCreated"); + expect(query.aggregates).toEqual(["count()"]); + expect(query.name).toBe("Query 1"); + }); + + test("preserves user-facing fields", () => { + const widget: DashboardWidget = { + title: "My Widget", + displayType: "table", + widgetType: "spans", + layout: { x: 0, y: 0, w: 6, h: 2 }, + queries: [ + { + aggregates: ["count()"], + columns: ["browser.name"], + conditions: "is:unresolved", + name: "", + }, + ], + }; + const result = stripWidgetServerFields(widget); + expect(result.title).toBe("My Widget"); + expect(result.displayType).toBe("table"); + expect(result.widgetType).toBe("spans"); + expect(result.layout).toBeDefined(); + expect(result.queries![0]!.conditions).toBe("is:unresolved"); + }); +}); From 20dfa4bcfc1d43386531a3607bb2ce67b98f67f4 Mon Sep 17 00:00:00 2001 From: betegon <miguelbetegongarcia@gmail.com> Date: Fri, 13 Mar 2026 12:11:57 +0100 Subject: [PATCH 03/10] refactor(dashboard): align list/view commands to codebase patterns - Convert list from buildListCommand to buildCommand with return-based output - Extract formatDashboardListHuman for human-readable table rendering - Add --fresh flag to both list and view commands - Update list tests to include fresh flag Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- src/commands/dashboard/list.ts | 138 +++++++++++++-------------- src/commands/dashboard/view.ts | 10 +- test/commands/dashboard/list.test.ts | 44 +++++++-- 3 files changed, 113 insertions(+), 79 deletions(-) diff --git a/src/commands/dashboard/list.ts b/src/commands/dashboard/list.ts index 516eb12c2..a612fc79e 100644 --- a/src/commands/dashboard/list.ts +++ b/src/commands/dashboard/list.ts @@ -8,51 +8,63 @@ import type { SentryContext } from "../../context.js"; import { listDashboards } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; -import { ContextError } from "../../lib/errors.js"; -import { writeFooter, writeJson } from "../../lib/formatters/index.js"; +import { buildCommand } from "../../lib/command.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { - buildListCommand, - LIST_TARGET_POSITIONAL, + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, } from "../../lib/list-command.js"; -import { resolveOrg } from "../../lib/resolve-target.js"; import { buildDashboardsListUrl } from "../../lib/sentry-urls.js"; import type { DashboardListItem } from "../../types/dashboard.js"; +import type { Writer } from "../../types/index.js"; +import { resolveOrgFromTarget } from "./resolve.js"; type ListFlags = { readonly web: boolean; + readonly fresh: boolean; readonly json: boolean; readonly fields?: string[]; }; -/** Resolve org slug from parsed target argument */ -async function resolveOrgFromTarget( - parsed: ReturnType<typeof parseOrgProjectArg>, - cwd: string -): Promise<string> { - switch (parsed.type) { - case "explicit": - case "org-all": - return parsed.org; - case "project-search": - case "auto-detect": { - const resolved = await resolveOrg({ cwd }); - if (!resolved) { - throw new ContextError("Organization", "sentry dashboard list <org>/"); - } - return resolved.org; - } - default: { - const _exhaustive: never = parsed; - throw new Error( - `Unexpected parsed type: ${(_exhaustive as { type: string }).type}` - ); - } +/** + * Format dashboard list for human-readable terminal output. + * + * Renders a table with ID, title, and widget count columns. + * Returns "No dashboards found." for empty results. + */ +function formatDashboardListHuman(dashboards: DashboardListItem[]): string { + if (dashboards.length === 0) { + return "No dashboards found."; } + + type DashboardRow = { + id: string; + title: string; + widgets: string; + }; + + const rows: DashboardRow[] = dashboards.map((d) => ({ + id: d.id, + title: escapeMarkdownCell(d.title), + widgets: String(d.widgetDisplay?.length ?? 0), + })); + + const columns: Column<DashboardRow>[] = [ + { header: "ID", value: (r) => r.id }, + { header: "TITLE", value: (r) => r.title }, + { header: "WIDGETS", value: (r) => r.widgets }, + ]; + + const parts: string[] = []; + const buffer: Writer = { write: (s) => parts.push(s) }; + writeTable(buffer, rows, columns); + + return parts.join("").trimEnd(); } -export const listCommand = buildListCommand("dashboard", { +export const listCommand = buildCommand({ docs: { brief: "List dashboards", fullDescription: @@ -63,27 +75,40 @@ export const listCommand = buildListCommand("dashboard", { " sentry dashboard list --json\n" + " sentry dashboard list --web", }, - output: "json", + output: { json: true, human: formatDashboardListHuman }, parameters: { - positional: LIST_TARGET_POSITIONAL, + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: + "<org>/ (all projects), <org>/<project>, or <project> (search)", + parse: String, + optional: true, + }, + ], + }, flags: { web: { kind: "boolean", brief: "Open in browser", default: false, }, + fresh: FRESH_FLAG, }, - aliases: { w: "web" }, + aliases: { ...FRESH_ALIASES, w: "web" }, }, - async func( - this: SentryContext, - flags: ListFlags, - target?: string - ): Promise<void> { - const { stdout, cwd } = this; + async func(this: SentryContext, flags: ListFlags, target?: string) { + applyFreshFlag(flags); + const { cwd } = this; const parsed = parseOrgProjectArg(target); - const orgSlug = await resolveOrgFromTarget(parsed, cwd); + const orgSlug = await resolveOrgFromTarget( + parsed, + cwd, + "sentry dashboard list <org>/" + ); if (flags.web) { await openInBrowser(buildDashboardsListUrl(orgSlug), "dashboards"); @@ -91,38 +116,11 @@ export const listCommand = buildListCommand("dashboard", { } const dashboards = await listDashboards(orgSlug); + const url = buildDashboardsListUrl(orgSlug); - if (flags.json) { - writeJson(stdout, dashboards, flags.fields); - return; - } - - if (dashboards.length === 0) { - stdout.write("No dashboards found.\n"); - return; - } - - type DashboardRow = { - id: string; - title: string; - widgets: string; + return { + data: dashboards, + hint: dashboards.length > 0 ? `Dashboards: ${url}` : undefined, }; - - const rows: DashboardRow[] = dashboards.map((d: DashboardListItem) => ({ - id: d.id, - title: escapeMarkdownCell(d.title), - widgets: String(d.widgetDisplay?.length ?? 0), - })); - - const columns: Column<DashboardRow>[] = [ - { header: "ID", value: (r) => r.id }, - { header: "TITLE", value: (r) => r.title }, - { header: "WIDGETS", value: (r) => r.widgets }, - ]; - - writeTable(stdout, rows, columns); - - const url = buildDashboardsListUrl(orgSlug); - writeFooter(stdout, `Dashboards: ${url}`); }, }); diff --git a/src/commands/dashboard/view.ts b/src/commands/dashboard/view.ts index 6a4f53e1c..3f903710c 100644 --- a/src/commands/dashboard/view.ts +++ b/src/commands/dashboard/view.ts @@ -10,6 +10,11 @@ import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { formatDashboardView } from "../../lib/formatters/human.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; import { buildDashboardUrl } from "../../lib/sentry-urls.js"; import type { DashboardDetail } from "../../types/dashboard.js"; import { @@ -20,6 +25,7 @@ import { type ViewFlags = { readonly web: boolean; + readonly fresh: boolean; readonly json: boolean; readonly fields?: string[]; }; @@ -57,10 +63,12 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, + fresh: FRESH_FLAG, }, - aliases: { w: "web" }, + aliases: { ...FRESH_ALIASES, w: "web" }, }, async func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + applyFreshFlag(flags); const { cwd } = this; const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args); diff --git a/test/commands/dashboard/list.test.ts b/test/commands/dashboard/list.test.ts index 94f8ec629..169a1b652 100644 --- a/test/commands/dashboard/list.test.ts +++ b/test/commands/dashboard/list.test.ts @@ -92,7 +92,11 @@ describe("dashboard list command", () => { const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: true, web: false }, undefined); + await func.call( + context, + { json: true, web: false, fresh: false }, + undefined + ); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); const parsed = JSON.parse(output); @@ -109,7 +113,11 @@ describe("dashboard list command", () => { const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: true, web: false }, undefined); + await func.call( + context, + { json: true, web: false, fresh: false }, + undefined + ); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(JSON.parse(output)).toEqual([]); @@ -121,7 +129,11 @@ describe("dashboard list command", () => { const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: false, web: false }, undefined); + await func.call( + context, + { json: false, web: false, fresh: false }, + undefined + ); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("ID"); @@ -137,7 +149,11 @@ describe("dashboard list command", () => { const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: false, web: false }, undefined); + await func.call( + context, + { json: false, web: false, fresh: false }, + undefined + ); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("No dashboards found."); @@ -149,7 +165,11 @@ describe("dashboard list command", () => { const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: false, web: false }, undefined); + await func.call( + context, + { json: false, web: false, fresh: false }, + undefined + ); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("dashboards"); @@ -162,7 +182,11 @@ describe("dashboard list command", () => { const { context } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: true, web: false }, "my-org/"); + await func.call( + context, + { json: true, web: false, fresh: false }, + "my-org/" + ); expect(listDashboardsSpy).toHaveBeenCalledWith("my-org"); }); @@ -174,7 +198,7 @@ describe("dashboard list command", () => { const func = await listCommand.loader(); await expect( - func.call(context, { json: false, web: false }, undefined) + func.call(context, { json: false, web: false, fresh: false }, undefined) ).rejects.toThrow("Organization"); }); @@ -183,7 +207,11 @@ describe("dashboard list command", () => { const { context } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: false, web: true }, undefined); + await func.call( + context, + { json: false, web: true, fresh: false }, + undefined + ); expect(openInBrowserSpy).toHaveBeenCalled(); expect(listDashboardsSpy).not.toHaveBeenCalled(); From 681d476e93c40edafb6d9484279196b3ad9b82c9 Mon Sep 17 00:00:00 2001 From: betegon <miguelbetegongarcia@gmail.com> Date: Fri, 13 Mar 2026 12:21:59 +0100 Subject: [PATCH 04/10] fix(dashboard): use args[1] pattern for positional arg parsing in resolve.ts Align parseDashboardPositionalArgs with the established codebase pattern used in event/view, trace/view, trace/logs, and dashboard/create. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- src/commands/dashboard/resolve.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/dashboard/resolve.ts b/src/commands/dashboard/resolve.ts index f0615c195..81f13c574 100644 --- a/src/commands/dashboard/resolve.ts +++ b/src/commands/dashboard/resolve.ts @@ -77,7 +77,7 @@ export function parseDashboardPositionalArgs(args: string[]): { }; } return { - dashboardRef: args.at(-1) as string, + dashboardRef: args[1] as string, targetArg: args[0] as string, }; } From 48a3b8ba2807c4f25cd519a3d87eb6e78461c0b7 Mon Sep 17 00:00:00 2001 From: betegon <miguelbetegongarcia@gmail.com> Date: Fri, 13 Mar 2026 12:32:53 +0100 Subject: [PATCH 05/10] fix(dashboard): remove unused updateDashboard export updateDashboard has no callers in the core dashboard commands (list, view, create). It will be added back when widget commands are introduced. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- src/lib/api-client.ts | 1 - src/lib/api/dashboards.ts | 23 ----------------------- 2 files changed, 24 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index bcb28d30b..b7f44676d 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -23,7 +23,6 @@ export { createDashboard, getDashboard, listDashboards, - updateDashboard, } from "./api/dashboards.js"; export { findEventAcrossOrgs, diff --git a/src/lib/api/dashboards.ts b/src/lib/api/dashboards.ts index 31930548c..62fcc6541 100644 --- a/src/lib/api/dashboards.ts +++ b/src/lib/api/dashboards.ts @@ -69,26 +69,3 @@ export async function createDashboard( ); return data; } - -/** - * Update a dashboard (full PUT — replaces all widgets). - * Always GET first, modify, then PUT the full widget list. - * - * @param orgSlug - Organization slug - * @param dashboardId - Dashboard ID - * @param body - Dashboard update body (title, widgets) - * @returns Updated dashboard detail - */ -export async function updateDashboard( - orgSlug: string, - dashboardId: string, - body: { title: string; widgets: DashboardWidget[]; projects?: number[] } -): Promise<DashboardDetail> { - const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion<DashboardDetail>( - regionUrl, - `/organizations/${orgSlug}/dashboards/${dashboardId}/`, - { method: "PUT", body } - ); - return data; -} From af8cbbd752a5731b9f27a61962beb5246083dd43 Mon Sep 17 00:00:00 2001 From: betegon <miguelbetegongarcia@gmail.com> Date: Fri, 13 Mar 2026 12:35:53 +0100 Subject: [PATCH 06/10] fix(dashboard): remove unused prepareDashboardForUpdate and stripWidgetServerFields These functions are only needed for widget update operations (add/edit/delete), which are not part of the core dashboard commands (list, view, create). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- src/types/dashboard.ts | 81 ------------------------------------ test/types/dashboard.test.ts | 72 -------------------------------- 2 files changed, 153 deletions(-) diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts index d9b1f9267..b19af4123 100644 --- a/src/types/dashboard.ts +++ b/src/types/dashboard.ts @@ -504,84 +504,3 @@ export function assignDefaultLayout( // No gap found — place below everything return { ...widget, layout: { x: 0, y: maxY, w, h, minH } }; } - -// --------------------------------------------------------------------------- -// Server field stripping utilities -// --------------------------------------------------------------------------- - -/** - * Server-generated fields on widget queries that must be stripped before PUT. - * NEVER strip user-controlled fields like conditions, columns, aggregates. - */ -const QUERY_SERVER_FIELDS = ["id", "widgetId", "dateCreated"] as const; - -/** - * Server-generated fields on widgets that must be stripped before PUT. - * CRITICAL: Never strip widgetType, displayType, or layout — these are - * user-controlled and stripping them causes widgets to reset to defaults. - */ -const WIDGET_SERVER_FIELDS = ["id", "dashboardId", "dateCreated"] as const; - -/** - * Server-generated fields on widget layout that must be stripped before PUT. - */ -const LAYOUT_SERVER_FIELDS = ["isResizable"] as const; - -/** - * Strip server-generated fields from a single widget for PUT requests. - * - * @param widget - Widget object from GET response - * @returns Widget safe for PUT (widgetType, displayType, layout preserved) - */ -export function stripWidgetServerFields( - widget: DashboardWidget -): DashboardWidget { - const cleaned = { ...widget }; - - // Strip widget-level server fields - for (const field of WIDGET_SERVER_FIELDS) { - delete (cleaned as Record<string, unknown>)[field]; - } - - // Strip query-level server fields - if (cleaned.queries) { - cleaned.queries = cleaned.queries.map((q) => { - const cleanedQuery = { ...q }; - for (const field of QUERY_SERVER_FIELDS) { - delete (cleanedQuery as Record<string, unknown>)[field]; - } - return cleanedQuery; - }); - } - - // Strip layout server fields - if (cleaned.layout) { - const cleanedLayout = { ...cleaned.layout }; - for (const field of LAYOUT_SERVER_FIELDS) { - delete (cleanedLayout as Record<string, unknown>)[field]; - } - cleaned.layout = cleanedLayout; - } - - return cleaned; -} - -/** - * Prepare a full dashboard for PUT update. - * Strips server-generated fields from all widgets while preserving - * widgetType, displayType, and layout. - * - * @param dashboard - Dashboard detail from GET response - * @returns Object with title and cleaned widgets, ready for PUT body - */ -export function prepareDashboardForUpdate(dashboard: DashboardDetail): { - title: string; - widgets: DashboardWidget[]; - projects?: number[]; -} { - return { - title: dashboard.title, - widgets: (dashboard.widgets ?? []).map(stripWidgetServerFields), - projects: dashboard.projects, - }; -} diff --git a/test/types/dashboard.test.ts b/test/types/dashboard.test.ts index fcea10581..7b0ea6452 100644 --- a/test/types/dashboard.test.ts +++ b/test/types/dashboard.test.ts @@ -23,7 +23,6 @@ import { prepareWidgetQueries, SPAN_AGGREGATE_FUNCTIONS, SpanAggregateFunctionSchema, - stripWidgetServerFields, WIDGET_TYPES, type WidgetType, } from "../../src/types/dashboard.js"; @@ -600,74 +599,3 @@ describe("assignDefaultLayout", () => { expect(result.layout!.y).toBe(0); }); }); - -// --------------------------------------------------------------------------- -// stripWidgetServerFields -// --------------------------------------------------------------------------- - -describe("stripWidgetServerFields", () => { - test("strips id, dashboardId, dateCreated from widget", () => { - const widget: DashboardWidget = { - id: "100", - dashboardId: "42", - dateCreated: "2026-01-01T00:00:00Z", - title: "Test", - displayType: "line", - widgetType: "spans", - }; - const result = stripWidgetServerFields(widget); - expect(result).not.toHaveProperty("id"); - expect(result).not.toHaveProperty("dashboardId"); - expect(result).not.toHaveProperty("dateCreated"); - expect(result.title).toBe("Test"); - expect(result.displayType).toBe("line"); - expect(result.widgetType).toBe("spans"); - }); - - test("strips server fields from queries", () => { - const widget: DashboardWidget = { - title: "Test", - displayType: "line", - queries: [ - { - id: "q1", - widgetId: "w1", - dateCreated: "2026-01-01T00:00:00Z", - aggregates: ["count()"], - conditions: "", - name: "Query 1", - }, - ], - }; - const result = stripWidgetServerFields(widget); - const query = result.queries![0]!; - expect(query).not.toHaveProperty("id"); - expect(query).not.toHaveProperty("widgetId"); - expect(query).not.toHaveProperty("dateCreated"); - expect(query.aggregates).toEqual(["count()"]); - expect(query.name).toBe("Query 1"); - }); - - test("preserves user-facing fields", () => { - const widget: DashboardWidget = { - title: "My Widget", - displayType: "table", - widgetType: "spans", - layout: { x: 0, y: 0, w: 6, h: 2 }, - queries: [ - { - aggregates: ["count()"], - columns: ["browser.name"], - conditions: "is:unresolved", - name: "", - }, - ], - }; - const result = stripWidgetServerFields(widget); - expect(result.title).toBe("My Widget"); - expect(result.displayType).toBe("table"); - expect(result.widgetType).toBe("spans"); - expect(result.layout).toBeDefined(); - expect(result.queries![0]!.conditions).toBe("is:unresolved"); - }); -}); From e11d6e4b76cbb2a56ebaaf310c5cc4c8626e9ee0 Mon Sep 17 00:00:00 2001 From: betegon <miguelbetegongarcia@gmail.com> Date: Fri, 13 Mar 2026 13:06:38 +0100 Subject: [PATCH 07/10] feat(dashboard): add clickable titles, --limit flag, and loading spinner to list - Title column renders as markdown link to dashboard URL (clickable in terminal) - Add --limit flag (default 30) with per_page passed to API - Wrap fetch in withProgress() for "Fetching dashboards..." spinner - Add jsonTransform to keep JSON output as plain array Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- src/commands/dashboard/list.ts | 39 +++++++++++++++------ src/lib/api/dashboards.ts | 7 ++-- test/commands/dashboard/list.test.ts | 51 +++++++++++++++++++++++----- 3 files changed, 76 insertions(+), 21 deletions(-) diff --git a/src/commands/dashboard/list.ts b/src/commands/dashboard/list.ts index a612fc79e..76ba57639 100644 --- a/src/commands/dashboard/list.ts +++ b/src/commands/dashboard/list.ts @@ -13,10 +13,15 @@ import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { applyFreshFlag, + buildListLimitFlag, FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; -import { buildDashboardsListUrl } from "../../lib/sentry-urls.js"; +import { withProgress } from "../../lib/polling.js"; +import { + buildDashboardsListUrl, + buildDashboardUrl, +} from "../../lib/sentry-urls.js"; import type { DashboardListItem } from "../../types/dashboard.js"; import type { Writer } from "../../types/index.js"; import { resolveOrgFromTarget } from "./resolve.js"; @@ -24,18 +29,24 @@ import { resolveOrgFromTarget } from "./resolve.js"; type ListFlags = { readonly web: boolean; readonly fresh: boolean; + readonly limit: number; readonly json: boolean; readonly fields?: string[]; }; +type DashboardListResult = { + dashboards: DashboardListItem[]; + orgSlug: string; +}; + /** * Format dashboard list for human-readable terminal output. * - * Renders a table with ID, title, and widget count columns. + * Renders a table with ID, title (clickable link), and widget count columns. * Returns "No dashboards found." for empty results. */ -function formatDashboardListHuman(dashboards: DashboardListItem[]): string { - if (dashboards.length === 0) { +function formatDashboardListHuman(result: DashboardListResult): string { + if (result.dashboards.length === 0) { return "No dashboards found."; } @@ -45,9 +56,9 @@ function formatDashboardListHuman(dashboards: DashboardListItem[]): string { widgets: string; }; - const rows: DashboardRow[] = dashboards.map((d) => ({ + const rows: DashboardRow[] = result.dashboards.map((d) => ({ id: d.id, - title: escapeMarkdownCell(d.title), + title: `[${escapeMarkdownCell(d.title)}](${buildDashboardUrl(result.orgSlug, d.id)})`, widgets: String(d.widgetDisplay?.length ?? 0), })); @@ -75,7 +86,11 @@ export const listCommand = buildCommand({ " sentry dashboard list --json\n" + " sentry dashboard list --web", }, - output: { json: true, human: formatDashboardListHuman }, + output: { + json: true, + human: formatDashboardListHuman, + jsonTransform: (result: DashboardListResult) => result.dashboards, + }, parameters: { positional: { kind: "tuple", @@ -95,9 +110,10 @@ export const listCommand = buildCommand({ brief: "Open in browser", default: false, }, + limit: buildListLimitFlag("dashboards"), fresh: FRESH_FLAG, }, - aliases: { ...FRESH_ALIASES, w: "web" }, + aliases: { ...FRESH_ALIASES, w: "web", n: "limit" }, }, async func(this: SentryContext, flags: ListFlags, target?: string) { applyFreshFlag(flags); @@ -115,11 +131,14 @@ export const listCommand = buildCommand({ return; } - const dashboards = await listDashboards(orgSlug); + const dashboards = await withProgress( + { message: `Fetching dashboards (up to ${flags.limit})...` }, + () => listDashboards(orgSlug, { perPage: flags.limit }) + ); const url = buildDashboardsListUrl(orgSlug); return { - data: dashboards, + data: { dashboards, orgSlug } as DashboardListResult, hint: dashboards.length > 0 ? `Dashboards: ${url}` : undefined, }; }, diff --git a/src/lib/api/dashboards.ts b/src/lib/api/dashboards.ts index 62fcc6541..3c88b228f 100644 --- a/src/lib/api/dashboards.ts +++ b/src/lib/api/dashboards.ts @@ -18,15 +18,18 @@ import { apiRequestToRegion } from "./infrastructure.js"; * List dashboards in an organization. * * @param orgSlug - Organization slug + * @param options - Optional pagination parameters * @returns Array of dashboard list items */ export async function listDashboards( - orgSlug: string + orgSlug: string, + options: { perPage?: number } = {} ): Promise<DashboardListItem[]> { const regionUrl = await resolveOrgRegion(orgSlug); const { data } = await apiRequestToRegion<DashboardListItem[]>( regionUrl, - `/organizations/${orgSlug}/dashboards/` + `/organizations/${orgSlug}/dashboards/`, + { params: { per_page: options.perPage } } ); return data; } diff --git a/test/commands/dashboard/list.test.ts b/test/commands/dashboard/list.test.ts index 169a1b652..c74d1bb12 100644 --- a/test/commands/dashboard/list.test.ts +++ b/test/commands/dashboard/list.test.ts @@ -21,6 +21,8 @@ import * as apiClient from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as browser from "../../../src/lib/browser.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as polling from "../../../src/lib/polling.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { DashboardListItem } from "../../../src/types/dashboard.js"; @@ -71,6 +73,7 @@ describe("dashboard list command", () => { let listDashboardsSpy: ReturnType<typeof spyOn>; let resolveOrgSpy: ReturnType<typeof spyOn>; let openInBrowserSpy: ReturnType<typeof spyOn>; + let withProgressSpy: ReturnType<typeof spyOn>; beforeEach(() => { listDashboardsSpy = spyOn(apiClient, "listDashboards"); @@ -78,12 +81,20 @@ describe("dashboard list command", () => { openInBrowserSpy = spyOn(browser, "openInBrowser").mockResolvedValue( undefined as never ); + // Bypass spinner — just run the callback directly + withProgressSpy = spyOn(polling, "withProgress").mockImplementation( + (_opts, fn) => + fn(() => { + /* no-op setMessage */ + }) + ); }); afterEach(() => { listDashboardsSpy.mockRestore(); resolveOrgSpy.mockRestore(); openInBrowserSpy.mockRestore(); + withProgressSpy.mockRestore(); }); test("outputs JSON array of dashboards with --json", async () => { @@ -94,7 +105,7 @@ describe("dashboard list command", () => { const func = await listCommand.loader(); await func.call( context, - { json: true, web: false, fresh: false }, + { json: true, web: false, fresh: false, limit: 30 }, undefined ); @@ -115,7 +126,7 @@ describe("dashboard list command", () => { const func = await listCommand.loader(); await func.call( context, - { json: true, web: false, fresh: false }, + { json: true, web: false, fresh: false, limit: 30 }, undefined ); @@ -131,7 +142,7 @@ describe("dashboard list command", () => { const func = await listCommand.loader(); await func.call( context, - { json: false, web: false, fresh: false }, + { json: false, web: false, fresh: false, limit: 30 }, undefined ); @@ -151,7 +162,7 @@ describe("dashboard list command", () => { const func = await listCommand.loader(); await func.call( context, - { json: false, web: false, fresh: false }, + { json: false, web: false, fresh: false, limit: 30 }, undefined ); @@ -167,7 +178,7 @@ describe("dashboard list command", () => { const func = await listCommand.loader(); await func.call( context, - { json: false, web: false, fresh: false }, + { json: false, web: false, fresh: false, limit: 30 }, undefined ); @@ -184,11 +195,11 @@ describe("dashboard list command", () => { const func = await listCommand.loader(); await func.call( context, - { json: true, web: false, fresh: false }, + { json: true, web: false, fresh: false, limit: 30 }, "my-org/" ); - expect(listDashboardsSpy).toHaveBeenCalledWith("my-org"); + expect(listDashboardsSpy).toHaveBeenCalledWith("my-org", { perPage: 30 }); }); test("throws ContextError when org cannot be resolved", async () => { @@ -198,7 +209,11 @@ describe("dashboard list command", () => { const func = await listCommand.loader(); await expect( - func.call(context, { json: false, web: false, fresh: false }, undefined) + func.call( + context, + { json: false, web: false, fresh: false, limit: 30 }, + undefined + ) ).rejects.toThrow("Organization"); }); @@ -209,11 +224,29 @@ describe("dashboard list command", () => { const func = await listCommand.loader(); await func.call( context, - { json: false, web: true, fresh: false }, + { json: false, web: true, fresh: false, limit: 30 }, undefined ); expect(openInBrowserSpy).toHaveBeenCalled(); expect(listDashboardsSpy).not.toHaveBeenCalled(); }); + + test("passes limit to API via withProgress", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsSpy.mockResolvedValue([DASHBOARD_A]); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { json: true, web: false, fresh: false, limit: 10 }, + undefined + ); + + expect(withProgressSpy).toHaveBeenCalled(); + expect(listDashboardsSpy).toHaveBeenCalledWith("test-org", { + perPage: 10, + }); + }); }); From 25af073e511b4790dc08dd699129f4c30657c186 Mon Sep 17 00:00:00 2001 From: betegon <miguelbetegongarcia@gmail.com> Date: Fri, 13 Mar 2026 13:29:46 +0100 Subject: [PATCH 08/10] fix(dashboard): show URL below title instead of OSC 8 link Replace invisible OSC 8 terminal hyperlink on title with a visible muted URL on a second line, so it's auto-clickable by the terminal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- src/commands/dashboard/list.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/commands/dashboard/list.ts b/src/commands/dashboard/list.ts index 76ba57639..5c3bd97e2 100644 --- a/src/commands/dashboard/list.ts +++ b/src/commands/dashboard/list.ts @@ -9,7 +9,7 @@ import { listDashboards } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { applyFreshFlag, @@ -56,11 +56,14 @@ function formatDashboardListHuman(result: DashboardListResult): string { widgets: string; }; - const rows: DashboardRow[] = result.dashboards.map((d) => ({ - id: d.id, - title: `[${escapeMarkdownCell(d.title)}](${buildDashboardUrl(result.orgSlug, d.id)})`, - widgets: String(d.widgetDisplay?.length ?? 0), - })); + const rows: DashboardRow[] = result.dashboards.map((d) => { + const url = buildDashboardUrl(result.orgSlug, d.id); + return { + id: d.id, + title: `${escapeMarkdownCell(d.title)}\n${colorTag("muted", url)}`, + widgets: String(d.widgetDisplay?.length ?? 0), + }; + }); const columns: Column<DashboardRow>[] = [ { header: "ID", value: (r) => r.id }, From 67b80d5245dfac2fc5fac5088743e4c322ef8536 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya <byk@sentry.io> Date: Tue, 17 Mar 2026 12:18:47 +0000 Subject: [PATCH 09/10] refactor(dashboard): convert commands to generator-based output system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert dashboard list, view, and create commands to use the new async generator pattern with CommandOutput class: - async func() → async *func() generators - return { data } → yield new CommandOutput(data) - Remove json: true from output config (presence of output implies it) - Regenerate SKILL.md to include dashboard commands --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 41 +++++++++++++++++++ src/commands/dashboard/create.ts | 8 ++-- src/commands/dashboard/list.ts | 6 +-- src/commands/dashboard/view.ts | 8 ++-- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 885643cc7..ac94734d7 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -505,6 +505,47 @@ Update the Sentry CLI to the latest version - `--json - Output as JSON` - `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)` +### Dashboard + +Manage Sentry dashboards + +#### `sentry dashboard list <org/project>` + +List dashboards + +**Flags:** +- `-w, --web - Open in browser` +- `-n, --limit <value> - Maximum number of dashboards to list - (default: "30")` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `--json - Output as JSON` +- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)` + +#### `sentry dashboard view <args...>` + +View a dashboard + +**Flags:** +- `-w, --web - Open in browser` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `--json - Output as JSON` +- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)` + +#### `sentry dashboard create <args...>` + +Create a dashboard + +**Flags:** +- `--widget-title <value> - Inline widget title` +- `--widget-display <value> - Inline widget display type (line, bar, table, big_number, ...)` +- `--widget-dataset <value> - Inline widget dataset (default: spans)` +- `--widget-query <value>... - Inline widget aggregate (e.g. count, p95:span.duration)` +- `--widget-where <value> - Inline widget search conditions filter` +- `--widget-group-by <value>... - Inline widget group-by column (repeatable)` +- `--widget-sort <value> - Inline widget order by (prefix - for desc)` +- `--widget-limit <value> - Inline widget result limit` +- `--json - Output as JSON` +- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)` + ### Repo Work with Sentry repositories diff --git a/src/commands/dashboard/create.ts b/src/commands/dashboard/create.ts index 99c43c8dd..82540b520 100644 --- a/src/commands/dashboard/create.ts +++ b/src/commands/dashboard/create.ts @@ -13,6 +13,7 @@ import { import { buildCommand, numberParser } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatDashboardCreated } from "../../lib/formatters/human.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { fetchProjectId, resolveAllTargets, @@ -197,7 +198,6 @@ export const createCommand = buildCommand({ ' --widget-title "Error Count" --widget-display big_number --widget-query count', }, output: { - json: true, human: formatDashboardCreated, }, parameters: { @@ -261,7 +261,7 @@ export const createCommand = buildCommand({ }, }, }, - async func(this: SentryContext, flags: CreateFlags, ...args: string[]) { + async *func(this: SentryContext, flags: CreateFlags, ...args: string[]) { const { cwd } = this; const { title, targetArg } = parsePositionalArgs(args); @@ -289,8 +289,6 @@ export const createCommand = buildCommand({ }); const url = buildDashboardUrl(orgSlug, dashboard.id); - return { - data: { ...dashboard, url } as CreateResult, - }; + yield new CommandOutput({ ...dashboard, url } as CreateResult); }, }); diff --git a/src/commands/dashboard/list.ts b/src/commands/dashboard/list.ts index 5c3bd97e2..c1d46bac2 100644 --- a/src/commands/dashboard/list.ts +++ b/src/commands/dashboard/list.ts @@ -10,6 +10,7 @@ import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { applyFreshFlag, @@ -90,7 +91,6 @@ export const listCommand = buildCommand({ " sentry dashboard list --web", }, output: { - json: true, human: formatDashboardListHuman, jsonTransform: (result: DashboardListResult) => result.dashboards, }, @@ -118,7 +118,7 @@ export const listCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web", n: "limit" }, }, - async func(this: SentryContext, flags: ListFlags, target?: string) { + async *func(this: SentryContext, flags: ListFlags, target?: string) { applyFreshFlag(flags); const { cwd } = this; @@ -140,8 +140,8 @@ export const listCommand = buildCommand({ ); const url = buildDashboardsListUrl(orgSlug); + yield new CommandOutput({ dashboards, orgSlug } as DashboardListResult); return { - data: { dashboards, orgSlug } as DashboardListResult, hint: dashboards.length > 0 ? `Dashboards: ${url}` : undefined, }; }, diff --git a/src/commands/dashboard/view.ts b/src/commands/dashboard/view.ts index 3f903710c..ef75540e6 100644 --- a/src/commands/dashboard/view.ts +++ b/src/commands/dashboard/view.ts @@ -10,6 +10,7 @@ import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { formatDashboardView } from "../../lib/formatters/human.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -46,7 +47,6 @@ export const viewCommand = buildCommand({ " sentry dashboard view 12345 --web", }, output: { - json: true, human: formatDashboardView, }, parameters: { @@ -67,7 +67,7 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd } = this; @@ -89,8 +89,6 @@ export const viewCommand = buildCommand({ const dashboard = await getDashboard(orgSlug, dashboardId); - return { - data: { ...dashboard, url } as ViewResult, - }; + yield new CommandOutput({ ...dashboard, url } as ViewResult); }, }); From d91d8642808ac51b905bd94e4396109eb58df0da Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya <byk@sentry.io> Date: Tue, 17 Mar 2026 12:37:27 +0000 Subject: [PATCH 10/10] refactor(dashboard): remove inline widget flags from create command Remove all 8 --widget-* flags from 'dashboard create', leaving it as a clean 'sentry dashboard create [<org/project>] <title>' with 0 flags for core functionality. The inline widget creation is deferred to the planned 'dashboard widget add' command in the follow-up PR. The widget type infrastructure (parseAggregate, parseWidgetInput, assignDefaultLayout, etc.) remains in src/types/dashboard.ts as pre-staged code for that PR. Removed: - buildInlineWidget() function and all widget validation logic - 8 flag definitions (widget-title, widget-display, widget-dataset, widget-query, widget-where, widget-group-by, widget-sort, widget-limit) - 3 widget-related tests - ~190 lines net reduction --- AGENTS.md | 80 +++++------ plugins/sentry-cli/skills/sentry-cli/SKILL.md | 8 -- src/commands/dashboard/create.ts | 133 +----------------- test/commands/dashboard/create.test.ts | 70 --------- 4 files changed, 43 insertions(+), 248 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0644a2871..8201696b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -767,69 +767,65 @@ mock.module("./some-module", () => ({ ### Architecture -<!-- lore:019ce2be-39f1-7ad9-a4c5-4506b62f689c --> -* **api-client.ts split into domain modules under src/lib/api/**: The original monolithic \`src/lib/api-client.ts\` (1,977 lines) was split into 12 focused domain modules under \`src/lib/api/\`: infrastructure.ts (shared helpers, types, raw requests), organizations.ts, projects.ts, teams.ts, repositories.ts, issues.ts, events.ts, traces.ts, logs.ts, seer.ts, trials.ts, users.ts. The original \`api-client.ts\` was converted to a ~100-line barrel re-export file preserving all existing import paths. The \`biome.jsonc\` override for \`noBarrelFile\` already includes \`api-client.ts\`. When adding new API functions, place them in the appropriate domain module under \`src/lib/api/\`, not in the barrel file. +<!-- lore:365e4299-37cf-48e0-8f2e-8503d4a249dd --> +* **API client wraps all errors as CliError subclasses — no raw exceptions escape**: The API client (src/lib/api-client.ts) wraps ALL errors as CliError subclasses (ApiError or AuthError) — no raw exceptions escape. Commands don't need try-catch for error display; the central handler in app.ts formats CliError cleanly. Only add try-catch when a command needs to handle errors specially (e.g., login continuing despite user-info fetch failure). -<!-- lore:019cb8ea-c6f0-75d8-bda7-e32b4e217f92 --> -* **CLI telemetry DSN is public write-only — safe to embed in install script**: The CLI's Sentry DSN (\`SENTRY\_CLI\_DSN\` in \`src/lib/constants.ts\`) is a public write-only ingest key already baked into every binary. Safe to hardcode in install scripts. Opt-out: \`SENTRY\_CLI\_NO\_TELEMETRY=1\`. +<!-- lore:019c8b60-d221-718a-823b-7c2c6e4ca1d5 --> +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry API scoping: Events require org+project in URL path (\`/projects/{org}/{project}/events/{id}/\`). Issues use legacy global endpoint (\`/api/0/issues/{id}/\`) without org context. Traces need only org (\`/organizations/{org}/trace/{traceId}/\`). Two-step lookup for events: fetch issue → extract org/project from response → fetch event. Cross-project event search possible via Discover endpoint \`/organizations/{org}/events/\` with \`query=id:{eventId}\`. -<!-- lore:019c978a-18b5-7a0d-a55f-b72f7789bdac --> -* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: \`cli.sentry.dev\` is served from gh-pages branch via GitHub Pages. Craft's gh-pages target runs \`git rm -r -f .\` before extracting docs — persist extra files via \`postReleaseCommand\` in \`.craft.yml\`. Install script supports \`--channel nightly\`, downloading from the \`nightly\` release tag directly. version.json is only used by upgrade/version-check flow. +<!-- lore:019cb6ab-ab98-7a9c-a25f-e154a5adbbe1 --> +* **Sentry CLI authenticated fetch architecture with response caching**: \`createAuthenticatedFetch()\` wraps fetch with auth, 30s timeout, retry (max 2), 401 refresh, and span tracing. Response caching integrates BEFORE auth/retry via \`http-cache-semantics\` (RFC 7234) with filesystem storage at \`~/.sentry/cache/responses/\`. URL-based fallback TTL tiers: immutable (24hr), stable (5min), volatile (60s), no-cache (0). Only GET 2xx cached. \`--fresh\` and \`SENTRY\_NO\_CACHE=1\` bypass cache. Cache cleared on login/logout. \`hasServerCacheDirectives(policy)\` distinguishes \`max-age=0\` from missing headers. -<!-- lore:019cbe93-19b8-7776-9705-20bbde226599 --> -* **Nightly delta upgrade buildNightlyPatchGraph fetches ALL patch tags — O(N) HTTP calls**: Delta upgrade in \`src/lib/delta-upgrade.ts\` supports stable (GitHub Releases) and nightly (GHCR) channels. \`filterAndSortChainTags\` filters \`patch-\*\` tags by version range using \`Bun.semver.order()\`. GHCR uses \`fetchWithRetry\` (10s timeout + 1 retry; blobs 30s) with optional \`signal?: AbortSignal\` combined via \`AbortSignal.any()\`. \`isExternalAbort(error, signal)\` skips retries for external aborts — critical for background prefetch. Patches cached to \`~/.sentry/patch-cache/\` (file-based, 7-day TTL). \`loadCachedChain\` stitches patches for multi-hop offline upgrades. +<!-- lore:019c8c72-b871-7d5e-a1a4-5214359a5a77 --> +* **Sentry CLI has two distribution channels with different runtimes**: Sentry CLI ships two ways: (1) Standalone binary via \`Bun.build()\` with \`compile: true\`. (2) npm package via esbuild producing CJS \`dist/bin.cjs\` for Node 22+, with Bun API polyfills from \`script/node-polyfills.ts\`. \`Bun.$\` has NO polyfill — use \`execSync\` instead. \`require()\` in ESM is safe (Bun native, esbuild resolves at bundle time). -<!-- lore:2c3eb7ab-1341-4392-89fd-d81095cfe9c4 --> -* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses \`node:sqlite\`. A runtime version guard in the esbuild banner catches this early. When writing esbuild banner strings in TS template literals, double-escape: \`\\\\\\\n\` in TS → \`\\\n\` in output → newline at runtime. Single \`\\\n\` produces a literal newline inside a JS string, causing SyntaxError. - -<!-- lore:019c972c-9f0f-75cd-9e24-9bdbb1ac03d6 --> -* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution in \`resolveNumericIssue()\`: (1) try DSN/env/config for org, (2) if found use \`getIssueInOrg(org, id)\` with region routing, (3) else fall back to unscoped \`getIssue(id)\`, (4) extract org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. \`parseSentryUrl\` handles path-based (\`/organizations/{org}/...\`) and subdomain-style URLs. \`matchSubdomainOrg()\` filters region subdomains by requiring slug length > 2. Self-hosted uses path-based only. - -<!-- lore:019ce0bb-f35d-7380-b661-8dc56f9938cf --> -* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: The CLI's error recovery middlewares in \`bin.ts\` are layered: \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (for \`no\_budget\`/\`not\_enabled\` errors) are caught by the inner wrapper; auth errors bubble up to the outer wrapper. After successful auth login retry, the retry also goes through \`executeWithSeerTrialPrompt\` (not \`runCommand\` directly) so the full middleware chain applies. Trial check API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start trial: \`PUT /api/0/customers/{org}/product-trial/\`. The \`/customers/\` endpoint is getsentry SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` errors are excluded (admin's explicit choice). \`startSeerTrial\` accepts \`category\` from the trial object — don't hardcode it. +<!-- lore:019c8b60-d21a-7d44-8a88-729f74ec7e02 --> +* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: Resolve-target cascade (src/lib/resolve-target.ts) has 5 priority levels: (1) Explicit CLI flags, (2) SENTRY\_ORG/SENTRY\_PROJECT env vars, (3) SQLite config defaults, (4) DSN auto-detection, (5) Directory name inference. SENTRY\_PROJECT supports combo notation \`org/project\` — when used, SENTRY\_ORG is ignored. If combo parse fails (e.g. \`org/\`), the entire value is discarded. The \`resolveFromEnvVars()\` helper is injected into all four resolution functions. ### Decision -<!-- lore:019c99d5-69f2-74eb-8c86-411f8512801d --> -* **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode assertions. +<!-- lore:019c9f9c-40ee-76b5-b98d-acf1e5867ebc --> +* **Issue list global limit with fair per-project distribution and representation guarantees**: \`issue list --limit\` is a global total across all detected projects. \`fetchWithBudget\` Phase 1 divides evenly, Phase 2 redistributes surplus via cursor resume. \`trimWithProjectGuarantee\` ensures at least 1 issue per project before filling remaining slots. JSON output wraps in \`{ data, hasMore }\` with optional \`errors\` array. Compound cursor (pipe-separated) enables \`-c last\` for multi-target pagination, keyed by sorted target fingerprint. -<!-- lore:00166785-609d-4ab5-911e-ee205d17b90c --> -* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. +<!-- lore:019c8f05-c86f-7b46-babc-5e4faebff2e9 --> +* **Sentry CLI config dir should stay at ~/.sentry/, not move to XDG**: Config dir stays at \`~/.sentry/\` (not XDG). The readonly DB errors on macOS are from \`sudo brew install\` creating root-owned files. Fixes: (1) bestEffort() makes setup steps non-fatal, (2) tryRepairReadonly() detects root-owned files and prints \`sudo chown\` instructions, (3) \`sentry cli fix\` handles ownership repair. Ownership must be checked BEFORE permissions — root-owned files cause chmod to EPERM. ### Gotcha -<!-- lore:019c8ab6-d119-7365-9359-98ecf464b704 --> -* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: @sentry/api SDK calls \`\_fetch(request)\` with no init object. In \`authenticatedFetch\`, \`init\` is undefined so \`prepareHeaders\` creates empty headers — on Node.js this strips Content-Type (HTTP 415). Fix: fall back to \`input.headers\` when \`init\` is undefined. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) to access the Response's Link header for pagination. \`per\_page\` is not in SDK types; cast query to pass it at runtime. +<!-- lore:019c8ee1-affd-7198-8d01-54aa164cde35 --> +* **brew is not in VALID\_METHODS but Homebrew formula passes --method brew**: Homebrew install: \`isHomebrewInstall()\` detects via Cellar realpath (checked before stored install info). Upgrade command tells users \`brew upgrade getsentry/tools/sentry\`. Formula runs \`sentry cli setup --method brew --no-modify-path\` as post\_install. Version pinning throws 'unsupported\_operation'. Uses .gz artifacts. Tap at getsentry/tools. + +<!-- lore:70319dc2-556d-4e30-9562-e51d1b68cf45 --> +* **Bun mock.module() leaks globally across test files in same process**: Bun's mock.module() replaces modules globally and leaks across test files in the same process. Solution: tests using mock.module() must run in a separate \`bun test\` invocation. In package.json, use \`bun run test:unit && bun run test:isolated\` instead of \`bun test\`. The \`test/isolated/\` directory exists for these tests. This was the root cause of ~100 test failures (getsentry/cli#258). -<!-- lore:019c9e98-7af4-7e25-95f4-fc06f7abf564 --> -* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\` +<!-- lore:019cb8cc-bfa8-7dd8-8ec7-77c974fd7985 --> +* **Making clearAuth() async breaks model-based tests — use non-async Promise\<void> return instead**: Making \`clearAuth()\` \`async\` breaks fast-check model-based tests — real async yields (macrotasks) during \`asyncModelRun\` cause \`createIsolatedDbContext\` cleanup to interleave. Fix: keep non-async, return \`clearResponseCache().catch(...)\` directly. Model-based tests should NOT await it. Also: model-based tests need explicit timeouts (e.g., \`30\_000\`) — Bun's default 5s causes false failures during shrinking. -<!-- lore:019c9776-e3dd-7632-88b8-358a19506218 --> -* **GitHub immutable releases prevent rolling nightly tag pattern**: getsentry/cli has immutable GitHub releases — assets can't be modified and tags can NEVER be reused. Nightly builds publish to GHCR with versioned tags like \`nightly-0.14.0-dev.1772661724\`, not GitHub Releases or npm. \`fetchManifest()\` throws \`UpgradeError("network\_error")\` for both network failures and non-200 — callers must check message for HTTP 404/403. Craft with no \`preReleaseCommand\` silently skips \`bump-version.sh\` if only target is \`github\`. +<!-- lore:a28c4f2a-e2b6-4f24-9663-a85461bc6412 --> +* **Multiregion mock must include all control silo API routes**: When changing which Sentry API endpoint a function uses, mock routes must be updated in BOTH \`test/mocks/routes.ts\` (single-region) AND \`test/mocks/multiregion.ts\` \`createControlSiloRoutes()\`. Missing the multiregion mock causes 404s in multi-region test scenarios. -<!-- lore:019cb8c2-d7b5-780c-8a9f-d20001bc198f --> -* **Install script: BSD sed and awk JSON parsing breaks OCI digest extraction**: The install script parses OCI manifests with awk (no jq). Key trap: BSD sed \`\n\` is literal, not newline. Fix: single awk pass tracking last-seen \`"digest"\`, printing when \`"org.opencontainers.image.title"\` matches target. The config digest (\`sha256:44136fa...\`) is a 2-byte \`{}\` blob — downloading it instead of the real binary causes \`gunzip: unexpected end of file\`. +<!-- lore:ce43057f-2eff-461f-b49b-fb9ebaadff9d --> +* **Sentry /users/me/ endpoint returns 403 for OAuth tokens — use /auth/ instead**: The Sentry \`/users/me/\` endpoint returns 403 for OAuth tokens. Use \`/auth/\` instead — it works with ALL token types and lives on the control silo. In the CLI, \`getControlSiloUrl()\` handles routing correctly. \`SentryUserSchema\` (with \`.passthrough()\`) handles the \`/auth/\` response since it only requires \`id\`. -<!-- lore:019c969a-1c90-7041-88a8-4e4d9a51ebed --> -* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: Bun test mocking gotchas: (1) \`mockFetch()\` replaces \`globalThis.fetch\` — calling it twice replaces the first mock. Use a single unified fetch mock dispatching by URL pattern. (2) \`mock.module()\` pollutes the module registry for ALL subsequent test files. Tests using it must live in \`test/isolated/\` and run via \`test:isolated\`. This also causes \`delta-upgrade.test.ts\` to fail when run alongside \`test/isolated/delta-upgrade.test.ts\` — the isolated test's \`mock.module()\` replaces \`CLI\_VERSION\` for all subsequent files. (3) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`. +<!-- lore:019ce2c5-c9b0-7151-9579-5273c0397203 --> +* **Stricli command context uses this.stdout not this.process.stdout**: In Stricli command \`func()\` handlers, use \`this.stdout\` and \`this.stderr\` directly — NOT \`this.process.stdout\`. The \`SentryContext\` interface has both \`process\` and \`stdout\`/\`stderr\` as separate top-level properties. Test mock contexts typically provide \`stdout\` but not a full \`process\` object, so \`this.process.stdout\` causes \`TypeError: undefined is not an object\` at runtime in tests even though TypeScript doesn't flag it. -<!-- lore:019c9741-d78e-73b1-87c2-e360ef6c7475 --> -* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` in the repo tree. Without \`{ isolateProjectRoot: true }\`, \`findProjectRoot\` walks up and finds the repo's \`.git\`, causing DSN detection to scan real source code and trigger network calls against test mocks (timeouts). Always pass \`isolateProjectRoot: true\` when tests exercise \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\`. +<!-- lore:019c8bbe-bc63-7b5e-a4e0-de7e968dcacb --> +* **Stricli defaultCommand blends default command flags into route completions**: When a Stricli route map has \`defaultCommand\` set, requesting completions for that route (e.g. \`\["issues", ""]\`) returns both the subcommand names AND the default command's flags/positional completions. This means completion tests that compare against \`extractCommandTree()\` subcommand lists will fail for groups with defaultCommand, since the actual completions include extra entries like \`--limit\`, \`--query\`, etc. Solution: track \`hasDefaultCommand\` in the command tree and skip strict subcommand-matching assertions for those groups. ### Pattern -<!-- lore:019c972c-9f11-7c0d-96ce-3f8cc2641175 --> -* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts: (1) call \`getOrgSdkConfig(orgSlug)\` for regional URL + SDK config, (2) spread into SDK function: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass to \`unwrapResult(result, errorContext)\`. Shared helpers \`resolveAllTargets\`/\`resolveOrgAndProject\` must NOT call \`fetchProjectId\` — commands that need it enrich targets themselves. +<!-- lore:019cb100-4630-79ac-8a13-185ea3d7bbb7 --> +* **Extract logic from Stricli func() handlers into standalone functions for testability**: Stricli command \`func()\` handlers are hard to unit test because they require full command context setup. To boost coverage, extract flag validation and body-building logic into standalone exported functions (e.g., \`resolveBody()\` extracted from the \`api\` command's \`func()\`). This moved ~20 lines of mutual-exclusivity checks and flag routing from an untestable handler into a directly testable pure function. Property-based tests on the extracted function drove patch coverage from 78% to 97%. The general pattern: keep \`func()\` as a thin orchestrator that calls exported helpers. This also keeps biome complexity under the limit (max 15). -<!-- lore:5ac4e219-ea1f-41cb-8e97-7e946f5848c0 --> -* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in the getsentry/cli repo, the CI pipeline includes Seer Code Review and Cursor Bugbot as advisory checks. Both typically take 2-3 minutes but may not trigger on draft PRs — only ready-for-review PRs reliably get bot reviews. The workflow is: push → wait for all CI (including npm build jobs which test the actual bundle) → check for inline review comments from Seer/BugBot → fix if needed → repeat. Use \`gh pr checks \<PR> --watch\` to monitor. Review comments are fetched via \`gh api repos/OWNER/REPO/pulls/NUM/comments\` and \`gh api repos/OWNER/REPO/pulls/NUM/reviews\`. +<!-- lore:d441d9e5-3638-4b5a-8148-f88c349b8979 --> +* **Non-essential DB cache writes should be guarded with try-catch**: Non-essential DB cache writes (e.g., \`setUserInfo()\` in whoami.ts and login.ts) must be wrapped in try-catch. If the DB is broken, the cache write shouldn't crash the command when its primary operation already succeeded. In login.ts specifically, \`getCurrentUser()\` failure after token save must not block authentication — wrap in try-catch, log warning to stderr, let login succeed. This differs from \`getUserRegions()\` failure which should \`clearAuth()\` and fail hard (indicates invalid token). -<!-- lore:019cb162-d3ad-7b05-ab4f-f87892d517a6 --> -* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: List commands with cursor pagination use \`buildPaginationContextKey(type, identifier, flags)\` for composite context keys and \`parseCursorFlag(value)\` accepting \`"last"\` magic value. Critical: \`resolveCursor()\` must be called inside the \`org-all\` override closure, not before \`dispatchOrgScopedList\` — otherwise cursor validation errors fire before the correct mode-specific error. +<!-- lore:019ce2c5-c9a8-7219-bdb8-154ead871d27 --> +* **Stricli buildCommand output config injects json flag into func params**: When a Stricli command uses \`output: { json: true, human: formatFn }\`, the framework injects \`--json\` and \`--fields\` flags automatically. The \`func\` handler receives these as its first parameter. Type it explicitly (e.g., \`flags: { json?: boolean }\`) rather than \`\_flags: unknown\` to access the json flag for conditional behavior (e.g., skipping interactive output in JSON mode). The \`human\` formatter runs on the returned \`data\` for non-JSON output. Commands that produce interactive side effects (browser prompts, QR codes) should check \`flags.json\` and skip them when true. -<!-- lore:019cbd5f-ec35-7e2d-8386-6d3a67adf0cf --> -* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: For graceful-fallback operations, use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. \`withTracingSpan\` uses \`onlyIfParent: true\` — no-op without active transaction. User-visible fallbacks use \`log.warn()\` not \`log.debug()\`. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\` (trace/list, trace/view, log/view, api.ts, help.ts). +### Preference -<!-- lore:019cc43d-e651-7154-a88e-1309c4a2a2b6 --> -* **Testing Stricli command func() bodies via spyOn mocking**: To unit-test a Stricli command's \`func()\` body: (1) \`const func = await cmd.loader()\`, (2) \`func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. (3) \`spyOn\` namespace imports to mock dependencies (e.g., \`spyOn(apiClient, 'getLogs')\`). The \`loader()\` return type union causes \`.call()\` LSP errors — these are false positives that pass \`tsc --noEmit\`. When API functions are renamed (e.g., \`getLog\` → \`getLogs\`), update both spy target name AND mock return shape (single → array). Slug normalization (\`normalizeSlug\`) replaces underscores with dashes but does NOT lowercase — test assertions must match original casing (e.g., \`'CAM-82X'\` not \`'cam-82x'\`). +<!-- lore:019cb3e6-da61-7dfe-83c2-17fe3257bece --> +* **PR workflow: address review comments, resolve threads, wait for CI**: User's PR workflow after creation: (1) Wait for CI checks to pass, (2) Check for unresolved review comments via \`gh api\` for PR review comments, (3) Fix issues in follow-up commits (not amends), (4) Reply to the comment thread explaining the fix, (5) Resolve the thread programmatically via \`gh api graphql\` with \`resolveReviewThread\` mutation, (6) Push and wait for CI again, (7) Final sweep for any remaining unresolved comments. Use \`git notes add\` to attach implementation plans to commits. Branch naming: \`fix/descriptive-slug\` or \`feat/descriptive-slug\`. <!-- End lore-managed section --> diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index ac94734d7..a00327b58 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -535,14 +535,6 @@ View a dashboard Create a dashboard **Flags:** -- `--widget-title <value> - Inline widget title` -- `--widget-display <value> - Inline widget display type (line, bar, table, big_number, ...)` -- `--widget-dataset <value> - Inline widget dataset (default: spans)` -- `--widget-query <value>... - Inline widget aggregate (e.g. count, p95:span.duration)` -- `--widget-where <value> - Inline widget search conditions filter` -- `--widget-group-by <value>... - Inline widget group-by column (repeatable)` -- `--widget-sort <value> - Inline widget order by (prefix - for desc)` -- `--widget-limit <value> - Inline widget result limit` - `--json - Output as JSON` - `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)` diff --git a/src/commands/dashboard/create.ts b/src/commands/dashboard/create.ts index 82540b520..517d0387b 100644 --- a/src/commands/dashboard/create.ts +++ b/src/commands/dashboard/create.ts @@ -10,7 +10,7 @@ import { type ParsedOrgProject, parseOrgProjectArg, } from "../../lib/arg-parsing.js"; -import { buildCommand, numberParser } from "../../lib/command.js"; +import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatDashboardCreated } from "../../lib/formatters/human.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -22,26 +22,9 @@ import { toNumericId, } from "../../lib/resolve-target.js"; import { buildDashboardUrl } from "../../lib/sentry-urls.js"; -import { - assignDefaultLayout, - type DashboardDetail, - type DashboardWidget, - DISPLAY_TYPES, - parseAggregate, - parseSortExpression, - parseWidgetInput, - prepareWidgetQueries, -} from "../../types/dashboard.js"; +import type { DashboardDetail } from "../../types/dashboard.js"; type CreateFlags = { - readonly "widget-title"?: string; - readonly "widget-display"?: string; - readonly "widget-dataset"?: string; - readonly "widget-query"?: string[]; - readonly "widget-where"?: string; - readonly "widget-group-by"?: string[]; - readonly "widget-sort"?: string; - readonly "widget-limit"?: number; readonly json: boolean; readonly fields?: string[]; }; @@ -147,43 +130,6 @@ async function resolveDashboardTarget( } } -/** Build an inline widget from --widget-* flags */ -function buildInlineWidget(flags: CreateFlags): DashboardWidget { - if (!flags["widget-title"]) { - throw new ValidationError( - "Missing --widget-title. Both --widget-title and --widget-display are required for inline widgets.\n\n" + - "Example:\n" + - " sentry dashboard create 'My Dashboard' --widget-title \"Error Count\" --widget-display big_number --widget-query count", - "widget-title" - ); - } - - const aggregates = (flags["widget-query"] ?? ["count"]).map(parseAggregate); - const columns = flags["widget-group-by"] ?? []; - const orderby = flags["widget-sort"] - ? parseSortExpression(flags["widget-sort"]) - : undefined; - - const rawWidget = { - title: flags["widget-title"], - displayType: flags["widget-display"] as string, - ...(flags["widget-dataset"] && { widgetType: flags["widget-dataset"] }), - queries: [ - { - aggregates, - columns, - conditions: flags["widget-where"] ?? "", - ...(orderby && { orderby }), - name: "", - }, - ], - ...(flags["widget-limit"] !== undefined && { - limit: flags["widget-limit"], - }), - }; - return prepareWidgetQueries(parseWidgetInput(rawWidget)); -} - export const createCommand = buildCommand({ docs: { brief: "Create a dashboard", @@ -192,10 +138,7 @@ export const createCommand = buildCommand({ "Examples:\n" + " sentry dashboard create 'My Dashboard'\n" + " sentry dashboard create my-org/ 'My Dashboard'\n" + - " sentry dashboard create my-org/my-project 'My Dashboard'\n\n" + - "With an inline widget:\n" + - " sentry dashboard create 'My Dashboard' \\\n" + - ' --widget-title "Error Count" --widget-display big_number --widget-query count', + " sentry dashboard create my-org/my-project 'My Dashboard'", }, output: { human: formatDashboardCreated, @@ -208,83 +151,17 @@ export const createCommand = buildCommand({ parse: String, }, }, - flags: { - "widget-title": { - kind: "parsed", - parse: String, - brief: "Inline widget title", - optional: true, - }, - "widget-display": { - kind: "parsed", - parse: String, - brief: "Inline widget display type (line, bar, table, big_number, ...)", - optional: true, - }, - "widget-dataset": { - kind: "parsed", - parse: String, - brief: "Inline widget dataset (default: spans)", - optional: true, - }, - "widget-query": { - kind: "parsed", - parse: String, - brief: "Inline widget aggregate (e.g. count, p95:span.duration)", - variadic: true, - optional: true, - }, - "widget-where": { - kind: "parsed", - parse: String, - brief: "Inline widget search conditions filter", - optional: true, - }, - "widget-group-by": { - kind: "parsed", - parse: String, - brief: "Inline widget group-by column (repeatable)", - variadic: true, - optional: true, - }, - "widget-sort": { - kind: "parsed", - parse: String, - brief: "Inline widget order by (prefix - for desc)", - optional: true, - }, - "widget-limit": { - kind: "parsed", - parse: numberParser, - brief: "Inline widget result limit", - optional: true, - }, - }, + flags: {}, }, - async *func(this: SentryContext, flags: CreateFlags, ...args: string[]) { + async *func(this: SentryContext, _flags: CreateFlags, ...args: string[]) { const { cwd } = this; const { title, targetArg } = parsePositionalArgs(args); const parsed = parseOrgProjectArg(targetArg); const { orgSlug, projectIds } = await resolveDashboardTarget(parsed, cwd); - const widgets: DashboardWidget[] = []; - if (flags["widget-display"]) { - const validated = buildInlineWidget(flags); - widgets.push(assignDefaultLayout(validated, widgets)); - } else if (flags["widget-title"]) { - throw new ValidationError( - "Missing --widget-display. Both --widget-title and --widget-display are required for inline widgets.\n\n" + - "Example:\n" + - " sentry dashboard create 'My Dashboard' --widget-title \"Error Count\" --widget-display big_number --widget-query count\n\n" + - `Valid display types: ${DISPLAY_TYPES.join(", ")}`, - "widget-display" - ); - } - const dashboard = await createDashboard(orgSlug, { title, - widgets, projects: projectIds.length > 0 ? projectIds : undefined, }); const url = buildDashboardUrl(orgSlug, dashboard.id); diff --git a/test/commands/dashboard/create.test.ts b/test/commands/dashboard/create.test.ts index fb78cc173..e34cb9389 100644 --- a/test/commands/dashboard/create.test.ts +++ b/test/commands/dashboard/create.test.ts @@ -90,7 +90,6 @@ describe("dashboard create", () => { expect(createDashboardSpy).toHaveBeenCalledWith("acme-corp", { title: "My Dashboard", - widgets: [], projects: undefined, }); }); @@ -135,79 +134,10 @@ describe("dashboard create", () => { expect(createDashboardSpy).toHaveBeenCalledWith("my-org", { title: "My Dashboard", - widgets: [], projects: undefined, }); }); - test("--widget-display and --widget-title creates dashboard with widget", async () => { - createDashboardSpy.mockResolvedValue({ - ...sampleDashboard, - widgets: [ - { - title: "Error Count", - displayType: "big_number", - widgetType: "spans", - }, - ], - }); - - const { context } = createMockContext(); - const func = await createCommand.loader(); - await func.call( - context, - { - json: false, - "widget-title": "Error Count", - "widget-display": "big_number", - }, - "My Dashboard" - ); - - expect(createDashboardSpy).toHaveBeenCalledWith( - "acme-corp", - expect.objectContaining({ - title: "My Dashboard", - widgets: expect.arrayContaining([ - expect.objectContaining({ - title: "Error Count", - displayType: "big_number", - }), - ]), - }) - ); - }); - - test("--widget-title without --widget-display throws ValidationError", async () => { - const { context } = createMockContext(); - const func = await createCommand.loader(); - - const err = await func - .call( - context, - { json: false, "widget-title": "Error Count" }, - "My Dashboard" - ) - .catch((e: Error) => e); - expect(err).toBeInstanceOf(ValidationError); - expect(err.message).toContain("--widget-display"); - }); - - test("--widget-display without --widget-title throws ValidationError", async () => { - const { context } = createMockContext(); - const func = await createCommand.loader(); - - const err = await func - .call( - context, - { json: false, "widget-display": "big_number" }, - "My Dashboard" - ) - .catch((e: Error) => e); - expect(err).toBeInstanceOf(ValidationError); - expect(err.message).toContain("--widget-title"); - }); - test("throws ContextError when org cannot be resolved", async () => { resolveOrgSpy.mockResolvedValue(null);