From e1b6a189a48ff8cfe0820e26f590625d0bac90fe Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 7 Apr 2026 12:31:07 +0000 Subject: [PATCH 01/14] feat(release): surface adoption and health metrics in list and view (#463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add health/adoption data to the existing release commands: - Pass `health=1` to the list endpoint so each release includes per-project adoption and crash-free metrics - Add ADOPTION and CRASH-FREE columns to `release list` table - Add `health`, `adoptionStages`, and `healthStatsPeriod` query options to `getRelease()` API function - Show per-project health breakdown table in `release view` (crash-free users/sessions, adoption %, 24h user/session counts) - Color-code crash-free rates (green ≥ 99%, yellow ≥ 95%, red < 95%) - Gracefully omit health section when no project has session data --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 4 +- .../skills/sentry-cli/references/release.md | 4 +- src/commands/release/list.ts | 53 ++++++-- src/commands/release/view.ts | 98 +++++++++++++- src/lib/api-client.ts | 1 + src/lib/api/releases.ts | 48 ++++++- test/commands/release/view.test.ts | 120 +++++++++++++++++- 7 files changed, 306 insertions(+), 22 deletions(-) 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..e0e54e405 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/release.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/release.md @@ -13,7 +13,7 @@ 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")` @@ -22,7 +22,7 @@ List releases ### `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..612ebd45a 100644 --- a/src/commands/release/list.ts +++ b/src/commands/release/list.ts @@ -2,6 +2,7 @@ * 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"; @@ -19,23 +20,48 @@ export const PAGINATION_KEY = "release-list"; type ReleaseWithOrg = OrgReleaseResponse & { orgSlug?: string }; +/** + * 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; +} + +/** Format a percentage value with one decimal, or "—" when absent. */ +function fmtPct(value: number | null | undefined): string { + if (value === null || value === undefined) { + return "—"; + } + return `${value.toFixed(1)}%`; +} + 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: "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) }, @@ -48,20 +74,27 @@ const releaseListConfig: OrgListConfig = { commandPrefix: "sentry release list", // listForOrg fetches a buffer page for multi-org fan-out. // The framework truncates results to --limit after aggregation. + // health=true to populate per-project adoption/crash-free metrics. listForOrg: async (org) => { - const { data } = await listReleasesPaginated(org, { perPage: 100 }); + const { data } = await listReleasesPaginated(org, { + perPage: 100, + health: true, + }); return data; }, - listPaginated: (org, opts) => listReleasesPaginated(org, opts), + listPaginated: (org, opts) => + listReleasesPaginated(org, { ...opts, health: true }), withOrg: (release, orgSlug) => ({ ...release, orgSlug }), displayTable: (releases: ReleaseWithOrg[]) => formatTable(releases, RELEASE_COLUMNS), }; const docs: OrgListCommandDocs = { - brief: "List releases", + brief: "List releases with adoption and health metrics", fullDescription: - "List releases in an organization.\n\n" + + "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" + "Target specification:\n" + " sentry release list # auto-detect from DSN or config\n" + " sentry release list / # list all releases in org (paginated)\n" + diff --git a/src/commands/release/view.ts b/src/commands/release/view.ts index 96777183a..7705f5ec7 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,8 +12,10 @@ 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"; @@ -26,6 +29,81 @@ import { import { resolveOrg } from "../../lib/resolve-target.js"; import { parseReleaseArg } from "./parse.js"; +/** Format a percentage with one decimal, colorized by threshold. */ +function fmtPct(value: number | null | undefined): string { + if (value === null || value === undefined) { + return "—"; + } + return `${value.toFixed(1)}%`; +} + +/** 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); +} + +/** Format a count with thousands separators, or "—" when absent. */ +function fmtCount(value: number | null | undefined): string { + if (value === null || value === undefined) { + return "—"; + } + return value.toLocaleString("en-US"); +} + +/** + * 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 +155,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 +227,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/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"); + }); }); From e8264ea986a0b32a1f5e05e1caeea0c1e1438bf8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Apr 2026 12:31:59 +0000 Subject: [PATCH 02/14] chore: regenerate skill files and command docs --- docs/src/content/docs/commands/release.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/commands/release.md b/docs/src/content/docs/commands/release.md index 739b8322f..e0bad8e51 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:** @@ -27,7 +27,7 @@ List releases ### `sentry release view ` -View release details +View release details with health metrics **Arguments:** From 49c74de5a5e1001872f502fca82b555243bafeaa Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 7 Apr 2026 13:53:04 +0000 Subject: [PATCH 03/14] refactor(release): extract shared fmtPct/fmtCount to format.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Cursor Bugbot review — deduplicate the percentage and count formatting helpers into a shared module imported by both list and view. --- src/commands/release/format.ts | 32 ++++++++++++++++++++++++++++++++ src/commands/release/list.ts | 9 +-------- src/commands/release/view.ts | 17 +---------------- 3 files changed, 34 insertions(+), 24 deletions(-) create mode 100644 src/commands/release/format.ts diff --git a/src/commands/release/format.ts b/src/commands/release/format.ts new file mode 100644 index 000000000..3d6279eb1 --- /dev/null +++ b/src/commands/release/format.ts @@ -0,0 +1,32 @@ +/** + * Shared formatting utilities for release commands. + * + * Small helpers used by both `list.ts` and `view.ts` to format + * health/adoption metrics consistently. + */ + +/** + * 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 with thousands separators, or "—" when absent. + * + * @example fmtCount(52000) // "52,000" + * @example fmtCount(null) // "—" + */ +export function fmtCount(value: number | null | undefined): string { + if (value === null || value === undefined) { + return "—"; + } + return value.toLocaleString("en-US"); +} diff --git a/src/commands/release/list.ts b/src/commands/release/list.ts index 612ebd45a..b6c9f589a 100644 --- a/src/commands/release/list.ts +++ b/src/commands/release/list.ts @@ -15,6 +15,7 @@ import { type OrgListCommandDocs, } from "../../lib/list-command.js"; import type { OrgListConfig } from "../../lib/org-list.js"; +import { fmtPct } from "./format.js"; export const PAGINATION_KEY = "release-list"; @@ -30,14 +31,6 @@ function getHealthData(release: OrgReleaseResponse) { return release.projects?.find((p) => p.healthData?.hasHealthData)?.healthData; } -/** Format a percentage value with one decimal, or "—" when absent. */ -function fmtPct(value: number | null | undefined): string { - if (value === null || value === undefined) { - return "—"; - } - return `${value.toFixed(1)}%`; -} - const RELEASE_COLUMNS: Column[] = [ { header: "ORG", value: (r) => r.orgSlug || "" }, { diff --git a/src/commands/release/view.ts b/src/commands/release/view.ts index 7705f5ec7..2473e8864 100644 --- a/src/commands/release/view.ts +++ b/src/commands/release/view.ts @@ -27,16 +27,9 @@ import { FRESH_FLAG, } from "../../lib/list-command.js"; import { resolveOrg } from "../../lib/resolve-target.js"; +import { fmtCount, fmtPct } from "./format.js"; import { parseReleaseArg } from "./parse.js"; -/** Format a percentage with one decimal, colorized by threshold. */ -function fmtPct(value: number | null | undefined): string { - if (value === null || value === undefined) { - return "—"; - } - return `${value.toFixed(1)}%`; -} - /** 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) { @@ -52,14 +45,6 @@ function fmtCrashFree(value: number | null | undefined): string { return colorTag("red", formatted); } -/** Format a count with thousands separators, or "—" when absent. */ -function fmtCount(value: number | null | undefined): string { - if (value === null || value === undefined) { - return "—"; - } - return value.toLocaleString("en-US"); -} - /** * Build a markdown table of per-project health data. * From c27f041abab4ed18192c56d46928baf94a1842dd Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 7 Apr 2026 14:12:22 +0000 Subject: [PATCH 04/14] feat(release-list): add --sort flag for health-based ordering Support sorting releases by: date (default), sessions, users, crash_free_sessions, crash_free_users. Switches from buildOrgListCommand to buildListCommand to support the custom flag (-s alias). --- .../skills/sentry-cli/references/release.md | 1 + src/commands/release/list.ts | 212 +++++++++++++----- 2 files changed, 163 insertions(+), 50 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/release.md b/plugins/sentry-cli/skills/sentry-cli/references/release.md index e0e54e405..835a6c578 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/release.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/release.md @@ -17,6 +17,7 @@ List releases with adoption and health metrics **Flags:** - `-n, --limit - Maximum number of releases to list - (default: "25")` +- `-s, --sort - Sort order: date, sessions, users, crash_free_sessions, crash_free_users - (default: "date")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/src/commands/release/list.ts b/src/commands/release/list.ts index b6c9f589a..971d97b8b 100644 --- a/src/commands/release/list.ts +++ b/src/commands/release/list.ts @@ -6,21 +6,59 @@ */ 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 { CommandOutput } from "../../lib/formatters/output.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"; import { fmtPct } from "./format.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", +]; + +const DEFAULT_SORT: ReleaseSortValue = "date"; + +/** + * Parse and validate the `--sort` flag value. + * + * @throws Error when value is not one of the accepted sort keys + */ +function parseSortFlag(value: string): ReleaseSortValue { + if (VALID_SORT_VALUES.includes(value as ReleaseSortValue)) { + return value as ReleaseSortValue; + } + throw new Error( + `Invalid sort value. Must be one of: ${VALID_SORT_VALUES.join(", ")}` + ); +} + /** * Extract health data from the first project that has it. * @@ -60,52 +98,126 @@ const RELEASE_COLUMNS: Column[] = [ { 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. - // health=true to populate per-project adoption/crash-free metrics. - listForOrg: async (org) => { - const { data } = await listReleasesPaginated(org, { - perPage: 100, - health: true, - }); - return data; - }, - listPaginated: (org, opts) => - listReleasesPaginated(org, { ...opts, health: true }), - 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 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" + - "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\n" + + " crash_free_users # by crash-free user rate\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 order: date, sessions, users, crash_free_sessions, crash_free_users", + 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 }; + }, +}); From c3e442e7d1c1febad6e280ee47a1a9d8061b960a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Apr 2026 14:12:59 +0000 Subject: [PATCH 05/14] chore: regenerate skill files and command docs --- docs/src/content/docs/commands/release.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/src/content/docs/commands/release.md b/docs/src/content/docs/commands/release.md index e0bad8e51..f60167a32 100644 --- a/docs/src/content/docs/commands/release.md +++ b/docs/src/content/docs/commands/release.md @@ -22,6 +22,7 @@ List releases with adoption and health metrics | Option | Description | |--------|-------------| | `-n, --limit ` | Maximum number of releases to list (default: "25") | +| `-s, --sort ` | Sort order: date, sessions, users, crash_free_sessions, crash_free_users (default: "date") | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | | `-c, --cursor ` | Navigate pages: "next", "prev", "first" (or raw cursor string) | From 54726583bb2680d48ced63ebc8a7b94be4c14a0d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 7 Apr 2026 20:29:29 +0000 Subject: [PATCH 06/14] refactor(formatters): extract shared number formatters, add sort aliases and session sparklines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract compactFormatter, formatNumber, fmtPct, fmtCount, appendUnitSuffix, formatWithUnit, formatCompactWithUnit into src/lib/formatters/numbers.ts - Update dashboard.ts to import from shared module (no behavior change) - Delete src/commands/release/format.ts — replaced by shared module - Add sort aliases: stable_sessions/cfs → crash_free_sessions, stable_users/cfu → crash_free_users - Add SESSIONS sparkline column to release list table using health stats time-series data (same [timestamp, count] format as issue stats) - Drop COMMITS column from list to make room for sparkline --- .../skills/sentry-cli/references/release.md | 2 +- src/commands/release/format.ts | 32 ----- src/commands/release/list.ts | 72 ++++++++-- src/commands/release/view.ts | 2 +- src/lib/formatters/dashboard.ts | 49 +------ src/lib/formatters/index.ts | 1 + src/lib/formatters/numbers.ts | 130 ++++++++++++++++++ 7 files changed, 204 insertions(+), 84 deletions(-) delete mode 100644 src/commands/release/format.ts create mode 100644 src/lib/formatters/numbers.ts diff --git a/plugins/sentry-cli/skills/sentry-cli/references/release.md b/plugins/sentry-cli/skills/sentry-cli/references/release.md index 835a6c578..26dae5b52 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/release.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/release.md @@ -17,7 +17,7 @@ List releases with adoption and health metrics **Flags:** - `-n, --limit - Maximum number of releases to list - (default: "25")` -- `-s, --sort - Sort order: date, sessions, users, crash_free_sessions, crash_free_users - (default: "date")` +- `-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)` diff --git a/src/commands/release/format.ts b/src/commands/release/format.ts deleted file mode 100644 index 3d6279eb1..000000000 --- a/src/commands/release/format.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Shared formatting utilities for release commands. - * - * Small helpers used by both `list.ts` and `view.ts` to format - * health/adoption metrics consistently. - */ - -/** - * 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 with thousands separators, or "—" when absent. - * - * @example fmtCount(52000) // "52,000" - * @example fmtCount(null) // "—" - */ -export function fmtCount(value: number | null | undefined): string { - if (value === null || value === undefined) { - return "—"; - } - return value.toLocaleString("en-US"); -} diff --git a/src/commands/release/list.ts b/src/commands/release/list.ts index 971d97b8b..9266eebe1 100644 --- a/src/commands/release/list.ts +++ b/src/commands/release/list.ts @@ -13,7 +13,9 @@ import { } 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 { @@ -28,7 +30,6 @@ import { type ListResult, type OrgListConfig, } from "../../lib/org-list.js"; -import { fmtPct } from "./format.js"; export const PAGINATION_KEY = "release-list"; @@ -43,20 +44,40 @@ const VALID_SORT_VALUES: ReleaseSortValue[] = [ "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. * - * @throws Error when value is not one of the accepted sort keys + * 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; } - throw new Error( - `Invalid sort value. Must be one of: ${VALID_SORT_VALUES.join(", ")}` + 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}`); } /** @@ -69,6 +90,29 @@ 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 || "" }, { @@ -89,12 +133,24 @@ const RELEASE_COLUMNS: Column[] = [ 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) }, ]; @@ -168,8 +224,8 @@ export const listCommand = buildListCommand("release", { " date # by creation date (default)\n" + " sessions # by total sessions\n" + " users # by total users\n" + - " crash_free_sessions # by crash-free session rate\n" + - " crash_free_users # by crash-free user rate\n\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" + @@ -199,7 +255,7 @@ export const listCommand = buildListCommand("release", { kind: "parsed" as const, parse: parseSortFlag, brief: - "Sort order: date, sessions, users, crash_free_sessions, crash_free_users", + "Sort: date, sessions, users, crash_free_sessions (cfs), crash_free_users (cfu)", default: DEFAULT_SORT, }, }, diff --git a/src/commands/release/view.ts b/src/commands/release/view.ts index 2473e8864..edc67d346 100644 --- a/src/commands/release/view.ts +++ b/src/commands/release/view.ts @@ -19,6 +19,7 @@ import { 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 { @@ -27,7 +28,6 @@ import { FRESH_FLAG, } from "../../lib/list-command.js"; import { resolveOrg } from "../../lib/resolve-target.js"; -import { fmtCount, fmtPct } from "./format.js"; import { parseReleaseArg } from "./parse.js"; /** Format a crash-free rate with color coding (green ≥ 99, yellow ≥ 95, red < 95). */ 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); +} From 5276d9c7123854bf195a84456d94e6696439ab8d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Apr 2026 20:30:13 +0000 Subject: [PATCH 07/14] chore: regenerate skill files and command docs --- docs/src/content/docs/commands/release.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/docs/commands/release.md b/docs/src/content/docs/commands/release.md index f60167a32..04a70b4f2 100644 --- a/docs/src/content/docs/commands/release.md +++ b/docs/src/content/docs/commands/release.md @@ -22,7 +22,7 @@ List releases with adoption and health metrics | Option | Description | |--------|-------------| | `-n, --limit ` | Maximum number of releases to list (default: "25") | -| `-s, --sort ` | Sort order: date, sessions, users, crash_free_sessions, crash_free_users (default: "date") | +| `-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) | From d09c7da670fa26f229579d3f4b6d9894983e8151 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 8 Apr 2026 18:37:46 +0000 Subject: [PATCH 08/14] feat(release-list): project scoping, env filter, rich styling, release URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major enhancements to `release list` to match the Sentry releases page: API layer: - Add project (numeric ID[]), environment (string[]), statsPeriod, and status query params to listReleasesPaginated - Add listReleasesForProject helper that resolves slug → numeric ID - Add buildReleaseUrl to sentry-urls.ts (URI-encoded versions) New flags: - --environment/-e: filter by environment (e.g., production) - --period/-t: health stats period (default 90d, matching web UI) - --status: open (default) or archived Project scoping: - Wire listForProject in OrgListConfig so DSN auto-detection and explicit org/project targets scope releases to the detected project Rich styling (matching issue list patterns): - Bold version names linked to Sentry release page - 2-line VERSION cell: version + muted "age | deploy-env" subtitle - Colored adoption: green ≥50%, yellow ≥10% - Colored crash-free rate: green ≥99%, yellow ≥95%, red <95% - Colored crashes: red when >0, green when 0 - Muted session sparklines - Muted row separators between rows - Right-aligned numeric columns (ADOPTION, CRASH-FREE, CRASHES, NEW ISSUES) --- .../skills/sentry-cli/references/dashboard.md | 2 +- .../skills/sentry-cli/references/event.md | 2 +- .../skills/sentry-cli/references/issue.md | 4 +- .../skills/sentry-cli/references/log.md | 2 +- .../skills/sentry-cli/references/release.md | 3 + .../skills/sentry-cli/references/span.md | 2 +- .../skills/sentry-cli/references/trace.md | 4 +- src/commands/release/list.ts | 248 +++++++++++++----- src/lib/api-client.ts | 2 + src/lib/api/releases.ts | 58 +++- src/lib/sentry-urls.ts | 18 ++ 11 files changed, 260 insertions(+), 85 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index e1f837354..0211a409f 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -42,7 +42,7 @@ View a dashboard - `-w, --web - Open in browser` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` -- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07"` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08"` **Examples:** diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index 573eb2e1d..2a1fe67b5 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -28,7 +28,7 @@ List events for an issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 197f9da95..d1d699399 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -19,7 +19,7 @@ List issues in a project - `-q, --query - Search query (Sentry search syntax)` - `-n, --limit - Maximum number of issues to list - (default: "25")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" - (default: "90d")` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "90d")` - `-c, --cursor - Pagination cursor (use "next" for next page, "prev" for previous)` - `--compact - Single-line rows for compact output (auto-detects if omitted)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -78,7 +78,7 @@ List events for a specific issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/log.md b/plugins/sentry-cli/skills/sentry-cli/references/log.md index a75fd06e0..6701287c1 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -19,7 +19,7 @@ List logs from a project - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Filter query (Sentry search syntax)` - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07"` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08"` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` - `--fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/release.md b/plugins/sentry-cli/skills/sentry-cli/references/release.md index 26dae5b52..ae29441c3 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/release.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/release.md @@ -18,6 +18,9 @@ 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")` +- `-e, --environment - Filter by environment (e.g., production)` +- `-t, --period - Health stats period (e.g., 24h, 7d, 14d, 90d) - (default: "90d")` +- `--status - Filter by status: open (default) or archived - (default: "open")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/span.md b/plugins/sentry-cli/skills/sentry-cli/references/span.md index 0bba2e955..98e3125f0 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/span.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/span.md @@ -19,7 +19,7 @@ List spans in a project or trace - `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")` - `-s, --sort - Sort order: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/plugins/sentry-cli/skills/sentry-cli/references/trace.md index dbbd6e8aa..6718fa6ad 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -19,7 +19,7 @@ List recent traces in a project - `-n, --limit - Number of traces (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `-s, --sort - Sort by: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` @@ -78,7 +78,7 @@ View logs associated with a trace **Flags:** - `-w, --web - Open trace in browser` -- `-t, --period - Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" - (default: "14d")` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "14d")` - `-n, --limit - Number of log entries (<=1000) - (default: "100")` - `-q, --query - Additional filter query (Sentry search syntax)` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` diff --git a/src/commands/release/list.ts b/src/commands/release/list.ts index 9266eebe1..f72e8b2f8 100644 --- a/src/commands/release/list.ts +++ b/src/commands/release/list.ts @@ -1,18 +1,23 @@ /** * sentry release list * - * List releases in an organization with pagination support. - * Includes per-project health/adoption metrics when available. + * List releases in an organization with health/adoption metrics, + * project scoping, environment filtering, and rich terminal styling. */ import type { OrgReleaseResponse } from "@sentry/api"; import type { SentryContext } from "../../context.js"; import { + type ListReleasesOptions, + listReleasesForProject, listReleasesPaginated, type ReleaseSortValue, } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; -import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { + colorTag, + escapeMarkdownInline, +} 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"; @@ -30,12 +35,16 @@ import { type ListResult, type OrgListConfig, } from "../../lib/org-list.js"; +import { buildReleaseUrl } from "../../lib/sentry-urls.js"; export const PAGINATION_KEY = "release-list"; type ReleaseWithOrg = OrgReleaseResponse & { orgSlug?: string }; -/** Valid values for the `--sort` flag. */ +// --------------------------------------------------------------------------- +// Sort +// --------------------------------------------------------------------------- + const VALID_SORT_VALUES: ReleaseSortValue[] = [ "date", "sessions", @@ -44,13 +53,6 @@ const VALID_SORT_VALUES: ReleaseSortValue[] = [ "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", @@ -60,12 +62,6 @@ const SORT_ALIASES: Record = { 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; @@ -80,22 +76,16 @@ function parseSortFlag(value: string): ReleaseSortValue { 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`. - */ +// --------------------------------------------------------------------------- +// Health data helpers +// --------------------------------------------------------------------------- + +/** Pick health data from the first project that has it. */ 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. - */ +/** Extract session time-series from health stats `{ "": [[ts, count], ...] }`. */ function extractSessionPoints(stats?: Record): number[] { if (!stats) { return []; @@ -113,55 +103,133 @@ function extractSessionPoints(stats?: Record): number[] { ); } +// --------------------------------------------------------------------------- +// Cell formatters (rich styling, matching issue list patterns) +// --------------------------------------------------------------------------- + +/** + * Format the VERSION cell: bold version linked to Sentry release page. + * Second line: muted "age | last-deploy-env". + */ +function formatVersionCell(r: ReleaseWithOrg): string { + const version = escapeMarkdownInline(r.shortVersion || r.version); + const org = r.orgSlug || ""; + const linked = org + ? `[**${version}**](${buildReleaseUrl(org, r.version)})` + : `**${version}**`; + const age = r.dateCreated ? formatRelativeTime(r.dateCreated) : ""; + const env = r.lastDeploy?.environment || ""; + const subtitle = [age, env].filter(Boolean).join(" | "); + if (subtitle) { + return `${linked}\n${colorTag("muted", subtitle)}`; + } + return linked; +} + +/** Color adoption percentage: green ≥ 50%, yellow ≥ 10%, default otherwise. */ +function formatAdoption(value: number | null | undefined): string { + if (value === null || value === undefined) { + return colorTag("muted", "—"); + } + const text = `${value.toFixed(0)}%`; + if (value >= 50) { + return colorTag("green", text); + } + if (value >= 10) { + return colorTag("yellow", text); + } + return text; +} + +/** Color crash-free rate: green ≥ 99%, yellow ≥ 95%, red < 95%. */ +function formatCrashFree(value: number | null | undefined): string { + if (value === null || value === undefined) { + return colorTag("muted", "—"); + } + const text = fmtPct(value); + if (value >= 99) { + return colorTag("green", text); + } + if (value >= 95) { + return colorTag("yellow", text); + } + return colorTag("red", text); +} + +/** Session sparkline in muted color. */ +function formatSessionSparkline(r: OrgReleaseResponse): string { + const health = getHealthData(r); + if (!health) { + return ""; + } + const points = extractSessionPoints( + health.stats as Record | undefined + ); + if (points.length === 0) { + return ""; + } + return colorTag("muted", sparkline(points)); +} + +// --------------------------------------------------------------------------- +// Column definitions +// --------------------------------------------------------------------------- + const RELEASE_COLUMNS: Column[] = [ { header: "ORG", value: (r) => r.orgSlug || "" }, { header: "VERSION", - value: (r) => escapeMarkdownCell(r.shortVersion || r.version), - }, - { - header: "CREATED", - value: (r) => (r.dateCreated ? formatRelativeTime(r.dateCreated) : ""), + value: formatVersionCell, + shrinkable: false, }, { header: "ADOPTION", - value: (r) => fmtPct(getHealthData(r)?.adoption), + value: (r) => formatAdoption(getHealthData(r)?.adoption), align: "right", }, + { + header: "SESSIONS", + value: formatSessionSparkline, + }, { header: "CRASH-FREE", - value: (r) => fmtPct(getHealthData(r)?.crashFreeSessions), + value: (r) => formatCrashFree(getHealthData(r)?.crashFreeSessions), align: "right", }, { - header: "SESSIONS", + header: "CRASHES", value: (r) => { - const health = getHealthData(r); - if (!health) { - return ""; + const h = getHealthData(r); + const v = h?.sessionsCrashed; + if (v === undefined || v === null) { + return colorTag("muted", "—"); } - const points = extractSessionPoints( - health.stats as Record | undefined - ); - return points.length > 0 ? sparkline(points) : ""; + return v > 0 ? colorTag("red", String(v)) : colorTag("green", "0"); }, + align: "right", }, { - header: "ISSUES", + header: "NEW ISSUES", value: (r) => String(r.newGroups ?? 0), align: "right", }, - { header: "DEPLOYS", value: (r) => String(r.deployCount ?? 0) }, ]; -/** - * 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. - */ +/** Muted ANSI color for row separators (matches issue list). */ +const MUTED_ANSI = "\x1b[38;2;137;130;148m"; + +// --------------------------------------------------------------------------- +// Config builder +// --------------------------------------------------------------------------- + +/** Extra API options shared across listForOrg, listPaginated, and listForProject. */ +type ExtraApiOptions = Pick< + ListReleasesOptions, + "sort" | "environment" | "statsPeriod" | "status" +>; + function buildReleaseListConfig( - sort: ReleaseSortValue + extra: ExtraApiOptions ): OrgListConfig { return { paginationKey: PAGINATION_KEY, @@ -172,19 +240,24 @@ function buildReleaseListConfig( const { data } = await listReleasesPaginated(org, { perPage: 100, health: true, - sort, + ...extra, }); return data; }, listPaginated: (org, opts) => - listReleasesPaginated(org, { ...opts, health: true, sort }), + listReleasesPaginated(org, { ...opts, health: true, ...extra }), + listForProject: (org, project) => + listReleasesForProject(org, project, { health: true, ...extra }), withOrg: (release, orgSlug) => ({ ...release, orgSlug }), displayTable: (releases: ReleaseWithOrg[]) => - formatTable(releases, RELEASE_COLUMNS), + formatTable(releases, RELEASE_COLUMNS, { rowSeparator: MUTED_ANSI }), }; } -/** Format a ListResult as human-readable output. */ +// --------------------------------------------------------------------------- +// Human formatter +// --------------------------------------------------------------------------- + function formatListHuman(result: ListResult): string { const parts: string[] = []; @@ -195,7 +268,9 @@ function formatListHuman(result: ListResult): string { return parts.join("\n"); } - parts.push(formatTable(result.items, RELEASE_COLUMNS)); + parts.push( + formatTable(result.items, RELEASE_COLUMNS, { rowSeparator: MUTED_ANSI }) + ); if (result.header) { parts.push(`\n${result.header}`); @@ -204,22 +279,33 @@ function formatListHuman(result: ListResult): string { return parts.join(""); } +// --------------------------------------------------------------------------- +// Flags +// --------------------------------------------------------------------------- + type ListFlags = { readonly limit: number; readonly sort: ReleaseSortValue; + readonly environment?: string; + readonly period: string; + readonly status: string; readonly json: boolean; readonly cursor?: string; readonly fresh: boolean; readonly fields?: string[]; }; +// --------------------------------------------------------------------------- +// Command +// --------------------------------------------------------------------------- + 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" + + "When run from a project directory (DSN auto-detection or explicit\n" + + "/ target), shows only releases for that project.\n\n" + "Sort options:\n" + " date # by creation date (default)\n" + " sessions # by total sessions\n" + @@ -227,18 +313,20 @@ export const listCommand = buildListCommand("release", { " 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 # auto-detect from DSN (project-scoped)\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 for project\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 # auto-detect project\n" + + " sentry release list my-org/ # all releases in org\n" + + " sentry release list my-org/my-proj # project-scoped\n" + + " sentry release list --sort cfs # sort by crash-free sessions\n" + + " sentry release list --environment production # filter by env\n" + + " sentry release list --period 7d # last 7 days of health data\n" + " sentry release list --json\n\n" + "Alias: `sentry releases` → `sentry release list`", }, @@ -258,13 +346,37 @@ export const listCommand = buildListCommand("release", { "Sort: date, sessions, users, crash_free_sessions (cfs), crash_free_users (cfu)", default: DEFAULT_SORT, }, + environment: { + kind: "parsed" as const, + parse: String, + brief: "Filter by environment (e.g., production)", + optional: true as const, + }, + period: { + kind: "parsed" as const, + parse: String, + brief: "Health stats period (e.g., 24h, 7d, 14d, 90d)", + default: "90d", + }, + status: { + kind: "parsed" as const, + parse: String, + brief: "Filter by status: open (default) or archived", + default: "open", + }, }, - aliases: { ...LIST_BASE_ALIASES, s: "sort" }, + aliases: { ...LIST_BASE_ALIASES, s: "sort", e: "environment", t: "period" }, }, async *func(this: SentryContext, flags: ListFlags, target?: string) { const { cwd } = this; const parsed = parseOrgProjectArg(target); - const config = buildReleaseListConfig(flags.sort); + const extra: ExtraApiOptions = { + sort: flags.sort, + environment: flags.environment ? [flags.environment] : undefined, + statsPeriod: flags.period, + status: flags.status, + }; + const config = buildReleaseListConfig(extra); const result = await dispatchOrgScopedList({ config, cwd, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 91a134d2b..a37ce4921 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -94,7 +94,9 @@ export { createReleaseDeploy, deleteRelease, getRelease, + type ListReleasesOptions, listReleaseDeploys, + listReleasesForProject, listReleasesPaginated, type ReleaseSortValue, setCommitsAuto, diff --git a/src/lib/api/releases.ts b/src/lib/api/releases.ts index 01c805276..2321f5ced 100644 --- a/src/lib/api/releases.ts +++ b/src/lib/api/releases.ts @@ -28,6 +28,7 @@ import { unwrapPaginatedResult, unwrapResult, } from "./infrastructure.js"; +import { getProject } from "./projects.js"; import { listRepositoriesPaginated } from "./repositories.js"; // We cast through `unknown` to bridge the gap between the SDK's internal @@ -45,29 +46,44 @@ import { listRepositoriesPaginated } from "./repositories.js"; * @param options - Pagination, query, sort, and health options * @returns Single page of releases with cursor metadata */ +/** Options for listing releases with pagination. */ +export type ListReleasesOptions = { + cursor?: string; + perPage?: number; + query?: string; + sort?: string; + /** Include per-project health/adoption data in the response. */ + health?: boolean; + /** Filter by numeric project IDs (repeated query param). */ + project?: number[]; + /** Filter by environment names (repeated query param). */ + environment?: string[]; + /** Stats period for health data, e.g. "24h", "7d", "90d". */ + statsPeriod?: string; + /** Filter by release status: "open" (active) or "archived". */ + status?: string; +}; + export async function listReleasesPaginated( orgSlug: string, - options: { - cursor?: string; - perPage?: number; - query?: string; - sort?: string; - /** Include per-project health/adoption data in the response. */ - health?: boolean; - } = {} + options: ListReleasesOptions = {} ): Promise> { const config = await getOrgSdkConfig(orgSlug); const result = await listAnOrganization_sReleases({ ...config, path: { organization_id_or_slug: orgSlug }, - // per_page, sort, and health are supported at runtime but not in the OpenAPI spec + // Most query params are supported at runtime but absent from the OpenAPI spec query: { cursor: options.cursor, per_page: options.perPage ?? 25, query: options.query, sort: options.sort, health: options.health ? 1 : undefined, + project: options.project, + environment: options.environment, + statsPeriod: options.statsPeriod, + status: options.status, } as { cursor?: string }, }); @@ -79,6 +95,30 @@ export async function listReleasesPaginated( ); } +/** + * List releases scoped to a specific project. + * + * Resolves the project slug to a numeric ID (required by the API's + * `project` query param), then fetches with health data. + */ +export async function listReleasesForProject( + orgSlug: string, + projectSlug: string, + options: Omit = {} +): Promise { + // Resolve slug → numeric ID (the API requires numeric project IDs) + const info = await getProject(orgSlug, projectSlug); + const numericId = Number(info.id); + const projectIds = + Number.isFinite(numericId) && numericId > 0 ? [numericId] : undefined; + const { data } = await listReleasesPaginated(orgSlug, { + ...options, + project: projectIds, + perPage: options.perPage ?? 100, + }); + return data; +} + /** Sort options for the release list endpoint. */ export type ReleaseSortValue = | "date" diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index 24e850869..74d24972e 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -221,3 +221,21 @@ export function buildTraceUrl(orgSlug: string, traceId: string): string { } return `${getSentryBaseUrl()}/organizations/${orgSlug}/traces/${traceId}/`; } + +/** + * Build URL to view a release in Sentry. + * + * Version is URI-encoded since release versions often contain special + * characters (e.g., `sentry-cli@0.24.0`, `1.0.0-beta+build.123`). + * + * @param orgSlug - Organization slug + * @param version - Release version string + * @returns Full URL to the release detail page + */ +export function buildReleaseUrl(orgSlug: string, version: string): string { + const encoded = encodeURIComponent(version); + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/releases/${encoded}/`; + } + return `${getSentryBaseUrl()}/organizations/${orgSlug}/releases/${encoded}/`; +} From 46507ce902b3c8cde3e6fc053067cf1ea104b8d4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Apr 2026 18:38:28 +0000 Subject: [PATCH 09/14] chore: regenerate skill files and command docs --- docs/src/content/docs/commands/dashboard.md | 2 +- docs/src/content/docs/commands/event.md | 2 +- docs/src/content/docs/commands/issue.md | 4 ++-- docs/src/content/docs/commands/log.md | 2 +- docs/src/content/docs/commands/release.md | 3 +++ docs/src/content/docs/commands/span.md | 2 +- docs/src/content/docs/commands/trace.md | 4 ++-- 7 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/src/content/docs/commands/dashboard.md b/docs/src/content/docs/commands/dashboard.md index 51ce630e0..d40c7e971 100644 --- a/docs/src/content/docs/commands/dashboard.md +++ b/docs/src/content/docs/commands/dashboard.md @@ -43,7 +43,7 @@ View a dashboard | `-w, --web` | Open in browser | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | | `-r, --refresh ` | Auto-refresh interval in seconds (default: 60, min: 10) | -| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" | +| `-t, --period ` | Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" | ### `sentry dashboard create ` diff --git a/docs/src/content/docs/commands/event.md b/docs/src/content/docs/commands/event.md index 34d1e0465..87ca42361 100644 --- a/docs/src/content/docs/commands/event.md +++ b/docs/src/content/docs/commands/event.md @@ -42,7 +42,7 @@ List events for an issue | `-n, --limit ` | Number of events (1-1000) (default: "25") | | `-q, --query ` | Search query (Sentry search syntax) | | `--full` | Include full event body (stacktraces) | -| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" (default: "7d") | +| `-t, --period ` | Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" (default: "7d") | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | | `-c, --cursor ` | Navigate pages: "next", "prev", "first" (or raw cursor string) | diff --git a/docs/src/content/docs/commands/issue.md b/docs/src/content/docs/commands/issue.md index 7b5fb9d32..1eb4503c9 100644 --- a/docs/src/content/docs/commands/issue.md +++ b/docs/src/content/docs/commands/issue.md @@ -24,7 +24,7 @@ List issues in a project | `-q, --query ` | Search query (Sentry search syntax) | | `-n, --limit ` | Maximum number of issues to list (default: "25") | | `-s, --sort ` | Sort by: date, new, freq, user (default: "date") | -| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" (default: "90d") | +| `-t, --period ` | Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" (default: "90d") | | `-c, --cursor ` | Pagination cursor (use "next" for next page, "prev" for previous) | | `--compact` | Single-line rows for compact output (auto-detects if omitted) | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | @@ -46,7 +46,7 @@ List events for a specific issue | `-n, --limit ` | Number of events (1-1000) (default: "25") | | `-q, --query ` | Search query (Sentry search syntax) | | `--full` | Include full event body (stacktraces) | -| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" (default: "7d") | +| `-t, --period ` | Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" (default: "7d") | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | | `-c, --cursor ` | Navigate pages: "next", "prev", "first" (or raw cursor string) | diff --git a/docs/src/content/docs/commands/log.md b/docs/src/content/docs/commands/log.md index dcbd6b470..bb6c82343 100644 --- a/docs/src/content/docs/commands/log.md +++ b/docs/src/content/docs/commands/log.md @@ -24,7 +24,7 @@ List logs from a project | `-n, --limit ` | Number of log entries (1-1000) (default: "100") | | `-q, --query ` | Filter query (Sentry search syntax) | | `-f, --follow ` | Stream logs (optionally specify poll interval in seconds) | -| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" | +| `-t, --period ` | Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" | | `-s, --sort ` | Sort order: "newest" (default) or "oldest" (default: "newest") | | `--fresh` | Bypass cache, re-detect projects, and fetch fresh data | diff --git a/docs/src/content/docs/commands/release.md b/docs/src/content/docs/commands/release.md index 04a70b4f2..37fcaae04 100644 --- a/docs/src/content/docs/commands/release.md +++ b/docs/src/content/docs/commands/release.md @@ -23,6 +23,9 @@ List releases with adoption and health metrics |--------|-------------| | `-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") | +| `-e, --environment ` | Filter by environment (e.g., production) | +| `-t, --period ` | Health stats period (e.g., 24h, 7d, 14d, 90d) (default: "90d") | +| `--status ` | Filter by status: open (default) or archived (default: "open") | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | | `-c, --cursor ` | Navigate pages: "next", "prev", "first" (or raw cursor string) | diff --git a/docs/src/content/docs/commands/span.md b/docs/src/content/docs/commands/span.md index 053367ccb..85bfef5da 100644 --- a/docs/src/content/docs/commands/span.md +++ b/docs/src/content/docs/commands/span.md @@ -24,7 +24,7 @@ List spans in a project or trace | `-n, --limit ` | Number of spans (<=1000) (default: "25") | | `-q, --query ` | Filter spans (e.g., "op:db", "duration:>100ms", "project:backend") | | `-s, --sort ` | Sort order: date, duration (default: "date") | -| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" (default: "7d") | +| `-t, --period ` | Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" (default: "7d") | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | | `-c, --cursor ` | Navigate pages: "next", "prev", "first" (or raw cursor string) | diff --git a/docs/src/content/docs/commands/trace.md b/docs/src/content/docs/commands/trace.md index b5d8c1e07..162168b85 100644 --- a/docs/src/content/docs/commands/trace.md +++ b/docs/src/content/docs/commands/trace.md @@ -24,7 +24,7 @@ List recent traces in a project | `-n, --limit ` | Number of traces (1-1000) (default: "25") | | `-q, --query ` | Search query (Sentry search syntax) | | `-s, --sort ` | Sort by: date, duration (default: "date") | -| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" (default: "7d") | +| `-t, --period ` | Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" (default: "7d") | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | | `-c, --cursor ` | Navigate pages: "next", "prev", "first" (or raw cursor string) | @@ -61,7 +61,7 @@ View logs associated with a trace | Option | Description | |--------|-------------| | `-w, --web` | Open trace in browser | -| `-t, --period ` | Time range: "7d", "2026-03-07..2026-04-07", ">=2026-03-07" (default: "14d") | +| `-t, --period ` | Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" (default: "14d") | | `-n, --limit ` | Number of log entries (<=1000) (default: "100") | | `-q, --query ` | Additional filter query (Sentry search syntax) | | `-s, --sort ` | Sort order: "newest" (default) or "oldest" (default: "newest") | From 48aac15b477584636e4a5818d38ab65308364d5a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 8 Apr 2026 18:48:23 +0000 Subject: [PATCH 10/14] refactor(release): deduplicate crash-free color formatter Export fmtCrashFree from view.ts and import in list.ts instead of maintaining two identical implementations (addresses Bugbot review). --- src/commands/release/list.ts | 19 ++----------------- src/commands/release/view.ts | 2 +- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/src/commands/release/list.ts b/src/commands/release/list.ts index f72e8b2f8..5cabcb01a 100644 --- a/src/commands/release/list.ts +++ b/src/commands/release/list.ts @@ -18,7 +18,6 @@ import { colorTag, escapeMarkdownInline, } 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"; @@ -36,6 +35,7 @@ import { type OrgListConfig, } from "../../lib/org-list.js"; import { buildReleaseUrl } from "../../lib/sentry-urls.js"; +import { fmtCrashFree } from "./view.js"; export const PAGINATION_KEY = "release-list"; @@ -141,21 +141,6 @@ function formatAdoption(value: number | null | undefined): string { return text; } -/** Color crash-free rate: green ≥ 99%, yellow ≥ 95%, red < 95%. */ -function formatCrashFree(value: number | null | undefined): string { - if (value === null || value === undefined) { - return colorTag("muted", "—"); - } - const text = fmtPct(value); - if (value >= 99) { - return colorTag("green", text); - } - if (value >= 95) { - return colorTag("yellow", text); - } - return colorTag("red", text); -} - /** Session sparkline in muted color. */ function formatSessionSparkline(r: OrgReleaseResponse): string { const health = getHealthData(r); @@ -193,7 +178,7 @@ const RELEASE_COLUMNS: Column[] = [ }, { header: "CRASH-FREE", - value: (r) => formatCrashFree(getHealthData(r)?.crashFreeSessions), + value: (r) => fmtCrashFree(getHealthData(r)?.crashFreeSessions), align: "right", }, { diff --git a/src/commands/release/view.ts b/src/commands/release/view.ts index edc67d346..9bab7369f 100644 --- a/src/commands/release/view.ts +++ b/src/commands/release/view.ts @@ -31,7 +31,7 @@ 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 { +export function fmtCrashFree(value: number | null | undefined): string { if (value === null || value === undefined) { return "—"; } From 71591f122cf58338662d8758811cf678cb24bd8e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 8 Apr 2026 18:57:03 +0000 Subject: [PATCH 11/14] fix(release-list): scope auto-detect to project via DSN resolution The default auto-detect handler only resolves org slugs and fetches ALL releases in the org, burying project-specific releases in noise. Override auto-detect to use resolveAllTargets which gets org+project+projectId from DSN detection, then pass numeric project IDs to the API for scoped results. This matches how issue list handles project-scoped auto-detect. --- src/commands/release/list.ts | 115 +++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/src/commands/release/list.ts b/src/commands/release/list.ts index 5cabcb01a..8479c4441 100644 --- a/src/commands/release/list.ts +++ b/src/commands/release/list.ts @@ -30,10 +30,16 @@ import { } from "../../lib/list-command.js"; import { dispatchOrgScopedList, + type HandlerContext, jsonTransformListResult, type ListResult, type OrgListConfig, } from "../../lib/org-list.js"; +import { + type ResolvedTarget, + resolveAllTargets, + toNumericId, +} from "../../lib/resolve-target.js"; import { buildReleaseUrl } from "../../lib/sentry-urls.js"; import { fmtCrashFree } from "./view.js"; @@ -264,6 +270,111 @@ function formatListHuman(result: ListResult): string { return parts.join(""); } +// --------------------------------------------------------------------------- +// Auto-detect override: resolve DSN → project-scoped listing +// --------------------------------------------------------------------------- + +/** + * Custom auto-detect handler that resolves DSN/config to org+project targets, + * then fetches releases scoped to each detected project. + * + * The default auto-detect handler only resolves org slugs and calls + * `listForOrg`, which returns ALL releases in the org. Since orgs can have + * hundreds of projects, the specific project's releases get buried. + * This override uses `resolveAllTargets` to get project context from DSN + * detection, then passes project IDs to the API for scoped results. + */ +async function handleAutoDetectWithProject( + config: OrgListConfig, + extra: ExtraApiOptions, + ctx: HandlerContext<"auto-detect"> +): Promise> { + const { cwd, flags } = ctx; + const resolved = await resolveAllTargets({ cwd }); + + if (resolved.targets.length === 0) { + // No DSN/config found — fall back to org-wide listing via listForOrg + const { data } = await listReleasesPaginated("", { + perPage: flags.limit, + health: true, + ...extra, + }); + return { + items: data.map((r) => config.withOrg(r, "")), + hint: "No project detected. Specify a target: sentry release list /", + }; + } + + // Deduplicate by org+project + const seen = new Set(); + const unique: ResolvedTarget[] = []; + for (const t of resolved.targets) { + const key = `${t.org}/${t.project}`; + if (!seen.has(key)) { + seen.add(key); + unique.push(t); + } + } + + // Fetch releases scoped to each detected project + const allItems: ReleaseWithOrg[] = []; + const hintParts: string[] = []; + + for (const t of unique) { + const projectIds = t.projectId ? [t.projectId] : undefined; + // If we don't have a numeric project ID, try to resolve it + const ids = projectIds ?? (await resolveProjectIds(t.org, t.project)); + const { data } = await listReleasesPaginated(t.org, { + perPage: Math.min(flags.limit, 100), + health: true, + project: ids, + ...extra, + }); + for (const release of data) { + allItems.push(config.withOrg(release, t.org)); + } + } + + const limited = allItems.slice(0, flags.limit); + + if (limited.length === 0) { + const projects = unique.map((t) => `${t.org}/${t.project}`).join(", "); + hintParts.push(`No releases found for ${projects}.`); + } + + if (resolved.footer) { + hintParts.push(resolved.footer); + } + + const detectedFrom = unique + .filter((t) => t.detectedFrom) + .map((t) => `${t.project} (from ${t.detectedFrom})`) + .join(", "); + if (detectedFrom) { + hintParts.push(`Detected: ${detectedFrom}`); + } + + return { + items: limited, + hint: hintParts.length > 0 ? hintParts.join("\n") : undefined, + }; +} + +/** Resolve a project slug to a numeric ID array for the API query param. */ +async function resolveProjectIds( + org: string, + project: string +): Promise { + try { + const { getProject } = await import("../../lib/api-client.js"); + const info = await getProject(org, project); + const id = toNumericId(info.id); + return id ? [id] : undefined; + } catch { + return; + } +} + // --------------------------------------------------------------------------- // Flags // --------------------------------------------------------------------------- @@ -368,6 +479,10 @@ export const listCommand = buildListCommand("release", { flags, parsed, orgSlugMatchBehavior: "redirect", + overrides: { + "auto-detect": (ctx: HandlerContext<"auto-detect">) => + handleAutoDetectWithProject(config, extra, ctx), + }, }); yield new CommandOutput(result); const hint = result.items.length > 0 ? result.hint : undefined; From dfaf768abd54f26fab375fdd340b5bd545d66f6d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 8 Apr 2026 19:05:39 +0000 Subject: [PATCH 12/14] feat(release-list): multi-env filtering with smart production default Environment filtering: - Support multiple -e flags: -e production -e development - Support comma-separated: -e production,development - Variadic flag via Stricli variadic: true Smart production default: - When no --environment is passed and a single project is auto-detected, call listProjectEnvironments to check if "production" or "prod" exists - Auto-select it as the default, matching the Sentry web UI behavior - Show "Environment: production (use -e to change)" hint so it is clear - One lightweight API call (project environments list), cached by region API layer: - Add listProjectEnvironments using @sentry/api listAProject_sEnvironments - Returns Array<{ id, name, isHidden }> for visible environments --- .../skills/sentry-cli/references/release.md | 2 +- src/commands/release/list.ts | 175 ++++++++++++------ src/lib/api-client.ts | 1 + src/lib/api/releases.ts | 35 ++++ 4 files changed, 160 insertions(+), 53 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/release.md b/plugins/sentry-cli/skills/sentry-cli/references/release.md index ae29441c3..71cbdee74 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/release.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/release.md @@ -18,7 +18,7 @@ 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")` -- `-e, --environment - Filter by environment (e.g., production)` +- `-e, --environment ... - Filter by environment (repeatable, comma-separated)` - `-t, --period - Health stats period (e.g., 24h, 7d, 14d, 90d) - (default: "90d")` - `--status - Filter by status: open (default) or archived - (default: "open")` - `-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 8479c4441..37783ed7e 100644 --- a/src/commands/release/list.ts +++ b/src/commands/release/list.ts @@ -9,6 +9,7 @@ import type { OrgReleaseResponse } from "@sentry/api"; import type { SentryContext } from "../../context.js"; import { type ListReleasesOptions, + listProjectEnvironments, listReleasesForProject, listReleasesPaginated, type ReleaseSortValue, @@ -274,6 +275,65 @@ function formatListHuman(result: ListResult): string { // Auto-detect override: resolve DSN → project-scoped listing // --------------------------------------------------------------------------- +/** Deduplicate resolved targets by org+project key. */ +function deduplicateTargets(targets: ResolvedTarget[]): ResolvedTarget[] { + const seen = new Set(); + const result: ResolvedTarget[] = []; + for (const t of targets) { + const key = `${t.org}/${t.project}`; + if (!seen.has(key)) { + seen.add(key); + result.push(t); + } + } + return result; +} + +/** Resolve a project slug to a numeric ID array for the API query param. */ +async function resolveProjectIds( + org: string, + project: string +): Promise { + try { + const { getProject } = await import("../../lib/api-client.js"); + const info = await getProject(org, project); + const id = toNumericId(info.id); + return id ? [id] : undefined; + } catch { + return; + } +} + +/** + * Fetch releases for a list of resolved targets, scoped by project ID. + * + * Each target contributes releases tagged with its org slug. Results are + * merged and truncated to `limit`. + */ +async function fetchReleasesForTargets( + config: OrgListConfig, + targets: ResolvedTarget[], + extra: ExtraApiOptions, + limit: number +): Promise { + const allItems: ReleaseWithOrg[] = []; + for (const t of targets) { + const ids = t.projectId + ? [t.projectId] + : await resolveProjectIds(t.org, t.project); + const { data } = await listReleasesPaginated(t.org, { + perPage: Math.min(limit, 100), + health: true, + project: ids, + ...extra, + }); + for (const release of data) { + allItems.push(config.withOrg(release, t.org)); + } + } + return allItems; +} + /** * Custom auto-detect handler that resolves DSN/config to org+project targets, * then fetches releases scoped to each detected project. @@ -283,6 +343,9 @@ function formatListHuman(result: ListResult): string { * hundreds of projects, the specific project's releases get buried. * This override uses `resolveAllTargets` to get project context from DSN * detection, then passes project IDs to the API for scoped results. + * + * When no `--environment` is given and a single project is detected, + * auto-defaults to the `production` or `prod` environment if it exists. */ async function handleAutoDetectWithProject( config: OrgListConfig, @@ -293,59 +356,39 @@ async function handleAutoDetectWithProject( const resolved = await resolveAllTargets({ cwd }); if (resolved.targets.length === 0) { - // No DSN/config found — fall back to org-wide listing via listForOrg - const { data } = await listReleasesPaginated("", { - perPage: flags.limit, - health: true, - ...extra, - }); return { - items: data.map((r) => config.withOrg(r, "")), + items: [], hint: "No project detected. Specify a target: sentry release list /", }; } - // Deduplicate by org+project - const seen = new Set(); - const unique: ResolvedTarget[] = []; - for (const t of resolved.targets) { - const key = `${t.org}/${t.project}`; - if (!seen.has(key)) { - seen.add(key); - unique.push(t); - } - } - - // Fetch releases scoped to each detected project - const allItems: ReleaseWithOrg[] = []; - const hintParts: string[] = []; + const unique = deduplicateTargets(resolved.targets); - for (const t of unique) { - const projectIds = t.projectId ? [t.projectId] : undefined; - // If we don't have a numeric project ID, try to resolve it - const ids = projectIds ?? (await resolveProjectIds(t.org, t.project)); - const { data } = await listReleasesPaginated(t.org, { - perPage: Math.min(flags.limit, 100), - health: true, - project: ids, - ...extra, - }); - for (const release of data) { - allItems.push(config.withOrg(release, t.org)); - } + // Smart default: auto-select production env when user omitted --environment + const effectiveExtra = { ...extra }; + if (!effectiveExtra.environment && unique.length === 1 && unique[0]) { + effectiveExtra.environment = await resolveDefaultEnvironment( + unique[0].org, + unique[0].project + ); } + const allItems = await fetchReleasesForTargets( + config, + unique, + effectiveExtra, + flags.limit + ); const limited = allItems.slice(0, flags.limit); + const hintParts: string[] = []; if (limited.length === 0) { const projects = unique.map((t) => `${t.org}/${t.project}`).join(", "); hintParts.push(`No releases found for ${projects}.`); } - if (resolved.footer) { hintParts.push(resolved.footer); } - const detectedFrom = unique .filter((t) => t.detectedFrom) .map((t) => `${t.project} (from ${t.detectedFrom})`) @@ -353,36 +396,56 @@ async function handleAutoDetectWithProject( if (detectedFrom) { hintParts.push(`Detected: ${detectedFrom}`); } - + if (effectiveExtra.environment) { + hintParts.push( + `Environment: ${effectiveExtra.environment.join(", ")} (use -e to change)` + ); + } return { items: limited, hint: hintParts.length > 0 ? hintParts.join("\n") : undefined, }; } -/** Resolve a project slug to a numeric ID array for the API query param. */ -async function resolveProjectIds( +// --------------------------------------------------------------------------- +// Flags +// --------------------------------------------------------------------------- + +/** Known production environment names to auto-detect as default. */ +const PRODUCTION_ENV_NAMES = ["production", "prod"]; + +/** + * Resolve environment filter for the API call. + * + * When the user passes `-e`, those values are used directly. + * When no `-e` is given and we have a detected project, check if + * `production` or `prod` exists and default to it — matching the + * Sentry web UI's default behavior of showing production releases. + * + * Returns `undefined` (all environments) if no production env is found. + */ +async function resolveDefaultEnvironment( org: string, project: string -): Promise { +): Promise { try { - const { getProject } = await import("../../lib/api-client.js"); - const info = await getProject(org, project); - const id = toNumericId(info.id); - return id ? [id] : undefined; + const envs = await listProjectEnvironments(org, project); + const names = envs.map((e) => e.name); + for (const candidate of PRODUCTION_ENV_NAMES) { + if (names.includes(candidate)) { + return [candidate]; + } + } } catch { - return; + // Environment listing failed — don't filter } + return; } -// --------------------------------------------------------------------------- -// Flags -// --------------------------------------------------------------------------- - type ListFlags = { readonly limit: number; readonly sort: ReleaseSortValue; - readonly environment?: string; + readonly environment?: readonly string[]; readonly period: string; readonly status: string; readonly json: boolean; @@ -445,7 +508,8 @@ export const listCommand = buildListCommand("release", { environment: { kind: "parsed" as const, parse: String, - brief: "Filter by environment (e.g., production)", + brief: "Filter by environment (repeatable, comma-separated)", + variadic: true as const, optional: true as const, }, period: { @@ -466,9 +530,16 @@ export const listCommand = buildListCommand("release", { async *func(this: SentryContext, flags: ListFlags, target?: string) { const { cwd } = this; const parsed = parseOrgProjectArg(target); + // Flatten: -e prod,dev -e staging → ["prod", "dev", "staging"] + const envFilter = flags.environment + ? [...flags.environment] + .flatMap((v) => v.split(",")) + .map((s) => s.trim()) + .filter(Boolean) + : undefined; const extra: ExtraApiOptions = { sort: flags.sort, - environment: flags.environment ? [flags.environment] : undefined, + environment: envFilter, statsPeriod: flags.period, status: flags.status, }; diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index a37ce4921..6a6287761 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -95,6 +95,7 @@ export { deleteRelease, getRelease, type ListReleasesOptions, + listProjectEnvironments, listReleaseDeploys, listReleasesForProject, listReleasesPaginated, diff --git a/src/lib/api/releases.ts b/src/lib/api/releases.ts index 2321f5ced..3975ae87d 100644 --- a/src/lib/api/releases.ts +++ b/src/lib/api/releases.ts @@ -11,6 +11,7 @@ import { createANewReleaseForAnOrganization, deleteAnOrganization_sRelease, listAnOrganization_sReleases, + listAProject_sEnvironments, listARelease_sDeploys, retrieveAnOrganization_sRelease, updateAnOrganization_sRelease, @@ -529,3 +530,37 @@ export function setCommitsLocal( ): Promise { return updateRelease(orgSlug, version, { commits }); } + +// --------------------------------------------------------------------------- +// Environments +// --------------------------------------------------------------------------- + +/** A visible project environment. */ +export type ProjectEnvironment = { + id: string; + name: string; + isHidden: boolean; +}; + +/** + * List visible environments for a project. + * + * Lightweight call — returns a small array of `{ id, name, isHidden }`. + * Used to auto-detect a production environment for smart defaults. + */ +export async function listProjectEnvironments( + orgSlug: string, + projectSlug: string +): Promise { + const config = await getOrgSdkConfig(orgSlug); + const result = await listAProject_sEnvironments({ + ...config, + path: { + organization_id_or_slug: orgSlug, + project_id_or_slug: projectSlug, + }, + query: { visibility: "visible" }, + }); + const data = unwrapResult(result, "Failed to list environments"); + return data as unknown as ProjectEnvironment[]; +} From 82e0bd28e553b59e70f6e3a32de10fc9454002e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Apr 2026 19:06:36 +0000 Subject: [PATCH 13/14] chore: regenerate skill files and command docs --- docs/src/content/docs/commands/release.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/docs/commands/release.md b/docs/src/content/docs/commands/release.md index 37fcaae04..657c5c763 100644 --- a/docs/src/content/docs/commands/release.md +++ b/docs/src/content/docs/commands/release.md @@ -23,7 +23,7 @@ List releases with adoption and health metrics |--------|-------------| | `-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") | -| `-e, --environment ` | Filter by environment (e.g., production) | +| `-e, --environment ...` | Filter by environment (repeatable, comma-separated) | | `-t, --period ` | Health stats period (e.g., 24h, 7d, 14d, 90d) (default: "90d") | | `--status ` | Filter by status: open (default) or archived (default: "open") | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | From 9521fe18f307c3d2507642e2b3f1052d247a9b29 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 8 Apr 2026 20:19:44 +0000 Subject: [PATCH 14/14] fix(release-list): prefer src/ DSN over docs/ for primary project detection When multiple DSNs are detected (e.g., cli from src/lib/constants.ts and cli-website from docs/sentry.client.config.js), rank targets by source path: src/lib/app/ and .env files get priority 0, root-level configs get priority 1, docs/test/scripts get priority 2. Picks only the top- ranked target for auto-detect, preventing mixed releases from unrelated projects. --- src/commands/release/list.ts | 80 ++++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 26 deletions(-) diff --git a/src/commands/release/list.ts b/src/commands/release/list.ts index 37783ed7e..799d64e3d 100644 --- a/src/commands/release/list.ts +++ b/src/commands/release/list.ts @@ -275,7 +275,32 @@ function formatListHuman(result: ListResult): string { // Auto-detect override: resolve DSN → project-scoped listing // --------------------------------------------------------------------------- -/** Deduplicate resolved targets by org+project key. */ +/** Matches DSN source paths in primary app directories. */ +const PRIMARY_SOURCE_RE = /^(src|lib|app)\//; +/** Matches .env and .env.local files. */ +const ENV_FILE_RE = /\.(env|env\.local)$/; + +/** + * Rank a detected target for primary-project selection. + * + * Lower score = higher priority. Prefers DSNs found in application source + * (`src/`, `lib/`, `app/`) over ancillary paths (`docs/`, `test/`, `scripts/`). + */ +function targetPriority(t: ResolvedTarget): number { + const from = t.detectedFrom?.toLowerCase() ?? ""; + // Primary source directories — most likely the "real" project + if (PRIMARY_SOURCE_RE.test(from) || ENV_FILE_RE.test(from)) { + return 0; + } + // Config files at project root + if (!from.includes("/")) { + return 1; + } + // Everything else (docs/, test/, scripts/, etc.) + return 2; +} + +/** Deduplicate and rank targets, best candidate first. */ function deduplicateTargets(targets: ResolvedTarget[]): ResolvedTarget[] { const seen = new Set(); const result: ResolvedTarget[] = []; @@ -286,7 +311,7 @@ function deduplicateTargets(targets: ResolvedTarget[]): ResolvedTarget[] { result.push(t); } } - return result; + return result.sort((a, b) => targetPriority(a) - targetPriority(b)); } /** Resolve a project slug to a numeric ID array for the API query param. */ @@ -335,17 +360,16 @@ async function fetchReleasesForTargets( } /** - * Custom auto-detect handler that resolves DSN/config to org+project targets, - * then fetches releases scoped to each detected project. + * Custom auto-detect handler that resolves DSN/config to a single project + * target, then fetches releases scoped to that project. * - * The default auto-detect handler only resolves org slugs and calls - * `listForOrg`, which returns ALL releases in the org. Since orgs can have - * hundreds of projects, the specific project's releases get buried. - * This override uses `resolveAllTargets` to get project context from DSN - * detection, then passes project IDs to the API for scoped results. + * Uses only the **first** detected target (primary project). Unlike issue + * list where each issue has a project-prefix short ID, release versions + * are project-ambiguous — mixing releases from multiple detected projects + * produces confusing, unsorted output. * - * When no `--environment` is given and a single project is detected, - * auto-defaults to the `production` or `prod` environment if it exists. + * When no `--environment` is given, auto-defaults to `production` or + * `prod` if that environment exists on the detected project. */ async function handleAutoDetectWithProject( config: OrgListConfig, @@ -354,28 +378,28 @@ async function handleAutoDetectWithProject( ): Promise> { const { cwd, flags } = ctx; const resolved = await resolveAllTargets({ cwd }); + const unique = deduplicateTargets(resolved.targets); + const primary = unique[0]; - if (resolved.targets.length === 0) { + if (!primary) { return { items: [], hint: "No project detected. Specify a target: sentry release list /", }; } - const unique = deduplicateTargets(resolved.targets); - // Smart default: auto-select production env when user omitted --environment const effectiveExtra = { ...extra }; - if (!effectiveExtra.environment && unique.length === 1 && unique[0]) { + if (!effectiveExtra.environment) { effectiveExtra.environment = await resolveDefaultEnvironment( - unique[0].org, - unique[0].project + primary.org, + primary.project ); } const allItems = await fetchReleasesForTargets( config, - unique, + [primary], effectiveExtra, flags.limit ); @@ -383,18 +407,22 @@ async function handleAutoDetectWithProject( const hintParts: string[] = []; if (limited.length === 0) { - const projects = unique.map((t) => `${t.org}/${t.project}`).join(", "); - hintParts.push(`No releases found for ${projects}.`); + hintParts.push(`No releases found for ${primary.org}/${primary.project}.`); } if (resolved.footer) { hintParts.push(resolved.footer); } - const detectedFrom = unique - .filter((t) => t.detectedFrom) - .map((t) => `${t.project} (from ${t.detectedFrom})`) - .join(", "); - if (detectedFrom) { - hintParts.push(`Detected: ${detectedFrom}`); + if (primary.detectedFrom) { + hintParts.push( + `Detected: ${primary.project} (from ${primary.detectedFrom})` + ); + } + if (unique.length > 1) { + const others = unique + .slice(1) + .map((t) => t.project) + .join(", "); + hintParts.push(`Also detected: ${others} (use / to switch)`); } if (effectiveExtra.environment) { hintParts.push(