Skip to content

Commit dfaf768

Browse files
committed
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
1 parent 71591f1 commit dfaf768

4 files changed

Lines changed: 160 additions & 53 deletions

File tree

plugins/sentry-cli/skills/sentry-cli/references/release.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ List releases with adoption and health metrics
1818
**Flags:**
1919
- `-n, --limit <value> - Maximum number of releases to list - (default: "25")`
2020
- `-s, --sort <value> - Sort: date, sessions, users, crash_free_sessions (cfs), crash_free_users (cfu) - (default: "date")`
21-
- `-e, --environment <value> - Filter by environment (e.g., production)`
21+
- `-e, --environment <value>... - Filter by environment (repeatable, comma-separated)`
2222
- `-t, --period <value> - Health stats period (e.g., 24h, 7d, 14d, 90d) - (default: "90d")`
2323
- `--status <value> - Filter by status: open (default) or archived - (default: "open")`
2424
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`

src/commands/release/list.ts

Lines changed: 123 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { OrgReleaseResponse } from "@sentry/api";
99
import type { SentryContext } from "../../context.js";
1010
import {
1111
type ListReleasesOptions,
12+
listProjectEnvironments,
1213
listReleasesForProject,
1314
listReleasesPaginated,
1415
type ReleaseSortValue,
@@ -274,6 +275,65 @@ function formatListHuman(result: ListResult<ReleaseWithOrg>): string {
274275
// Auto-detect override: resolve DSN → project-scoped listing
275276
// ---------------------------------------------------------------------------
276277

278+
/** Deduplicate resolved targets by org+project key. */
279+
function deduplicateTargets(targets: ResolvedTarget[]): ResolvedTarget[] {
280+
const seen = new Set<string>();
281+
const result: ResolvedTarget[] = [];
282+
for (const t of targets) {
283+
const key = `${t.org}/${t.project}`;
284+
if (!seen.has(key)) {
285+
seen.add(key);
286+
result.push(t);
287+
}
288+
}
289+
return result;
290+
}
291+
292+
/** Resolve a project slug to a numeric ID array for the API query param. */
293+
async function resolveProjectIds(
294+
org: string,
295+
project: string
296+
): Promise<number[] | undefined> {
297+
try {
298+
const { getProject } = await import("../../lib/api-client.js");
299+
const info = await getProject(org, project);
300+
const id = toNumericId(info.id);
301+
return id ? [id] : undefined;
302+
} catch {
303+
return;
304+
}
305+
}
306+
307+
/**
308+
* Fetch releases for a list of resolved targets, scoped by project ID.
309+
*
310+
* Each target contributes releases tagged with its org slug. Results are
311+
* merged and truncated to `limit`.
312+
*/
313+
async function fetchReleasesForTargets(
314+
config: OrgListConfig<OrgReleaseResponse, ReleaseWithOrg>,
315+
targets: ResolvedTarget[],
316+
extra: ExtraApiOptions,
317+
limit: number
318+
): Promise<ReleaseWithOrg[]> {
319+
const allItems: ReleaseWithOrg[] = [];
320+
for (const t of targets) {
321+
const ids = t.projectId
322+
? [t.projectId]
323+
: await resolveProjectIds(t.org, t.project);
324+
const { data } = await listReleasesPaginated(t.org, {
325+
perPage: Math.min(limit, 100),
326+
health: true,
327+
project: ids,
328+
...extra,
329+
});
330+
for (const release of data) {
331+
allItems.push(config.withOrg(release, t.org));
332+
}
333+
}
334+
return allItems;
335+
}
336+
277337
/**
278338
* Custom auto-detect handler that resolves DSN/config to org+project targets,
279339
* then fetches releases scoped to each detected project.
@@ -283,6 +343,9 @@ function formatListHuman(result: ListResult<ReleaseWithOrg>): string {
283343
* hundreds of projects, the specific project's releases get buried.
284344
* This override uses `resolveAllTargets` to get project context from DSN
285345
* detection, then passes project IDs to the API for scoped results.
346+
*
347+
* When no `--environment` is given and a single project is detected,
348+
* auto-defaults to the `production` or `prod` environment if it exists.
286349
*/
287350
async function handleAutoDetectWithProject(
288351
config: OrgListConfig<OrgReleaseResponse, ReleaseWithOrg>,
@@ -293,96 +356,96 @@ async function handleAutoDetectWithProject(
293356
const resolved = await resolveAllTargets({ cwd });
294357

295358
if (resolved.targets.length === 0) {
296-
// No DSN/config found — fall back to org-wide listing via listForOrg
297-
const { data } = await listReleasesPaginated("", {
298-
perPage: flags.limit,
299-
health: true,
300-
...extra,
301-
});
302359
return {
303-
items: data.map((r) => config.withOrg(r, "")),
360+
items: [],
304361
hint: "No project detected. Specify a target: sentry release list <org>/<project>",
305362
};
306363
}
307364

308-
// Deduplicate by org+project
309-
const seen = new Set<string>();
310-
const unique: ResolvedTarget[] = [];
311-
for (const t of resolved.targets) {
312-
const key = `${t.org}/${t.project}`;
313-
if (!seen.has(key)) {
314-
seen.add(key);
315-
unique.push(t);
316-
}
317-
}
318-
319-
// Fetch releases scoped to each detected project
320-
const allItems: ReleaseWithOrg[] = [];
321-
const hintParts: string[] = [];
365+
const unique = deduplicateTargets(resolved.targets);
322366

323-
for (const t of unique) {
324-
const projectIds = t.projectId ? [t.projectId] : undefined;
325-
// If we don't have a numeric project ID, try to resolve it
326-
const ids = projectIds ?? (await resolveProjectIds(t.org, t.project));
327-
const { data } = await listReleasesPaginated(t.org, {
328-
perPage: Math.min(flags.limit, 100),
329-
health: true,
330-
project: ids,
331-
...extra,
332-
});
333-
for (const release of data) {
334-
allItems.push(config.withOrg(release, t.org));
335-
}
367+
// Smart default: auto-select production env when user omitted --environment
368+
const effectiveExtra = { ...extra };
369+
if (!effectiveExtra.environment && unique.length === 1 && unique[0]) {
370+
effectiveExtra.environment = await resolveDefaultEnvironment(
371+
unique[0].org,
372+
unique[0].project
373+
);
336374
}
337375

376+
const allItems = await fetchReleasesForTargets(
377+
config,
378+
unique,
379+
effectiveExtra,
380+
flags.limit
381+
);
338382
const limited = allItems.slice(0, flags.limit);
339383

384+
const hintParts: string[] = [];
340385
if (limited.length === 0) {
341386
const projects = unique.map((t) => `${t.org}/${t.project}`).join(", ");
342387
hintParts.push(`No releases found for ${projects}.`);
343388
}
344-
345389
if (resolved.footer) {
346390
hintParts.push(resolved.footer);
347391
}
348-
349392
const detectedFrom = unique
350393
.filter((t) => t.detectedFrom)
351394
.map((t) => `${t.project} (from ${t.detectedFrom})`)
352395
.join(", ");
353396
if (detectedFrom) {
354397
hintParts.push(`Detected: ${detectedFrom}`);
355398
}
356-
399+
if (effectiveExtra.environment) {
400+
hintParts.push(
401+
`Environment: ${effectiveExtra.environment.join(", ")} (use -e to change)`
402+
);
403+
}
357404
return {
358405
items: limited,
359406
hint: hintParts.length > 0 ? hintParts.join("\n") : undefined,
360407
};
361408
}
362409

363-
/** Resolve a project slug to a numeric ID array for the API query param. */
364-
async function resolveProjectIds(
410+
// ---------------------------------------------------------------------------
411+
// Flags
412+
// ---------------------------------------------------------------------------
413+
414+
/** Known production environment names to auto-detect as default. */
415+
const PRODUCTION_ENV_NAMES = ["production", "prod"];
416+
417+
/**
418+
* Resolve environment filter for the API call.
419+
*
420+
* When the user passes `-e`, those values are used directly.
421+
* When no `-e` is given and we have a detected project, check if
422+
* `production` or `prod` exists and default to it — matching the
423+
* Sentry web UI's default behavior of showing production releases.
424+
*
425+
* Returns `undefined` (all environments) if no production env is found.
426+
*/
427+
async function resolveDefaultEnvironment(
365428
org: string,
366429
project: string
367-
): Promise<number[] | undefined> {
430+
): Promise<string[] | undefined> {
368431
try {
369-
const { getProject } = await import("../../lib/api-client.js");
370-
const info = await getProject(org, project);
371-
const id = toNumericId(info.id);
372-
return id ? [id] : undefined;
432+
const envs = await listProjectEnvironments(org, project);
433+
const names = envs.map((e) => e.name);
434+
for (const candidate of PRODUCTION_ENV_NAMES) {
435+
if (names.includes(candidate)) {
436+
return [candidate];
437+
}
438+
}
373439
} catch {
374-
return;
440+
// Environment listing failed — don't filter
375441
}
442+
return;
376443
}
377444

378-
// ---------------------------------------------------------------------------
379-
// Flags
380-
// ---------------------------------------------------------------------------
381-
382445
type ListFlags = {
383446
readonly limit: number;
384447
readonly sort: ReleaseSortValue;
385-
readonly environment?: string;
448+
readonly environment?: readonly string[];
386449
readonly period: string;
387450
readonly status: string;
388451
readonly json: boolean;
@@ -445,7 +508,8 @@ export const listCommand = buildListCommand("release", {
445508
environment: {
446509
kind: "parsed" as const,
447510
parse: String,
448-
brief: "Filter by environment (e.g., production)",
511+
brief: "Filter by environment (repeatable, comma-separated)",
512+
variadic: true as const,
449513
optional: true as const,
450514
},
451515
period: {
@@ -466,9 +530,16 @@ export const listCommand = buildListCommand("release", {
466530
async *func(this: SentryContext, flags: ListFlags, target?: string) {
467531
const { cwd } = this;
468532
const parsed = parseOrgProjectArg(target);
533+
// Flatten: -e prod,dev -e staging → ["prod", "dev", "staging"]
534+
const envFilter = flags.environment
535+
? [...flags.environment]
536+
.flatMap((v) => v.split(","))
537+
.map((s) => s.trim())
538+
.filter(Boolean)
539+
: undefined;
469540
const extra: ExtraApiOptions = {
470541
sort: flags.sort,
471-
environment: flags.environment ? [flags.environment] : undefined,
542+
environment: envFilter,
472543
statsPeriod: flags.period,
473544
status: flags.status,
474545
};

src/lib/api-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export {
9595
deleteRelease,
9696
getRelease,
9797
type ListReleasesOptions,
98+
listProjectEnvironments,
9899
listReleaseDeploys,
99100
listReleasesForProject,
100101
listReleasesPaginated,

src/lib/api/releases.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createANewReleaseForAnOrganization,
1212
deleteAnOrganization_sRelease,
1313
listAnOrganization_sReleases,
14+
listAProject_sEnvironments,
1415
listARelease_sDeploys,
1516
retrieveAnOrganization_sRelease,
1617
updateAnOrganization_sRelease,
@@ -529,3 +530,37 @@ export function setCommitsLocal(
529530
): Promise<OrgReleaseResponse> {
530531
return updateRelease(orgSlug, version, { commits });
531532
}
533+
534+
// ---------------------------------------------------------------------------
535+
// Environments
536+
// ---------------------------------------------------------------------------
537+
538+
/** A visible project environment. */
539+
export type ProjectEnvironment = {
540+
id: string;
541+
name: string;
542+
isHidden: boolean;
543+
};
544+
545+
/**
546+
* List visible environments for a project.
547+
*
548+
* Lightweight call — returns a small array of `{ id, name, isHidden }`.
549+
* Used to auto-detect a production environment for smart defaults.
550+
*/
551+
export async function listProjectEnvironments(
552+
orgSlug: string,
553+
projectSlug: string
554+
): Promise<ProjectEnvironment[]> {
555+
const config = await getOrgSdkConfig(orgSlug);
556+
const result = await listAProject_sEnvironments({
557+
...config,
558+
path: {
559+
organization_id_or_slug: orgSlug,
560+
project_id_or_slug: projectSlug,
561+
},
562+
query: { visibility: "visible" },
563+
});
564+
const data = unwrapResult(result, "Failed to list environments");
565+
return data as unknown as ProjectEnvironment[];
566+
}

0 commit comments

Comments
 (0)