@@ -10,24 +10,20 @@ import { buildOrgAwareAliases } from "../../lib/alias.js";
1010import {
1111 API_MAX_PER_PAGE ,
1212 buildIssueListCollapse ,
13- findProjectsByPattern ,
14- findProjectsBySlug ,
15- getProject ,
1613 type IssueCollapseField ,
1714 type IssuesPage ,
1815 listIssuesAllPages ,
1916 listIssuesPaginated ,
20- listProjects ,
2117} from "../../lib/api-client.js" ;
22- import {
23- looksLikeIssueShortId ,
24- parseOrgProjectArg ,
25- } from "../../lib/arg-parsing.js" ;
18+ import { parseOrgProjectArg } from "../../lib/arg-parsing.js" ;
2619import { getActiveEnvVarName , isEnvTokenActive } from "../../lib/db/auth.js" ;
2720import {
2821 advancePaginationState ,
22+ buildMultiTargetContextKey ,
2923 buildPaginationContextKey ,
30- escapeContextKeyValue ,
24+ CURSOR_SEP ,
25+ decodeCompoundCursor ,
26+ encodeCompoundCursor ,
3127 hasPreviousPage ,
3228 resolveCursor ,
3329} from "../../lib/db/pagination.js" ;
@@ -39,7 +35,6 @@ import { createDsnFingerprint } from "../../lib/dsn/index.js";
3935import {
4036 ApiError ,
4137 ContextError ,
42- ResolutionError ,
4338 ValidationError ,
4439 withAuthGuard ,
4540} from "../../lib/errors.js" ;
@@ -72,13 +67,9 @@ import {
7267} from "../../lib/org-list.js" ;
7368import { withProgress } from "../../lib/polling.js" ;
7469import {
75- fetchProjectId ,
7670 type ResolvedTarget ,
77- resolveAllTargets ,
78- toNumericId ,
71+ resolveTargetsFromParsedArg ,
7972} from "../../lib/resolve-target.js" ;
80- import { getApiBaseUrl } from "../../lib/sentry-client.js" ;
81- import { setOrgProjectContext } from "../../lib/telemetry.js" ;
8273import type {
8374 ProjectAliasEntry ,
8475 SentryIssue ,
@@ -346,191 +337,6 @@ type FetchResult =
346337 | { success : true ; data : IssueListFetchResult }
347338 | { success : false ; error : Error } ;
348339
349- /** Result of resolving targets from parsed argument */
350- type TargetResolutionResult = {
351- targets : ResolvedTarget [ ] ;
352- footer ?: string ;
353- skippedSelfHosted ?: number ;
354- detectedDsns ?: import ( "../../lib/dsn/index.js" ) . DetectedDsn [ ] ;
355- } ;
356-
357- /**
358- * Resolve targets based on parsed org/project argument.
359- *
360- * Handles all four cases:
361- * - auto-detect: Use DSN detection / config defaults
362- * - explicit: Single org/project target
363- * - org-all: All projects in specified org
364- * - project-search: Find project across all orgs
365- */
366- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: inherent multi-mode target resolution with per-mode error handling
367- async function resolveTargetsFromParsedArg (
368- parsed : ReturnType < typeof parseOrgProjectArg > ,
369- cwd : string
370- ) : Promise < TargetResolutionResult > {
371- switch ( parsed . type ) {
372- case "auto-detect" : {
373- // Use existing resolution logic (DSN detection, config defaults)
374- const result = await resolveAllTargets ( { cwd, usageHint : USAGE_HINT } ) ;
375- // DSN-detected and directory-inferred targets already carry a projectId.
376- // Env var / config-default paths return targets without one, so enrich
377- // them now using the project API. Any failure silently falls back to
378- // slug-based querying — the target was already resolved, so we never
379- // surface a ResolutionError here (that's only for the explicit case).
380- result . targets = await Promise . all (
381- result . targets . map ( async ( t ) => {
382- if ( t . projectId !== undefined ) {
383- return t ;
384- }
385- try {
386- const info = await getProject ( t . org , t . project ) ;
387- const id = toNumericId ( info . id ) ;
388- return id !== undefined ? { ...t , projectId : id } : t ;
389- } catch {
390- return t ;
391- }
392- } )
393- ) ;
394- return result ;
395- }
396-
397- case "explicit" : {
398- // Single explicit target — fetch project ID for API query param
399- // Telemetry context is set by dispatchOrgScopedList before this handler runs.
400- const projectId = await fetchProjectId ( parsed . org , parsed . project ) ;
401- return {
402- targets : [
403- {
404- org : parsed . org ,
405- project : parsed . project ,
406- projectId,
407- orgDisplay : parsed . org ,
408- projectDisplay : parsed . project ,
409- } ,
410- ] ,
411- } ;
412- }
413-
414- case "org-all" : {
415- // List all projects in the specified org
416- // Telemetry context is set by dispatchOrgScopedList before this handler runs.
417- const projects = await listProjects ( parsed . org ) ;
418- const targets : ResolvedTarget [ ] = projects . map ( ( p ) => ( {
419- org : parsed . org ,
420- project : p . slug ,
421- projectId : toNumericId ( p . id ) ,
422- orgDisplay : parsed . org ,
423- projectDisplay : p . name ,
424- } ) ) ;
425-
426- if ( targets . length === 0 ) {
427- throw new ResolutionError (
428- `Organization '${ parsed . org } '` ,
429- "has no accessible projects" ,
430- `sentry project list ${ parsed . org } /` ,
431- [ "Check that you have access to projects in this organization" ]
432- ) ;
433- }
434-
435- return {
436- targets,
437- footer :
438- targets . length > 1
439- ? `Showing issues from ${ targets . length } projects in ${ parsed . org } `
440- : undefined ,
441- } ;
442- }
443-
444- case "project-search" : {
445- // Detect when user passes an issue short ID instead of a project slug.
446- // Short IDs like "CONVERSATION-SVC-F" or "CLI-BM" are all-uppercase
447- // with a dash-separated suffix — a pattern that never occurs in project
448- // slugs (which are always lowercase).
449- if ( looksLikeIssueShortId ( parsed . projectSlug ) ) {
450- throw new ResolutionError (
451- `'${ parsed . projectSlug } '` ,
452- "looks like an issue short ID, not a project slug" ,
453- `sentry issue view ${ parsed . projectSlug } ` ,
454- [ "To list issues in a project: sentry issue list <org>/<project>" ]
455- ) ;
456- }
457-
458- // Find project across all orgs
459- const { projects : matches , orgs } = await findProjectsBySlug (
460- parsed . projectSlug
461- ) ;
462-
463- if ( matches . length === 0 ) {
464- // Check if the slug matches an organization — common mistake.
465- // The orgSlugMatchBehavior: "redirect" pre-check handles this for
466- // cached orgs (hot path). This is the cold-cache fallback: org
467- // isn't cached yet, so the pre-check couldn't fire. We throw a
468- // ResolutionError with a hint — after this command, the org will
469- // be cached and future runs will auto-redirect.
470- const isOrg = orgs . some ( ( o ) => o . slug === parsed . projectSlug ) ;
471- if ( isOrg ) {
472- throw new ResolutionError (
473- `'${ parsed . projectSlug } '` ,
474- "is an organization, not a project" ,
475- `sentry issue list ${ parsed . projectSlug } /` ,
476- [
477- `List projects: sentry project list ${ parsed . projectSlug } /` ,
478- `Specify a project: sentry issue list ${ parsed . projectSlug } /<project>` ,
479- ]
480- ) ;
481- }
482-
483- // Try word-boundary matching to suggest similar projects (CLI-A4, 16 users).
484- // Uses the same findProjectsByPattern used by directory name inference.
485- // Only runs on the error path, so the extra API cost is acceptable.
486- const similar = await findProjectsByPattern ( parsed . projectSlug ) ;
487- const suggestions : string [ ] = [ ] ;
488- if ( similar . length > 0 ) {
489- const names = similar
490- . slice ( 0 , 3 )
491- . map ( ( p ) => `'${ p . orgSlug } /${ p . slug } '` ) ;
492- suggestions . push ( `Similar projects: ${ names . join ( ", " ) } ` ) ;
493- }
494- suggestions . push (
495- "No project with this slug found in any accessible organization"
496- ) ;
497- throw new ResolutionError (
498- `Project '${ parsed . projectSlug } '` ,
499- "not found" ,
500- "sentry project list" ,
501- suggestions
502- ) ;
503- }
504-
505- const targets : ResolvedTarget [ ] = matches . map ( ( m ) => ( {
506- org : m . orgSlug ,
507- project : m . slug ,
508- projectId : toNumericId ( m . id ) ,
509- orgDisplay : m . orgSlug ,
510- projectDisplay : m . name ,
511- } ) ) ;
512-
513- const uniqueOrgs = [ ...new Set ( targets . map ( ( t ) => t . org ) ) ] ;
514- const uniqueProjects = [ ...new Set ( targets . map ( ( t ) => t . project ) ) ] ;
515- setOrgProjectContext ( uniqueOrgs , uniqueProjects ) ;
516-
517- return {
518- targets,
519- footer :
520- matches . length > 1
521- ? `Found '${ parsed . projectSlug } ' in ${ matches . length } organizations`
522- : undefined ,
523- } ;
524- }
525-
526- default : {
527- // TypeScript exhaustiveness check - this should never be reached
528- const _exhaustiveCheck : never = parsed ;
529- throw new Error ( `Unexpected parsed type: ${ _exhaustiveCheck } ` ) ;
530- }
531- }
532- }
533-
534340/**
535341 * Fetch issues for a single target project.
536342 *
@@ -768,65 +574,6 @@ function trimWithProjectGuarantee(
768574 return issues . filter ( ( _ , i ) => selected . has ( i ) ) ;
769575}
770576
771- /** Separator for compound cursor entries (pipe — not present in Sentry cursors). */
772- const CURSOR_SEP = "|" ;
773-
774- /**
775- * Encode per-target cursors as a pipe-separated string for storage.
776- *
777- * The position of each entry matches the **sorted** target order encoded in
778- * the context key fingerprint, so we only need to store the cursor values —
779- * no org/project metadata is needed in the cursor string itself.
780- *
781- * Empty string = project exhausted (no more pages).
782- *
783- * @example "1735689600:0:0||1735689601:0:0" — 3 targets, middle one exhausted
784- */
785- function encodeCompoundCursor ( cursors : ( string | null ) [ ] ) : string {
786- return cursors . map ( ( c ) => c ?? "" ) . join ( CURSOR_SEP ) ;
787- }
788-
789- /**
790- * Decode a compound cursor string back to an array of per-target cursors.
791- *
792- * Returns `null` for exhausted entries (empty segments) and `string` for active
793- * cursors. Returns an empty array if `raw` is empty or looks like a legacy
794- * JSON cursor (starts with `[`), causing a fresh start.
795- */
796- function decodeCompoundCursor ( raw : string ) : ( string | null ) [ ] {
797- // Guard against legacy JSON compound cursors or corrupted data
798- if ( ! raw || raw . startsWith ( "[" ) ) {
799- return [ ] ;
800- }
801- return raw . split ( CURSOR_SEP ) . map ( ( s ) => ( s === "" ? null : s ) ) ;
802- }
803-
804- /**
805- * Build a compound cursor context key that encodes the full target set, sort,
806- * query, and period so that a cursor from one search is never reused for a
807- * different search.
808- */
809- function buildMultiTargetContextKey (
810- targets : ResolvedTarget [ ] ,
811- flags : Pick < ListFlags , "sort" | "query" | "period" >
812- ) : string {
813- const host = getApiBaseUrl ( ) ;
814- const targetFingerprint = targets
815- . map ( ( t ) => `${ t . org } /${ t . project } ` )
816- . sort ( )
817- . join ( "," ) ;
818- const escapedQuery = flags . query
819- ? escapeContextKeyValue ( flags . query )
820- : undefined ;
821- const escapedPeriod = escapeContextKeyValue ( flags . period ?? "90d" ) ;
822- const escapedSort = escapeContextKeyValue ( flags . sort ) ;
823- return (
824- `host:${ host } |type:multi:${ targetFingerprint } ` +
825- `|sort:${ escapedSort } |period:${ escapedPeriod } ` +
826- ( escapedQuery ? `|q:${ escapedQuery } ` : "" )
827- ) ;
828- }
829-
830577/** Build the CLI hint for fetching the next page, preserving active flags. */
831578/** Append active non-default issue list flags to a base command string. */
832579function appendIssueFlags ( base : string , flags : ListFlags ) : string {
@@ -1140,7 +887,12 @@ async function handleResolvedTargets(
1140887 const { parsed, flags, cwd } = options ;
1141888
1142889 const { targets, footer, skippedSelfHosted, detectedDsns } =
1143- await resolveTargetsFromParsedArg ( parsed , cwd ) ;
890+ await resolveTargetsFromParsedArg ( parsed , {
891+ cwd,
892+ usageHint : USAGE_HINT ,
893+ enrichProjectIds : true ,
894+ checkIssueShortId : true ,
895+ } ) ;
1144896
1145897 if ( targets . length === 0 ) {
1146898 if ( skippedSelfHosted ) {
@@ -1154,7 +906,11 @@ async function handleResolvedTargets(
1154906
1155907 // Build a compound cursor context key that encodes the full target set +
1156908 // search parameters so a cursor from one search is never reused for another.
1157- const contextKey = buildMultiTargetContextKey ( targets , flags ) ;
909+ const contextKey = buildMultiTargetContextKey ( targets , {
910+ sort : flags . sort ,
911+ query : flags . query ,
912+ period : flags . period ,
913+ } ) ;
1158914
1159915 // Resolve per-target start cursors from the stored compound cursor (--cursor resume).
1160916 // Sorted target keys must match the order used in buildMultiTargetContextKey.
0 commit comments