diff --git a/docs/src/content/docs/commands/release.md b/docs/src/content/docs/commands/release.md index 739b8322f..04a70b4f2 100644 --- a/docs/src/content/docs/commands/release.md +++ b/docs/src/content/docs/commands/release.md @@ -9,7 +9,7 @@ Work with Sentry releases ### `sentry release list ` -List releases +List releases with adoption and health metrics **Arguments:** @@ -22,12 +22,13 @@ List releases | Option | Description | |--------|-------------| | `-n, --limit ` | Maximum number of releases to list (default: "25") | +| `-s, --sort ` | Sort: date, sessions, users, crash_free_sessions (cfs), crash_free_users (cfu) (default: "date") | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | | `-c, --cursor ` | Navigate pages: "next", "prev", "first" (or raw cursor string) | ### `sentry release view ` -View release details +View release details with health metrics **Arguments:** diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 03b893ea6..d99e7d6f6 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -346,8 +346,8 @@ Manage Sentry dashboards Work with Sentry releases -- `sentry release list ` — List releases -- `sentry release view ` — View release details +- `sentry release list ` — List releases with adoption and health metrics +- `sentry release view ` — View release details with health metrics - `sentry release create ` — Create a release - `sentry release finalize ` — Finalize a release - `sentry release delete ` — Delete a release diff --git a/plugins/sentry-cli/skills/sentry-cli/references/release.md b/plugins/sentry-cli/skills/sentry-cli/references/release.md index d29f2a3b0..26dae5b52 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/release.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/release.md @@ -13,16 +13,17 @@ Work with Sentry releases ### `sentry release list ` -List releases +List releases with adoption and health metrics **Flags:** - `-n, --limit - Maximum number of releases to list - (default: "25")` +- `-s, --sort - Sort: date, sessions, users, crash_free_sessions (cfs), crash_free_users (cfu) - (default: "date")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` ### `sentry release view ` -View release details +View release details with health metrics **Flags:** - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/src/commands/release/list.ts b/src/commands/release/list.ts index 542c5ca62..9266eebe1 100644 --- a/src/commands/release/list.ts +++ b/src/commands/release/list.ts @@ -2,84 +2,278 @@ * sentry release list * * List releases in an organization with pagination support. + * Includes per-project health/adoption metrics when available. */ import type { OrgReleaseResponse } from "@sentry/api"; -import { listReleasesPaginated } from "../../lib/api-client.js"; +import type { SentryContext } from "../../context.js"; +import { + listReleasesPaginated, + type ReleaseSortValue, +} from "../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { fmtPct } from "../../lib/formatters/numbers.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { sparkline } from "../../lib/formatters/sparkline.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; import { - buildOrgListCommand, - type OrgListCommandDocs, + buildListCommand, + buildListLimitFlag, + LIST_BASE_ALIASES, + LIST_TARGET_POSITIONAL, } from "../../lib/list-command.js"; -import type { OrgListConfig } from "../../lib/org-list.js"; +import { + dispatchOrgScopedList, + jsonTransformListResult, + type ListResult, + type OrgListConfig, +} from "../../lib/org-list.js"; export const PAGINATION_KEY = "release-list"; type ReleaseWithOrg = OrgReleaseResponse & { orgSlug?: string }; +/** Valid values for the `--sort` flag. */ +const VALID_SORT_VALUES: ReleaseSortValue[] = [ + "date", + "sessions", + "users", + "crash_free_sessions", + "crash_free_users", +]; + +/** + * Short aliases for sort values. + * + * Accepted alongside the canonical API values for convenience: + * - `stable_sessions` / `cfs` → `crash_free_sessions` + * - `stable_users` / `cfu` → `crash_free_users` + */ +const SORT_ALIASES: Record = { + stable_sessions: "crash_free_sessions", + stable_users: "crash_free_users", + cfs: "crash_free_sessions", + cfu: "crash_free_users", +}; + +const DEFAULT_SORT: ReleaseSortValue = "date"; + +/** + * Parse and validate the `--sort` flag value. + * + * Accepts canonical API values and short aliases. + * @throws Error when value is not recognized + */ +function parseSortFlag(value: string): ReleaseSortValue { + if (VALID_SORT_VALUES.includes(value as ReleaseSortValue)) { + return value as ReleaseSortValue; + } + const alias = SORT_ALIASES[value]; + if (alias) { + return alias; + } + const allAccepted = [...VALID_SORT_VALUES, ...Object.keys(SORT_ALIASES)].join( + ", " + ); + throw new Error(`Invalid sort value. Must be one of: ${allAccepted}`); +} + +/** + * Extract health data from the first project that has it. + * + * A release spans multiple projects; each gets independent health data. + * For the list table we pick the first project with `hasHealthData: true`. + */ +function getHealthData(release: OrgReleaseResponse) { + return release.projects?.find((p) => p.healthData?.hasHealthData)?.healthData; +} + +/** + * Extract session time-series data points from health stats. + * + * The `stats` object follows the same `{ "": [[ts, count], ...] }` + * shape as issue stats. Takes the first available key. + */ +function extractSessionPoints(stats?: Record): number[] { + if (!stats) { + return []; + } + const key = Object.keys(stats)[0]; + if (!key) { + return []; + } + const buckets = stats[key]; + if (!Array.isArray(buckets)) { + return []; + } + return buckets.map((b: unknown) => + Array.isArray(b) && b.length >= 2 ? Number(b[1]) || 0 : 0 + ); +} + const RELEASE_COLUMNS: Column[] = [ { header: "ORG", value: (r) => r.orgSlug || "" }, { header: "VERSION", value: (r) => escapeMarkdownCell(r.shortVersion || r.version), }, - { - header: "STATUS", - value: (r) => (r.dateReleased ? "Finalized" : "Unreleased"), - }, { header: "CREATED", value: (r) => (r.dateCreated ? formatRelativeTime(r.dateCreated) : ""), }, { - header: "RELEASED", - value: (r) => (r.dateReleased ? formatRelativeTime(r.dateReleased) : "—"), + header: "ADOPTION", + value: (r) => fmtPct(getHealthData(r)?.adoption), + align: "right", + }, + { + header: "CRASH-FREE", + value: (r) => fmtPct(getHealthData(r)?.crashFreeSessions), + align: "right", + }, + { + header: "SESSIONS", + value: (r) => { + const health = getHealthData(r); + if (!health) { + return ""; + } + const points = extractSessionPoints( + health.stats as Record | undefined + ); + return points.length > 0 ? sparkline(points) : ""; + }, + }, + { + header: "ISSUES", + value: (r) => String(r.newGroups ?? 0), + align: "right", }, - { header: "COMMITS", value: (r) => String(r.commitCount ?? 0) }, { header: "DEPLOYS", value: (r) => String(r.deployCount ?? 0) }, ]; -const releaseListConfig: OrgListConfig = { - paginationKey: PAGINATION_KEY, - entityName: "release", - entityPlural: "releases", - commandPrefix: "sentry release list", - // listForOrg fetches a buffer page for multi-org fan-out. - // The framework truncates results to --limit after aggregation. - listForOrg: async (org) => { - const { data } = await listReleasesPaginated(org, { perPage: 100 }); - return data; - }, - listPaginated: (org, opts) => listReleasesPaginated(org, opts), - withOrg: (release, orgSlug) => ({ ...release, orgSlug }), - displayTable: (releases: ReleaseWithOrg[]) => - formatTable(releases, RELEASE_COLUMNS), -}; +/** + * Build the OrgListConfig with the given sort value baked into API calls. + * + * We build this per-invocation so the `--sort` flag value flows into + * `listForOrg` and `listPaginated` closures. + */ +function buildReleaseListConfig( + sort: ReleaseSortValue +): OrgListConfig { + return { + paginationKey: PAGINATION_KEY, + entityName: "release", + entityPlural: "releases", + commandPrefix: "sentry release list", + listForOrg: async (org) => { + const { data } = await listReleasesPaginated(org, { + perPage: 100, + health: true, + sort, + }); + return data; + }, + listPaginated: (org, opts) => + listReleasesPaginated(org, { ...opts, health: true, sort }), + withOrg: (release, orgSlug) => ({ ...release, orgSlug }), + displayTable: (releases: ReleaseWithOrg[]) => + formatTable(releases, RELEASE_COLUMNS), + }; +} + +/** Format a ListResult as human-readable output. */ +function formatListHuman(result: ListResult): string { + const parts: string[] = []; + + if (result.items.length === 0) { + if (result.hint) { + parts.push(result.hint); + } + return parts.join("\n"); + } + + parts.push(formatTable(result.items, RELEASE_COLUMNS)); + + if (result.header) { + parts.push(`\n${result.header}`); + } -const docs: OrgListCommandDocs = { - brief: "List releases", - fullDescription: - "List releases in an organization.\n\n" + - "Target specification:\n" + - " sentry release list # auto-detect from DSN or config\n" + - " sentry release list / # list all releases in org (paginated)\n" + - " sentry release list / # list releases in org (project context)\n" + - " sentry release list # list releases in org\n\n" + - "Pagination:\n" + - " sentry release list / -c next # fetch next page\n" + - " sentry release list / -c prev # fetch previous page\n\n" + - "Examples:\n" + - " sentry release list # auto-detect or list all\n" + - " sentry release list my-org/ # list releases in my-org (paginated)\n" + - " sentry release list --limit 10\n" + - " sentry release list --json\n\n" + - "Alias: `sentry releases` → `sentry release list`", + return parts.join(""); +} + +type ListFlags = { + readonly limit: number; + readonly sort: ReleaseSortValue; + readonly json: boolean; + readonly cursor?: string; + readonly fresh: boolean; + readonly fields?: string[]; }; -export const listCommand = buildOrgListCommand( - releaseListConfig, - docs, - "release" -); +export const listCommand = buildListCommand("release", { + docs: { + brief: "List releases with adoption and health metrics", + fullDescription: + "List releases in an organization with adoption and crash-free metrics.\n\n" + + "Health data (adoption %, crash-free session rate) is shown per-release\n" + + "from the first project that has session data.\n\n" + + "Sort options:\n" + + " date # by creation date (default)\n" + + " sessions # by total sessions\n" + + " users # by total users\n" + + " crash_free_sessions # by crash-free session rate (aliases: stable_sessions, cfs)\n" + + " crash_free_users # by crash-free user rate (aliases: stable_users, cfu)\n\n" + + "Target specification:\n" + + " sentry release list # auto-detect from DSN or config\n" + + " sentry release list / # list all releases in org (paginated)\n" + + " sentry release list / # list releases in org (project context)\n" + + " sentry release list # list releases in org\n\n" + + "Pagination:\n" + + " sentry release list / -c next # fetch next page\n" + + " sentry release list / -c prev # fetch previous page\n\n" + + "Examples:\n" + + " sentry release list # auto-detect or list all\n" + + " sentry release list my-org/ # list releases in my-org (paginated)\n" + + " sentry release list --sort crash_free_sessions\n" + + " sentry release list --limit 10\n" + + " sentry release list --json\n\n" + + "Alias: `sentry releases` → `sentry release list`", + }, + output: { + human: formatListHuman, + jsonTransform: (result: ListResult, fields?: string[]) => + jsonTransformListResult(result, fields), + }, + parameters: { + positional: LIST_TARGET_POSITIONAL, + flags: { + limit: buildListLimitFlag("releases"), + sort: { + kind: "parsed" as const, + parse: parseSortFlag, + brief: + "Sort: date, sessions, users, crash_free_sessions (cfs), crash_free_users (cfu)", + default: DEFAULT_SORT, + }, + }, + aliases: { ...LIST_BASE_ALIASES, s: "sort" }, + }, + async *func(this: SentryContext, flags: ListFlags, target?: string) { + const { cwd } = this; + const parsed = parseOrgProjectArg(target); + const config = buildReleaseListConfig(flags.sort); + const result = await dispatchOrgScopedList({ + config, + cwd, + flags, + parsed, + orgSlugMatchBehavior: "redirect", + }); + yield new CommandOutput(result); + const hint = result.items.length > 0 ? result.hint : undefined; + return { hint }; + }, +}); diff --git a/src/commands/release/view.ts b/src/commands/release/view.ts index 96777183a..edc67d346 100644 --- a/src/commands/release/view.ts +++ b/src/commands/release/view.ts @@ -1,7 +1,8 @@ /** * sentry release view * - * View details of a specific release. + * View details of a specific release, including per-project + * health and adoption metrics when available. */ import type { OrgReleaseResponse } from "@sentry/api"; @@ -11,11 +12,14 @@ import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { colorTag, + escapeMarkdownCell, escapeMarkdownInline, mdKvTable, + mdTableHeader, renderMarkdown, safeCodeSpan, } from "../../lib/formatters/markdown.js"; +import { fmtCount, fmtPct } from "../../lib/formatters/numbers.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; import { @@ -26,6 +30,65 @@ import { import { resolveOrg } from "../../lib/resolve-target.js"; import { parseReleaseArg } from "./parse.js"; +/** Format a crash-free rate with color coding (green ≥ 99, yellow ≥ 95, red < 95). */ +function fmtCrashFree(value: number | null | undefined): string { + if (value === null || value === undefined) { + return "—"; + } + const formatted = `${value.toFixed(1)}%`; + if (value >= 99) { + return colorTag("green", formatted); + } + if (value >= 95) { + return colorTag("yellow", formatted); + } + return colorTag("red", formatted); +} + +/** + * Build a markdown table of per-project health data. + * + * Only includes projects that have health data. Returns empty string + * if no project has data (so the section is skipped entirely). + */ +function formatProjectHealthTable(release: OrgReleaseResponse): string { + const projects = release.projects?.filter((p) => p.healthData?.hasHealthData); + if (!projects?.length) { + return ""; + } + + const lines: string[] = []; + lines.push("### Health by Project"); + lines.push(""); + + // Table header: right-align numeric columns with trailing ":" + lines.push( + mdTableHeader([ + "PROJECT", + "ADOPTION:", + "CRASH-FREE USERS:", + "CRASH-FREE SESSIONS:", + "USERS (24h):", + "SESSIONS (24h):", + ]) + ); + + for (const project of projects) { + const h = project.healthData; + const cells = [ + escapeMarkdownCell(project.slug), + fmtPct(h?.adoption), + fmtCrashFree(h?.crashFreeUsers), + fmtCrashFree(h?.crashFreeSessions), + fmtCount(h?.totalUsers24h), + fmtCount(h?.totalSessions24h), + ]; + lines.push(`| ${cells.join(" | ")} |`); + } + + return lines.join("\n"); +} + function formatReleaseDetails(release: OrgReleaseResponse): string { const lines: string[] = []; @@ -77,14 +140,23 @@ function formatReleaseDetails(release: OrgReleaseResponse): string { } lines.push(mdKvTable(kvRows)); + + // Per-project health breakdown (only if any project has data) + const healthTable = formatProjectHealthTable(release); + if (healthTable) { + lines.push(""); + lines.push(healthTable); + } + return renderMarkdown(lines.join("\n")); } export const viewCommand = buildCommand({ docs: { - brief: "View release details", + brief: "View release details with health metrics", fullDescription: - "Show detailed information about a Sentry release.\n\n" + + "Show detailed information about a Sentry release, including\n" + + "per-project adoption and crash-free metrics.\n\n" + "Examples:\n" + " sentry release view 1.0.0\n" + " sentry release view my-org/1.0.0\n" + @@ -140,7 +212,10 @@ export const viewCommand = buildCommand({ "sentry release view [/]" ); } - const release = await getRelease(resolved.org, version); + const release = await getRelease(resolved.org, version, { + health: true, + adoptionStages: true, + }); yield new CommandOutput(release); const hint = resolved.detectedFrom ? `Detected from ${resolved.detectedFrom}` diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index bf025a422..91a134d2b 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -96,6 +96,7 @@ export { getRelease, listReleaseDeploys, listReleasesPaginated, + type ReleaseSortValue, setCommitsAuto, setCommitsLocal, setCommitsWithRefs, diff --git a/src/lib/api/releases.ts b/src/lib/api/releases.ts index 99bb2ba59..01c805276 100644 --- a/src/lib/api/releases.ts +++ b/src/lib/api/releases.ts @@ -38,8 +38,11 @@ import { listRepositoriesPaginated } from "./repositories.js"; * List releases in an organization with pagination control. * Returns a single page of results with cursor metadata. * + * When `health` is true, each release's `projects[].healthData` is populated + * with adoption percentages, crash-free rates, and session/user counts. + * * @param orgSlug - Organization slug - * @param options - Pagination, query, and sort options + * @param options - Pagination, query, sort, and health options * @returns Single page of releases with cursor metadata */ export async function listReleasesPaginated( @@ -49,6 +52,8 @@ export async function listReleasesPaginated( perPage?: number; query?: string; sort?: string; + /** Include per-project health/adoption data in the response. */ + health?: boolean; } = {} ): Promise> { const config = await getOrgSdkConfig(orgSlug); @@ -56,12 +61,13 @@ export async function listReleasesPaginated( const result = await listAnOrganization_sReleases({ ...config, path: { organization_id_or_slug: orgSlug }, - // per_page and sort are supported at runtime but not in the OpenAPI spec + // per_page, sort, and health are supported at runtime but not in the OpenAPI spec query: { cursor: options.cursor, per_page: options.perPage ?? 25, query: options.query, sort: options.sort, + health: options.health ? 1 : undefined, } as { cursor?: string }, }); @@ -73,17 +79,38 @@ export async function listReleasesPaginated( ); } +/** Sort options for the release list endpoint. */ +export type ReleaseSortValue = + | "date" + | "sessions" + | "users" + | "crash_free_sessions" + | "crash_free_users"; + /** * Get a single release by version. * Version is URL-encoded by the SDK. * + * When `health` is true, each project in the response includes a + * `healthData` object with adoption percentages, crash-free rates, + * and session/user counts for the requested period. + * * @param orgSlug - Organization slug * @param version - Release version string (e.g., "1.0.0", "sentry-cli@0.24.0") + * @param options - Optional health and adoption query parameters * @returns Full release detail */ export async function getRelease( orgSlug: string, - version: string + version: string, + options?: { + /** Include per-project health/adoption data. */ + health?: boolean; + /** Include adoption stage info (e.g., "adopted", "low_adoption"). */ + adoptionStages?: boolean; + /** Period for health stats: "24h", "7d", "14d", etc. Defaults to "24h". */ + healthStatsPeriod?: string; + } ): Promise { const config = await getOrgSdkConfig(orgSlug); @@ -93,6 +120,21 @@ export async function getRelease( organization_id_or_slug: orgSlug, version, }, + query: { + health: options?.health, + adoptionStages: options?.adoptionStages, + healthStatsPeriod: options?.healthStatsPeriod as + | "24h" + | "7d" + | "14d" + | "30d" + | "1h" + | "1d" + | "2d" + | "48h" + | "90d" + | undefined, + }, }); const data = unwrapResult(result, `Failed to get release '${version}'`); diff --git a/src/lib/formatters/dashboard.ts b/src/lib/formatters/dashboard.ts index 4e9d83d04..4889e647c 100644 --- a/src/lib/formatters/dashboard.ts +++ b/src/lib/formatters/dashboard.ts @@ -330,24 +330,15 @@ function calcGlyphWidth(formatted: string, glyphW: number): number { } // --------------------------------------------------------------------------- -// Number formatting +// Number formatting — shared helpers live in numbers.ts, dashboard-only +// helpers remain here. // --------------------------------------------------------------------------- -const compactFormatter = new Intl.NumberFormat("en-US", { - notation: "compact", - maximumFractionDigits: 1, -}); - -const standardFormatter = new Intl.NumberFormat("en-US", { - maximumFractionDigits: 2, -}); - -function formatNumber(value: number): string { - if (Math.abs(value) >= 1_000_000) { - return compactFormatter.format(value); - } - return standardFormatter.format(value); -} +import { + compactFormatter, + formatCompactWithUnit, + formatWithUnit, +} from "./numbers.js"; /** * Format a value as a short Y-axis tick label (max ~4 chars). @@ -376,32 +367,6 @@ function formatBigNumberValue(value: number): string { return Math.round(value).toString(); } -/** Append unit suffix to a pre-formatted number string. */ -function appendUnitSuffix(formatted: string, unit?: string | null): string { - if (!unit || unit === "none" || unit === "null") { - return formatted; - } - if (unit === "millisecond") { - return `${formatted}ms`; - } - if (unit === "second") { - return `${formatted}s`; - } - if (unit === "byte") { - return `${formatted}B`; - } - return `${formatted} ${unit}`; -} - -function formatWithUnit(value: number, unit?: string | null): string { - return appendUnitSuffix(formatNumber(value), unit); -} - -/** Format a number with unit using compact notation (K/M/B). */ -function formatCompactWithUnit(value: number, unit?: string | null): string { - return appendUnitSuffix(compactFormatter.format(Math.round(value)), unit); -} - // --------------------------------------------------------------------------- // Sort helper: descending by value, "Other" always last // --------------------------------------------------------------------------- diff --git a/src/lib/formatters/index.ts b/src/lib/formatters/index.ts index f487c9a31..5eb67a20a 100644 --- a/src/lib/formatters/index.ts +++ b/src/lib/formatters/index.ts @@ -10,6 +10,7 @@ export * from "./human.js"; export * from "./json.js"; export * from "./log.js"; export * from "./markdown.js"; +export * from "./numbers.js"; export * from "./output.js"; export * from "./seer.js"; export * from "./sparkline.js"; diff --git a/src/lib/formatters/numbers.ts b/src/lib/formatters/numbers.ts new file mode 100644 index 000000000..aa8b12d72 --- /dev/null +++ b/src/lib/formatters/numbers.ts @@ -0,0 +1,130 @@ +/** + * Shared number formatting utilities. + * + * Provides compact notation (K/M/B), percentage formatting, and unit + * suffixing used across dashboard, release, and other command formatters. + * + * Uses `Intl.NumberFormat` for locale-aware compact notation. + */ + +/** + * Compact notation formatter: 52000 → "52K", 1.2M, etc. + * One fractional digit maximum. + */ +export const compactFormatter = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); + +/** + * Standard notation formatter with thousands separators. + * Two fractional digits maximum: 1234.5 → "1,234.5". + */ +export const standardFormatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 2, +}); + +/** + * Format a number with standard notation, switching to compact above 1M. + * + * - Below 1M: standard grouping (e.g., "52,000", "1,234.5") + * - At or above 1M: compact (e.g., "1.2M", "52M") + * + * @example formatNumber(1234) // "1,234" + * @example formatNumber(1500000) // "1.5M" + */ +export function formatNumber(value: number): string { + if (Math.abs(value) >= 1_000_000) { + return compactFormatter.format(value); + } + return standardFormatter.format(value); +} + +/** + * Format a number in compact notation (always uses K/M/B suffixes). + * + * @example formatCompactCount(500) // "500" + * @example formatCompactCount(52000) // "52K" + * @example formatCompactCount(1200000) // "1.2M" + */ +export function formatCompactCount(value: number): string { + return compactFormatter.format(value); +} + +/** + * Append a unit suffix to a pre-formatted number string. + * + * Handles common Sentry unit names: "millisecond" → "ms", + * "second" → "s", "byte" → "B". Unknown units are appended with a space. + * Returns the number unchanged for "none"/"null"/empty units. + */ +export function appendUnitSuffix( + formatted: string, + unit?: string | null +): string { + if (!unit || unit === "none" || unit === "null") { + return formatted; + } + if (unit === "millisecond") { + return `${formatted}ms`; + } + if (unit === "second") { + return `${formatted}s`; + } + if (unit === "byte") { + return `${formatted}B`; + } + return `${formatted} ${unit}`; +} + +/** + * Format a number with its unit, using standard/compact notation. + * + * @example formatWithUnit(1234, "millisecond") // "1,234ms" + * @example formatWithUnit(1500000, "byte") // "1.5MB" + */ +export function formatWithUnit(value: number, unit?: string | null): string { + return appendUnitSuffix(formatNumber(value), unit); +} + +/** + * Format a number with its unit, always using compact notation. + * + * @example formatCompactWithUnit(52000, "byte") // "52KB" + */ +export function formatCompactWithUnit( + value: number, + unit?: string | null +): string { + return appendUnitSuffix(compactFormatter.format(Math.round(value)), unit); +} + +/** + * Format a percentage value with one decimal place, or "—" when absent. + * + * @example fmtPct(42.3) // "42.3%" + * @example fmtPct(null) // "—" + */ +export function fmtPct(value: number | null | undefined): string { + if (value === null || value === undefined) { + return "—"; + } + return `${value.toFixed(1)}%`; +} + +/** + * Format an integer count in compact notation, or "—" when absent. + * + * Values below 1000 are shown as-is. Above that, uses K/M/B suffixes. + * + * @example fmtCount(500) // "500" + * @example fmtCount(52000) // "52K" + * @example fmtCount(1200000) // "1.2M" + * @example fmtCount(null) // "—" + */ +export function fmtCount(value: number | null | undefined): string { + if (value === null || value === undefined) { + return "—"; + } + return compactFormatter.format(value); +} diff --git a/test/commands/release/view.test.ts b/test/commands/release/view.test.ts index 2ad02cdf3..b89f8f02d 100644 --- a/test/commands/release/view.test.ts +++ b/test/commands/release/view.test.ts @@ -1,5 +1,8 @@ /** * Release View Command Tests + * + * Tests basic display, org resolution, error handling, and + * per-project health/adoption data rendering. */ import { @@ -11,6 +14,7 @@ import { spyOn, test, } from "bun:test"; +import type { OrgReleaseResponse } from "@sentry/api"; import { viewCommand } from "../../../src/commands/release/view.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; @@ -48,6 +52,67 @@ const sampleRelease: OrgReleaseResponse = { ], }; +/** Sample release with per-project health data populated (as from `?health=1`). */ +const sampleReleaseWithHealth: OrgReleaseResponse = { + ...sampleRelease, + projects: [ + { + id: 1, + slug: "frontend", + name: "Frontend", + platform: "javascript", + platforms: ["javascript"], + hasHealthData: true, + newGroups: 3, + healthData: { + adoption: 42.3, + sessionsAdoption: 38.1, + crashFreeUsers: 99.1, + crashFreeSessions: 98.7, + totalUsers: 50_000, + totalUsers24h: 10_200, + totalProjectUsers24h: 12_000, + totalSessions: 200_000, + totalSessions24h: 52_000, + totalProjectSessions24h: 60_000, + sessionsCrashed: 120, + sessionsErrored: 450, + hasHealthData: true, + durationP50: null, + durationP90: null, + stats: {}, + }, + }, + { + id: 2, + slug: "backend", + name: "Backend", + platform: "python", + platforms: ["python"], + hasHealthData: true, + newGroups: 1, + healthData: { + adoption: 78.5, + sessionsAdoption: 72.0, + crashFreeUsers: 94.2, + crashFreeSessions: 93.8, + totalUsers: 30_000, + totalUsers24h: 5000, + totalProjectUsers24h: 6000, + totalSessions: 100_000, + totalSessions24h: 18_000, + totalProjectSessions24h: 20_000, + sessionsCrashed: 80, + sessionsErrored: 300, + hasHealthData: true, + durationP50: null, + durationP90: null, + stats: {}, + }, + }, + ], +}; + function createMockContext(cwd = "/tmp") { const stdoutWrite = mock(() => true); const stderrWrite = mock(() => true); @@ -112,7 +177,10 @@ describe("release view", () => { await func.call(context, { fresh: false, json: true }, "my-org/1.0.0"); expect(resolveOrgSpy).toHaveBeenCalledWith({ org: "my-org", cwd: "/tmp" }); - expect(getReleaseSpy).toHaveBeenCalledWith("my-org", "1.0.0"); + expect(getReleaseSpy).toHaveBeenCalledWith("my-org", "1.0.0", { + health: true, + adoptionStages: true, + }); }); test("throws when no version provided", async () => { @@ -134,4 +202,54 @@ describe("release view", () => { func.call(context, { fresh: false, json: false }, "1.0.0") ).rejects.toThrow("Organization"); }); + + test("displays per-project health data in human mode", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + getReleaseSpy.mockResolvedValue(sampleReleaseWithHealth); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { fresh: false, json: false }, "1.0.0"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + // Health section header + expect(output).toContain("Health by Project"); + // Project slugs + expect(output).toContain("frontend"); + expect(output).toContain("backend"); + // Adoption percentages + expect(output).toContain("42.3%"); + expect(output).toContain("78.5%"); + // Crash-free rates + expect(output).toContain("99.1%"); + expect(output).toContain("98.7%"); + }); + + test("includes health data in JSON output", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + getReleaseSpy.mockResolvedValue(sampleReleaseWithHealth); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { fresh: false, json: true }, "1.0.0"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.projects).toHaveLength(2); + expect(parsed.projects[0].healthData.adoption).toBe(42.3); + expect(parsed.projects[0].healthData.crashFreeSessions).toBe(98.7); + expect(parsed.projects[1].healthData.crashFreeUsers).toBe(94.2); + }); + + test("omits health section when no project has health data", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + getReleaseSpy.mockResolvedValue(sampleRelease); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { fresh: false, json: false }, "1.0.0"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).not.toContain("Health by Project"); + }); });