@@ -9,6 +9,7 @@ import type { OrgReleaseResponse } from "@sentry/api";
99import type { SentryContext } from "../../context.js" ;
1010import {
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 */
287350async 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-
382445type 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 } ;
0 commit comments