From b7d99fb4c66cd3248b48b6d915fcb14da8f0dcc1 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:44:57 +0000 Subject: [PATCH 1/8] fix(arg-parsing): allow project display names with spaces in org/project argument --- src/lib/arg-parsing.ts | 18 ++++++++++++++++++ src/lib/resolve-target.ts | 9 ++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 588de7738..a0e6c9273 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -369,6 +369,12 @@ export type ParsedOrgProject = projectSlug: string; /** True if project slug was normalized */ normalized?: boolean; + /** + * Organization slug to scope the search to, when the caller provided + * one (e.g. "org/My Project"). When unset the search spans all + * accessible organizations. + */ + org?: string; /** * Pre-normalization input when {@link normalized} is `true`. * Used by the resolution layer to produce user-friendly messages @@ -536,6 +542,18 @@ function parseSlashOrgProject(input: string): ParsedOrgProject { // "sentry/cli" → explicit org and project rejectAtSelector(rawProject, "project slug"); + if (looksLikeDisplayName(rawProject)) { + // Spaces → display name, not a slug. Skip slug validation and let the + // resolution layer do a fuzzy name-based search (mirrors the bare-slug + // and leading-slash paths). Prevents a hard ValidationError when callers + // pass a project display name in "org/project" form (CLI-1RA). + return { + type: "project-search", + projectSlug: rawProject, + originalSlug: rawProject, + org: no.slug, + }; + } const np = normalizeSlug(rawProject); validateResourceId(np.slug, "project slug"); const normalized = no.normalized || np.normalized; diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 9f1b97b0e..300ce6d41 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -1640,10 +1640,17 @@ export async function resolveOrgProjectTarget( case "project-search": { const displaySlug = parsed.originalSlug ?? parsed.projectSlug; const isDisplayName = parsed.originalSlug !== undefined; - const { projects, orgs } = isDisplayName + const { projects, orgs: foundOrgs } = isDisplayName ? { projects: [], orgs: await listOrganizations() } : await findProjectsBySlug(parsed.projectSlug); + // When the caller provided an org (e.g. "org/My Project"), scope the + // display-name search to that org instead of all accessible orgs. + const orgs = + isDisplayName && parsed.org !== undefined + ? foundOrgs.filter((o) => o.slug === parsed.org) + : foundOrgs; + if (projects.length === 0) { const outcome = await triageProjectNotFound( parsed.projectSlug, From fbeb61aa703a5ee19fbdbd82da9cce1eaaf9dc4b Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Mon, 22 Jun 2026 01:54:08 +0000 Subject: [PATCH 2/8] fix: update tests and resolveTargetsFromParsedArg for display name support - Update 3 failing tests that still expected ValidationError for org/project args with spaces in the project segment - Fix resolveTargetsFromParsedArg to handle display names: skip findProjectsBySlug when originalSlug is set, use triageProjectNotFound for fuzzy matching, and scope org search when parsed.org is present (mirrors the pattern in resolveOrgProjectTarget and handleProjectSearch) --- src/lib/resolve-target.ts | 63 +++++++++++++++++++++++++----------- test/lib/arg-parsing.test.ts | 35 ++++++++++++-------- 2 files changed, 66 insertions(+), 32 deletions(-) diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 300ce6d41..e6d5f17d0 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -1879,15 +1879,29 @@ export async function resolveTargetsFromParsedArg( ); } - const { projects: matches, orgs } = await findProjectsBySlug( - parsed.projectSlug - ); + const displaySlug = parsed.originalSlug ?? parsed.projectSlug; + // When the input is a display name (originalSlug set, contains spaces), + // skip the slug-based API lookup and go straight to fuzzy matching. + const isDisplayName = parsed.originalSlug !== undefined; + const { projects: matches, orgs: foundOrgs } = isDisplayName + ? { projects: [], orgs: await listOrganizations() } + : await findProjectsBySlug(parsed.projectSlug); + + // When the caller provided an org (e.g. "org/My Project"), scope the + // search to that org instead of all accessible orgs. + const orgs = + isDisplayName && parsed.org !== undefined + ? foundOrgs.filter((o) => o.slug === parsed.org) + : foundOrgs; if (matches.length === 0) { - const isOrg = orgs.some((o) => o.slug === parsed.projectSlug); - if (isOrg) { - // Derive the base command from the usage hint (strip trailing placeholder). - // e.g. "sentry issue list /" → "sentry issue list" + const outcome = await triageProjectNotFound( + parsed.projectSlug, + orgs, + parsed.originalSlug + ); + + if (outcome.kind === "org-match") { const prefix = usageHint.split(" <")[0]; throw new ResolutionError( `'${parsed.projectSlug}'`, @@ -1900,19 +1914,32 @@ export async function resolveTargetsFromParsedArg( ); } - const similar = await findProjectsByPattern(parsed.projectSlug); - const suggestions: string[] = []; - if (similar.length > 0) { - const names = similar - .slice(0, 3) - .map((p) => `'${p.orgSlug}/${p.slug}'`); - suggestions.push(`Similar projects: ${names.join(", ")}`); + if (outcome.kind === "fuzzy-match") { + const projectId = await fetchProjectId( + outcome.org, + outcome.project + ); + const targets: ResolvedTarget[] = [ + { + org: outcome.org, + project: outcome.project, + projectId, + orgDisplay: outcome.org, + projectDisplay: outcome.project, + }, + ]; + setOrgProjectContext([outcome.org], [outcome.project]); + return { targets }; } - suggestions.push( - "No project with this slug found in any accessible organization" - ); + + const suggestions = + outcome.suggestions.length > 0 + ? outcome.suggestions + : [ + "No project with this slug found in any accessible organization", + ]; throw new ResolutionError( - `Project '${parsed.projectSlug}'`, + `Project '${displaySlug}'`, "not found", "sentry project list", suggestions diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 2fc032976..c16dc0b4c 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -216,21 +216,26 @@ describe("parseOrgProjectArg", () => { expect(() => parseOrgProjectArg("My Org/cli")).toThrow(ValidationError); }); - test("project with spaces in explicit mode throws ValidationError", () => { - expect(() => parseOrgProjectArg("sentry/My Project")).toThrow( - ValidationError - ); + test("project with spaces in explicit mode produces project-search with org", () => { + expect(parseOrgProjectArg("sentry/My Project")).toEqual({ + type: "project-search", + projectSlug: "My Project", + originalSlug: "My Project", + org: "sentry", + }); }); test("org with spaces in org-all mode throws ValidationError", () => { expect(() => parseOrgProjectArg("My Org/")).toThrow(ValidationError); }); - test("org with underscores and project with spaces throws ValidationError", () => { - // Spaces in the project part of explicit mode hit validateResourceId. - expect(() => parseOrgProjectArg("my_org/My Project")).toThrow( - ValidationError - ); + test("org with underscores and project with spaces produces project-search with org", () => { + expect(parseOrgProjectArg("my_org/My Project")).toEqual({ + type: "project-search", + projectSlug: "My Project", + originalSlug: "My Project", + org: "my_org", + }); }); test("does not throw for auto-detect", () => { @@ -1128,11 +1133,13 @@ describe("parseOrgProjectArg space handling (no normalization)", () => { }); }); - test("underscores with spaces in explicit mode throws ValidationError", () => { - // Spaces in the project part of explicit mode hit validateResourceId. - expect(() => parseOrgProjectArg("my_org/My Project")).toThrow( - ValidationError - ); + test("underscores with spaces in explicit mode produces project-search with org", () => { + expect(parseOrgProjectArg("my_org/My Project")).toEqual({ + type: "project-search", + projectSlug: "My Project", + originalSlug: "My Project", + org: "my_org", + }); }); }); From 5854391226e0ccd3c7538c61589575a37a414107 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Mon, 22 Jun 2026 08:32:17 +0000 Subject: [PATCH 3/8] fix: biome formatting in resolve-target.ts --- src/lib/resolve-target.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index e6d5f17d0..8d9b17fea 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -1915,10 +1915,7 @@ export async function resolveTargetsFromParsedArg( } if (outcome.kind === "fuzzy-match") { - const projectId = await fetchProjectId( - outcome.org, - outcome.project - ); + const projectId = await fetchProjectId(outcome.org, outcome.project); const targets: ResolvedTarget[] = [ { org: outcome.org, From 54981ae257977f7793be3bf8926f19e191e44d3f Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Mon, 22 Jun 2026 09:48:47 +0000 Subject: [PATCH 4/8] fix: scope handleProjectSearch org filtering for display names Both handleProjectSearch implementations (org-list.ts and project/list.ts) now accept and use the parsed org to scope display-name searches, matching the pattern already applied in resolveTargetsFromParsedArg and resolveOrgProjectTarget. --- src/commands/project/list.ts | 18 +++++++++++++++--- src/lib/org-list.ts | 14 ++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 5f03bfb33..449622c59 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -587,12 +587,14 @@ export async function handleProjectSearch( /** Original user input before normalization — for clearer messages. */ originalSlug?: string, /** @internal — prevents infinite recursion from fuzzy recovery. */ - _isRecoveryAttempt = false + _isRecoveryAttempt = false, + /** Organization slug to scope the search to (e.g. from "org/My Project"). */ + scopedOrg?: string ): Promise> { // When the input is a display name (originalSlug set, contains spaces), // skip the slug-based API lookup and go straight to name-based matching. const isDisplayName = originalSlug !== undefined; - const { projects, orgs } = isDisplayName + const { projects, orgs: foundOrgs } = isDisplayName ? { projects: [], orgs: await listOrganizations() } : await withProgress( { @@ -601,6 +603,14 @@ export async function handleProjectSearch( }, () => findProjectsBySlug(projectSlug) ); + + // When the caller provided an org (e.g. "org/My Project"), scope the + // search to that org instead of all accessible orgs. + const orgs = + isDisplayName && scopedOrg !== undefined + ? foundOrgs.filter((o) => o.slug === scopedOrg) + : foundOrgs; + const filtered = filterByPlatform(projects, flags.platform); if (filtered.length === 0) { @@ -734,7 +744,9 @@ export const listCommand = buildListCommand("project", { handleProjectSearch( ctx.parsed.projectSlug, flags, - ctx.parsed.originalSlug + ctx.parsed.originalSlug, + false, + ctx.parsed.org ), }, }); diff --git a/src/lib/org-list.ts b/src/lib/org-list.ts index 088bc80c5..b60011e4f 100644 --- a/src/lib/org-list.ts +++ b/src/lib/org-list.ts @@ -773,17 +773,19 @@ export async function handleProjectSearch( orgAllFallback?: (orgSlug: string) => Promise>; /** Original user input before normalization — for clearer messages. */ originalSlug?: string; + /** Organization slug to scope the search to (e.g. from "org/My Project"). */ + org?: string; }, /** Guard against infinite recursion from fuzzy recovery. */ _isRecoveryAttempt = false ): Promise> { - const { flags, orgAllFallback, originalSlug } = options; + const { flags, orgAllFallback, originalSlug, org: scopedOrg } = options; /** Display label: the user's raw input when available, otherwise the slug. */ const displaySlug = originalSlug ?? projectSlug; // When the input is a display name (originalSlug set, contains spaces), // skip the slug-based API lookup and go straight to name-based matching. const isDisplayName = originalSlug !== undefined; - const { projects: matches, orgs } = isDisplayName + const { projects: matches, orgs: foundOrgs } = isDisplayName ? { projects: [], orgs: await listOrganizations() } : await withProgress( { @@ -793,6 +795,13 @@ export async function handleProjectSearch( () => findProjectsBySlug(projectSlug) ); + // When the caller provided an org (e.g. "org/My Project"), scope the + // search to that org instead of all accessible orgs. + const orgs = + isDisplayName && scopedOrg !== undefined + ? foundOrgs.filter((o) => o.slug === scopedOrg) + : foundOrgs; + if (matches.length === 0) { // Skip triage on recovery attempts to prevent infinite recursion. const outcome: ProjectNotFoundOutcome = _isRecoveryAttempt @@ -953,6 +962,7 @@ function buildDefaultHandlers( flags: ctx.flags, orgAllFallback: (orgSlug) => runOrgAll(config, orgSlug, ctx.flags), originalSlug: ctx.parsed.originalSlug, + org: ctx.parsed.org, }), "org-all": (ctx) => { From 5fa9c5e386b09bbdba4c296b2eedf4ed35c8aae5 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Mon, 22 Jun 2026 09:54:13 +0000 Subject: [PATCH 5/8] fix: refactor handleProjectSearch to options object (useMaxParams lint) --- src/commands/project/list.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 449622c59..b8060b197 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -564,7 +564,7 @@ async function handleProjectNotFound( if (outcome.kind === "fuzzy-match") { // Pass isRecoveryAttempt=true to prevent infinite recursion if the // fuzzy-recovered slug also fails to resolve. - return handleProjectSearch(outcome.project, flags, undefined, true); + return handleProjectSearch(outcome.project, flags, { isRecoveryAttempt: true }); } // JSON mode returns empty array; human mode throws a helpful error @@ -584,13 +584,16 @@ async function handleProjectNotFound( export async function handleProjectSearch( projectSlug: string, flags: ListFlags, - /** Original user input before normalization — for clearer messages. */ - originalSlug?: string, - /** @internal — prevents infinite recursion from fuzzy recovery. */ - _isRecoveryAttempt = false, - /** Organization slug to scope the search to (e.g. from "org/My Project"). */ - scopedOrg?: string + options?: { + /** Original user input before normalization — for clearer messages. */ + originalSlug?: string; + /** @internal — prevents infinite recursion from fuzzy recovery. */ + isRecoveryAttempt?: boolean; + /** Organization slug to scope the search to (e.g. from "org/My Project"). */ + scopedOrg?: string; + } ): Promise> { + const { originalSlug, isRecoveryAttempt = false, scopedOrg } = options ?? {}; // When the input is a display name (originalSlug set, contains spaces), // skip the slug-based API lookup and go straight to name-based matching. const isDisplayName = originalSlug !== undefined; @@ -623,7 +626,7 @@ export async function handleProjectSearch( return handleProjectNotFound(projectSlug, orgs, flags, { originalSlug, - isRecoveryAttempt: _isRecoveryAttempt, + isRecoveryAttempt, }); } @@ -741,13 +744,10 @@ export const listCommand = buildListCommand("project", { }); }, "project-search": (ctx) => - handleProjectSearch( - ctx.parsed.projectSlug, - flags, - ctx.parsed.originalSlug, - false, - ctx.parsed.org - ), + handleProjectSearch(ctx.parsed.projectSlug, flags, { + originalSlug: ctx.parsed.originalSlug, + scopedOrg: ctx.parsed.org, + }), }, }); From 0bf3b84e9fbb6378069477ebda57c64a3a99ee92 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Mon, 22 Jun 2026 09:59:13 +0000 Subject: [PATCH 6/8] fix: biome formatting for options object --- src/commands/project/list.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index b8060b197..5e00a5e08 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -564,7 +564,9 @@ async function handleProjectNotFound( if (outcome.kind === "fuzzy-match") { // Pass isRecoveryAttempt=true to prevent infinite recursion if the // fuzzy-recovered slug also fails to resolve. - return handleProjectSearch(outcome.project, flags, { isRecoveryAttempt: true }); + return handleProjectSearch(outcome.project, flags, { + isRecoveryAttempt: true, + }); } // JSON mode returns empty array; human mode throws a helpful error From 5d90116c212233fc4b90b579b26f5f1bb6cf05fd Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Tue, 23 Jun 2026 07:35:33 +0000 Subject: [PATCH 7/8] fix: preserve org scope through fuzzy recovery and resolve DSN-style org IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass scopedOrg through handleProjectNotFound to fuzzy recovery calls in both project/list.ts and org-list.ts - Remove isDisplayName guard from scopedOrg filter so org scoping applies to slug-based recovery lookups too - Call resolveEffectiveOrg for project-search mode when org is set, handling DSN-style identifiers (e.g. o1081365 → my-org) - Improve error messages when specified org is inaccessible to distinguish from the generic 'any accessible organization' message - Extract resolveOrgInParsed helper to keep dispatchOrgScopedList within cognitive complexity budget --- src/commands/project/list.ts | 28 ++++++++++++++------ src/lib/org-list.ts | 51 +++++++++++++++++++++++------------- src/lib/resolve-target.ts | 45 ++++++++++++++++++++++--------- 3 files changed, 85 insertions(+), 39 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 5e00a5e08..2aa18993e 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -533,9 +533,13 @@ async function handleProjectNotFound( projectSlug: string, orgs: { slug: string }[], flags: ListFlags, - options?: { originalSlug?: string; isRecoveryAttempt?: boolean } + options?: { + originalSlug?: string; + isRecoveryAttempt?: boolean; + scopedOrg?: string; + } ): Promise> { - const { originalSlug, isRecoveryAttempt = false } = options ?? {}; + const { originalSlug, isRecoveryAttempt = false, scopedOrg } = options ?? {}; const displaySlug = originalSlug ?? projectSlug; // Skip triage on recovery attempts to prevent infinite recursion. @@ -563,9 +567,11 @@ async function handleProjectNotFound( if (outcome.kind === "fuzzy-match") { // Pass isRecoveryAttempt=true to prevent infinite recursion if the - // fuzzy-recovered slug also fails to resolve. + // fuzzy-recovered slug also fails to resolve. Preserve scopedOrg so + // the recovery lookup stays scoped to the user's specified org. return handleProjectSearch(outcome.project, flags, { isRecoveryAttempt: true, + scopedOrg, }); } @@ -573,13 +579,17 @@ async function handleProjectNotFound( if (flags.json) { return { items: [] }; } + const fallback = scopedOrg + ? [ + `No project with this name found in organization '${scopedOrg}'`, + `Check the organization slug or try: sentry project list ${scopedOrg}/`, + ] + : ["No project with this slug found in any accessible organization"]; throw new ResolutionError( `Project '${displaySlug}'`, "not found", `sentry project list /${projectSlug}`, - outcome.suggestions.length > 0 - ? outcome.suggestions - : ["No project with this slug found in any accessible organization"] + outcome.suggestions.length > 0 ? outcome.suggestions : fallback ); } @@ -610,9 +620,10 @@ export async function handleProjectSearch( ); // When the caller provided an org (e.g. "org/My Project"), scope the - // search to that org instead of all accessible orgs. + // search to that org instead of all accessible orgs. This applies to both + // display-name searches and slug-based recovery lookups. const orgs = - isDisplayName && scopedOrg !== undefined + scopedOrg !== undefined ? foundOrgs.filter((o) => o.slug === scopedOrg) : foundOrgs; @@ -629,6 +640,7 @@ export async function handleProjectSearch( return handleProjectNotFound(projectSlug, orgs, flags, { originalSlug, isRecoveryAttempt, + scopedOrg, }); } diff --git a/src/lib/org-list.ts b/src/lib/org-list.ts index b60011e4f..958bef24d 100644 --- a/src/lib/org-list.ts +++ b/src/lib/org-list.ts @@ -796,9 +796,10 @@ export async function handleProjectSearch( ); // When the caller provided an org (e.g. "org/My Project"), scope the - // search to that org instead of all accessible orgs. + // search to that org instead of all accessible orgs. This applies to both + // display-name searches and slug-based recovery lookups. const orgs = - isDisplayName && scopedOrg !== undefined + scopedOrg !== undefined ? foundOrgs.filter((o) => o.slug === scopedOrg) : foundOrgs; @@ -842,13 +843,17 @@ export async function handleProjectSearch( return { items: [] }; } + const fallback = scopedOrg + ? [ + `No project with this name found in organization '${scopedOrg}'`, + `Check the organization slug or try: sentry project list ${scopedOrg}/`, + ] + : ["No project with this slug found in any accessible organization"]; throw new ResolutionError( `Project '${displaySlug}'`, "not found", `${config.commandPrefix} /${projectSlug}`, - outcome.suggestions.length > 0 - ? outcome.suggestions - : ["No project with this slug found in any accessible organization"] + outcome.suggestions.length > 0 ? outcome.suggestions : fallback ); } @@ -1062,6 +1067,28 @@ async function resolveOrgSlugMatch( return { type: "org-all", org: matchingOrg.slug }; } +/** + * Resolve DSN-style org identifiers and set org/project context for modes + * that carry an org field. Returns the (possibly updated) parsed object. + */ +async function resolveOrgInParsed( + parsed: ParsedOrgProject +): Promise { + if (!("org" in parsed && parsed.org)) { + return parsed; + } + const effectiveOrg = await resolveEffectiveOrg(parsed.org); + const resolved = + effectiveOrg !== parsed.org ? { ...parsed, org: effectiveOrg } : parsed; + if (resolved.type === "explicit" || resolved.type === "org-all") { + setOrgProjectContext( + [effectiveOrg], + resolved.type === "explicit" ? [resolved.project] : [] + ); + } + return resolved; +} + /** * Validate the cursor flag and dispatch to the correct mode handler. * @@ -1111,19 +1138,7 @@ export async function dispatchOrgScopedList( ); } - if ( - effectiveParsed.type === "explicit" || - effectiveParsed.type === "org-all" - ) { - const effectiveOrg = await resolveEffectiveOrg(effectiveParsed.org); - if (effectiveOrg !== effectiveParsed.org) { - effectiveParsed = { ...effectiveParsed, org: effectiveOrg }; - } - setOrgProjectContext( - [effectiveOrg], - effectiveParsed.type === "explicit" ? [effectiveParsed.project] : [] - ); - } + effectiveParsed = await resolveOrgInParsed(effectiveParsed); const defaults = buildDefaultHandlers(config); const handlers: ModeHandlerMap = { ...defaults, ...overrides }; diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 8d9b17fea..96c610dbf 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -1640,15 +1640,22 @@ export async function resolveOrgProjectTarget( case "project-search": { const displaySlug = parsed.originalSlug ?? parsed.projectSlug; const isDisplayName = parsed.originalSlug !== undefined; + + // Resolve DSN-style org identifiers (e.g. "o1081365" → "my-org") + // before using the org for filtering. + const scopedOrg = parsed.org + ? await resolveEffectiveOrg(parsed.org) + : undefined; + const { projects, orgs: foundOrgs } = isDisplayName ? { projects: [], orgs: await listOrganizations() } : await findProjectsBySlug(parsed.projectSlug); // When the caller provided an org (e.g. "org/My Project"), scope the - // display-name search to that org instead of all accessible orgs. + // search to that org instead of all accessible orgs. const orgs = - isDisplayName && parsed.org !== undefined - ? foundOrgs.filter((o) => o.slug === parsed.org) + scopedOrg !== undefined + ? foundOrgs.filter((o) => o.slug === scopedOrg) : foundOrgs; if (projects.length === 0) { @@ -1674,13 +1681,17 @@ export async function resolveOrgProjectTarget( }); } + const fallback = scopedOrg + ? [ + `No project with this name found in organization '${scopedOrg}'`, + `Check the organization slug or try: sentry project list ${scopedOrg}/`, + ] + : ["No project with this slug found in any accessible organization"]; throw new ResolutionError( `Project '${displaySlug}'`, "not found", `sentry ${commandName} /${parsed.projectSlug}`, - outcome.suggestions.length > 0 - ? outcome.suggestions - : ["No project with this slug found in any accessible organization"] + outcome.suggestions.length > 0 ? outcome.suggestions : fallback ); } @@ -1883,6 +1894,12 @@ export async function resolveTargetsFromParsedArg( // When the input is a display name (originalSlug set, contains spaces), // skip the slug-based API lookup and go straight to fuzzy matching. const isDisplayName = parsed.originalSlug !== undefined; + + // Resolve DSN-style org identifiers before filtering. + const scopedOrg = parsed.org + ? await resolveEffectiveOrg(parsed.org) + : undefined; + const { projects: matches, orgs: foundOrgs } = isDisplayName ? { projects: [], orgs: await listOrganizations() } : await findProjectsBySlug(parsed.projectSlug); @@ -1890,8 +1907,8 @@ export async function resolveTargetsFromParsedArg( // When the caller provided an org (e.g. "org/My Project"), scope the // search to that org instead of all accessible orgs. const orgs = - isDisplayName && parsed.org !== undefined - ? foundOrgs.filter((o) => o.slug === parsed.org) + scopedOrg !== undefined + ? foundOrgs.filter((o) => o.slug === scopedOrg) : foundOrgs; if (matches.length === 0) { @@ -1929,12 +1946,14 @@ export async function resolveTargetsFromParsedArg( return { targets }; } + const fallback = scopedOrg + ? [ + `No project with this name found in organization '${scopedOrg}'`, + `Check the organization slug or try: sentry project list ${scopedOrg}/`, + ] + : ["No project with this slug found in any accessible organization"]; const suggestions = - outcome.suggestions.length > 0 - ? outcome.suggestions - : [ - "No project with this slug found in any accessible organization", - ]; + outcome.suggestions.length > 0 ? outcome.suggestions : fallback; throw new ResolutionError( `Project '${displaySlug}'`, "not found", From 607647e9a4fbae6d1e35e7fb2ee6957e54bf3d8c Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Tue, 23 Jun 2026 22:37:41 +0000 Subject: [PATCH 8/8] fix: scope project matches to explicit org across all resolution paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org-scoping introduced for display names only filtered the orgs list, not the projects/matches array. Since findProjectsBySlug fans out across every accessible org, a recovered or directly-matched slug that also exists in another org could leak into a result that was explicitly scoped to one org (e.g. 'org-a/My Project' returning 'org-b/'). Filter the matched projects by scopedOrg in: - project/list.ts handleProjectSearch (recovery path) - org-list.ts handleProjectSearch (shared list handler) - resolve-target.ts resolveOrgProjectTarget + resolveTargetsFromParsedArg project-search branches Also resolve DSN-style org identifiers (o123/...) in the explicit and org-all branches of resolveTargetsFromParsedArg, which previously only happened in project-search — making it consistent with resolveOrgProjectTarget. Adds tests for cross-org duplicate recovery, shared org-list scoped recovery, and DSN-style org IDs through the explicit/org-all branches. --- src/commands/project/list.ts | 13 +++- src/lib/org-list.ts | 11 ++- src/lib/resolve-target.ts | 46 ++++++++---- test/commands/project/list.test.ts | 58 +++++++++++++++ test/lib/org-list.test.ts | 29 ++++++++ test/lib/resolve-target-listing.test.ts | 96 ++++++++++++++++++++++++- 6 files changed, 234 insertions(+), 19 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 2aa18993e..8c0907f1e 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -621,16 +621,23 @@ export async function handleProjectSearch( // When the caller provided an org (e.g. "org/My Project"), scope the // search to that org instead of all accessible orgs. This applies to both - // display-name searches and slug-based recovery lookups. + // display-name searches and slug-based recovery lookups. The slug-based + // lookup (findProjectsBySlug) fans out across every accessible org, so the + // recovered projects must be filtered too — otherwise a recovered slug that + // also exists in a different org could leak into a scoped result. const orgs = scopedOrg !== undefined ? foundOrgs.filter((o) => o.slug === scopedOrg) : foundOrgs; + const scopedProjects = + scopedOrg !== undefined + ? projects.filter((p) => p.orgSlug === scopedOrg) + : projects; - const filtered = filterByPlatform(projects, flags.platform); + const filtered = filterByPlatform(scopedProjects, flags.platform); if (filtered.length === 0) { - if (projects.length > 0 && flags.platform) { + if (scopedProjects.length > 0 && flags.platform) { return { items: [], hint: `No project '${projectSlug}' found matching platform '${flags.platform}'.`, diff --git a/src/lib/org-list.ts b/src/lib/org-list.ts index 958bef24d..74eab7d6e 100644 --- a/src/lib/org-list.ts +++ b/src/lib/org-list.ts @@ -785,7 +785,7 @@ export async function handleProjectSearch( // When the input is a display name (originalSlug set, contains spaces), // skip the slug-based API lookup and go straight to name-based matching. const isDisplayName = originalSlug !== undefined; - const { projects: matches, orgs: foundOrgs } = isDisplayName + const { projects: rawMatches, orgs: foundOrgs } = isDisplayName ? { projects: [], orgs: await listOrganizations() } : await withProgress( { @@ -797,11 +797,18 @@ export async function handleProjectSearch( // When the caller provided an org (e.g. "org/My Project"), scope the // search to that org instead of all accessible orgs. This applies to both - // display-name searches and slug-based recovery lookups. + // display-name searches and slug-based recovery lookups. The slug-based + // lookup (findProjectsBySlug) fans out across every accessible org, so the + // matched projects must be filtered too — otherwise a recovered slug that + // also exists in a different org could leak into a scoped result. const orgs = scopedOrg !== undefined ? foundOrgs.filter((o) => o.slug === scopedOrg) : foundOrgs; + const matches = + scopedOrg !== undefined + ? rawMatches.filter((m) => m.orgSlug === scopedOrg) + : rawMatches; if (matches.length === 0) { // Skip triage on recovery attempts to prevent infinite recursion. diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 96c610dbf..e02196a78 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -1647,16 +1647,23 @@ export async function resolveOrgProjectTarget( ? await resolveEffectiveOrg(parsed.org) : undefined; - const { projects, orgs: foundOrgs } = isDisplayName + const { projects: rawProjects, orgs: foundOrgs } = isDisplayName ? { projects: [], orgs: await listOrganizations() } : await findProjectsBySlug(parsed.projectSlug); // When the caller provided an org (e.g. "org/My Project"), scope the - // search to that org instead of all accessible orgs. + // search to that org instead of all accessible orgs. findProjectsBySlug + // fans out across every accessible org, so the matched projects must be + // filtered too — otherwise a slug that also exists in a different org + // could be returned (or flagged ambiguous) despite the explicit scope. const orgs = scopedOrg !== undefined ? foundOrgs.filter((o) => o.slug === scopedOrg) : foundOrgs; + const projects = + scopedOrg !== undefined + ? rawProjects.filter((p) => p.orgSlug === scopedOrg) + : rawProjects; if (projects.length === 0) { const outcome = await triageProjectNotFound( @@ -1838,14 +1845,17 @@ export async function resolveTargetsFromParsedArg( } case "explicit": { - const projectId = await fetchProjectId(parsed.org, parsed.project); + // Resolve DSN-style org identifiers (e.g. "o1081365" → "my-org") before + // hitting the API, mirroring resolveOrgProjectTarget's explicit branch. + const org = await resolveEffectiveOrg(parsed.org); + const projectId = await fetchProjectId(org, parsed.project); return { targets: [ { - org: parsed.org, + org, project: parsed.project, projectId, - orgDisplay: parsed.org, + orgDisplay: org, projectDisplay: parsed.project, }, ], @@ -1853,20 +1863,23 @@ export async function resolveTargetsFromParsedArg( } case "org-all": { - const projects = await listProjects(parsed.org); + // Resolve DSN-style org identifiers (e.g. "o1081365" → "my-org") before + // listing projects, so "o123/" works the same as "my-org/". + const org = await resolveEffectiveOrg(parsed.org); + const projects = await listProjects(org); const targets: ResolvedTarget[] = projects.map((p) => ({ - org: parsed.org, + org, project: p.slug, projectId: toNumericId(p.id), - orgDisplay: parsed.org, + orgDisplay: org, projectDisplay: p.name, })); if (targets.length === 0) { throw new ResolutionError( - `Organization '${parsed.org}'`, + `Organization '${org}'`, "has no accessible projects", - `sentry project list ${parsed.org}/`, + `sentry project list ${org}/`, ["Check that you have access to projects in this organization"] ); } @@ -1875,7 +1888,7 @@ export async function resolveTargetsFromParsedArg( targets, footer: targets.length > 1 - ? `Showing results from ${targets.length} projects in ${parsed.org}` + ? `Showing results from ${targets.length} projects in ${org}` : undefined, }; } @@ -1900,16 +1913,23 @@ export async function resolveTargetsFromParsedArg( ? await resolveEffectiveOrg(parsed.org) : undefined; - const { projects: matches, orgs: foundOrgs } = isDisplayName + const { projects: rawMatches, orgs: foundOrgs } = isDisplayName ? { projects: [], orgs: await listOrganizations() } : await findProjectsBySlug(parsed.projectSlug); // When the caller provided an org (e.g. "org/My Project"), scope the - // search to that org instead of all accessible orgs. + // search to that org instead of all accessible orgs. findProjectsBySlug + // fans out across every accessible org, so the matched projects must be + // filtered too — otherwise a slug that also exists in a different org + // could leak into a result that was explicitly scoped to one org. const orgs = scopedOrg !== undefined ? foundOrgs.filter((o) => o.slug === scopedOrg) : foundOrgs; + const matches = + scopedOrg !== undefined + ? rawMatches.filter((m) => m.orgSlug === scopedOrg) + : rawMatches; if (matches.length === 0) { const outcome = await triageProjectNotFound( diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index eb132954d..a134a705b 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -757,6 +757,64 @@ describe("handleProjectSearch", () => { expect(result.hint).toContain("matching platform 'rust'"); }); + test("scopedOrg only returns the matched project from that org, not a same-slug project elsewhere", async () => { + setOrgRegion("org-a", DEFAULT_SENTRY_URL); + setOrgRegion("org-b", DEFAULT_SENTRY_URL); + + // listOrganizations returns two orgs; each has a 'frontend' project. The + // bare-slug lookup fans out across both. With scopedOrg=org-a the result + // must only contain org-a's project, never org-b's. + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // getProject — /projects/{org}/{slug}/ + const projMatch = url.match(/\/projects\/([^/]+)\/frontend\//); + if (projMatch) { + return new Response( + JSON.stringify({ + id: projMatch[1] === "org-a" ? "1" : "2", + slug: "frontend", + name: "Frontend", + platform: "javascript", + dateCreated: "2024-01-01T00:00:00Z", + status: "active", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // listOrganizations + if ( + url.includes("/organizations/") && + !url.includes("/projects/") && + !url.includes("/issues/") + ) { + return new Response( + JSON.stringify([ + { id: "10", slug: "org-a", name: "Org A" }, + { id: "20", slug: "org-b", name: "Org B" }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + const result = await handleProjectSearch( + "frontend", + { limit: 30, json: false, fresh: false }, + { scopedOrg: "org-a" } + ); + + expect(result.items.every((i) => i.orgSlug === "org-a")).toBe(true); + expect(result.items.some((i) => i.orgSlug === "org-b")).toBe(false); + }); + test("respects --limit flag", async () => { setOrgRegion("org-a", DEFAULT_SENTRY_URL); setOrgRegion("org-b", DEFAULT_SENTRY_URL); diff --git a/test/lib/org-list.test.ts b/test/lib/org-list.test.ts index a897d86cf..7d2354c71 100644 --- a/test/lib/org-list.test.ts +++ b/test/lib/org-list.test.ts @@ -717,6 +717,35 @@ describe("handleProjectSearch", () => { expect(result.hint).toContain("2 organizations"); }); + + test("scopes matches to the explicit org, ignoring a same-slug project in another org", async () => { + // findProjectsBySlug fans out across orgs and finds the slug in BOTH + // org-a (the requested scope) and org-b. With an explicit org the result + // must only fetch from org-a — never leak org-b. + findProjectsBySlugSpy.mockResolvedValue({ + projects: [ + { orgSlug: "org-b", slug: "my-proj", id: "2", name: "My Project" }, + { orgSlug: "org-a", slug: "my-proj", id: "1", name: "My Project" }, + ], + orgs: [ + { slug: "org-a", name: "Org A" }, + { slug: "org-b", name: "Org B" }, + ], + }); + const listForOrg = vi.fn(() => + Promise.resolve([{ id: "1", name: "Widget" }]) + ); + const config = makeConfig({ listForOrg }); + + const result = await handleProjectSearch(config, "my-proj", { + flags: { limit: 10, json: false }, + org: "org-a", + }); + + expect(listForOrg).toHaveBeenCalledTimes(1); + expect(listForOrg).toHaveBeenCalledWith("org-a"); + expect(result.items.every((i) => i.orgSlug === "org-a")).toBe(true); + }); }); // --------------------------------------------------------------------------- diff --git a/test/lib/resolve-target-listing.test.ts b/test/lib/resolve-target-listing.test.ts index d0150dcff..191b4fd2d 100644 --- a/test/lib/resolve-target-listing.test.ts +++ b/test/lib/resolve-target-listing.test.ts @@ -36,7 +36,7 @@ vi.mock("../../src/lib/db/defaults.js", async (importOriginal) => { // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as defaults from "../../src/lib/db/defaults.js"; -import { setOrgRegion } from "../../src/lib/db/regions.js"; +import { setOrgRegion, setOrgRegions } from "../../src/lib/db/regions.js"; import { ContextError, ResolutionError } from "../../src/lib/errors.js"; vi.mock("../../src/lib/resolve-target.js", async (importOriginal) => { @@ -56,6 +56,7 @@ import { resolveOrgProjectFromArg, resolveOrgProjectTarget, resolveOrgsForListing, + resolveTargetsFromParsedArg, } from "../../src/lib/resolve-target.js"; const CWD = "/tmp/test-project"; @@ -369,3 +370,96 @@ describe("resolveOrgProjectFromArg", () => { expect(result).toEqual({ org: "auto-org", project: "auto-proj" }); }); }); + +// --------------------------------------------------------------------------- +// resolveTargetsFromParsedArg — org scoping & DSN-style org resolution +// --------------------------------------------------------------------------- + +describe("resolveTargetsFromParsedArg", () => { + let getProjectSpy: ReturnType; + let listProjectsSpy: ReturnType; + let findProjectsBySlugSpy: ReturnType; + + const OPTS = { cwd: CWD, usageHint: "sentry issue list /" }; + + beforeEach(() => { + getProjectSpy = vi.spyOn(apiClient, "getProject"); + listProjectsSpy = vi.spyOn(apiClient, "listProjects"); + findProjectsBySlugSpy = vi.spyOn(apiClient, "findProjectsBySlug"); + // Seed both a normal slug and a DSN-style numeric ID mapping so + // resolveEffectiveOrg resolves from cache without hitting the API. + setOrgRegion("my-org", DEFAULT_SENTRY_URL); + setOrgRegions([ + { slug: "real-org", regionUrl: DEFAULT_SENTRY_URL, orgId: "1081365" }, + ]); + }); + + afterEach(() => { + getProjectSpy.mockRestore(); + listProjectsSpy.mockRestore(); + findProjectsBySlugSpy.mockRestore(); + }); + + test("explicit: resolves DSN-style org id to the real slug", async () => { + getProjectSpy.mockResolvedValue({ id: "42", slug: "my-proj" }); + + const result = await resolveTargetsFromParsedArg( + { type: "explicit", org: "o1081365", project: "my-proj" }, + OPTS + ); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0]).toMatchObject({ + org: "real-org", + project: "my-proj", + projectId: 42, + }); + // The API lookup must use the resolved slug, not the raw DSN id. + expect(getProjectSpy).toHaveBeenCalledWith("real-org", "my-proj"); + }); + + test("org-all: resolves DSN-style org id before listing projects", async () => { + listProjectsSpy.mockResolvedValue([ + { id: "1", slug: "p1", name: "P1" }, + { id: "2", slug: "p2", name: "P2" }, + ]); + + const result = await resolveTargetsFromParsedArg( + { type: "org-all", org: "o1081365" }, + OPTS + ); + + expect(listProjectsSpy).toHaveBeenCalledWith("real-org"); + expect(result.targets).toHaveLength(2); + for (const t of result.targets) { + expect(t.org).toBe("real-org"); + } + }); + + test("project-search: scopes recovery to explicit org, ignoring a same-slug project in another org", async () => { + // A bare-slug lookup fans out across orgs and finds the slug in BOTH + // org-a (the requested scope) and org-b. The result must only include + // the org-a match — never leak org-b. + findProjectsBySlugSpy.mockResolvedValue({ + projects: [ + { orgSlug: "org-b", slug: "my-proj", name: "My Project" }, + { orgSlug: "my-org", slug: "my-proj", name: "My Project" }, + ], + orgs: [ + { slug: "org-b", name: "Org B" }, + { slug: "my-org", name: "My Org" }, + ], + }); + + const result = await resolveTargetsFromParsedArg( + { type: "project-search", projectSlug: "my-proj", org: "my-org" }, + OPTS + ); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0]).toMatchObject({ + org: "my-org", + project: "my-proj", + }); + }); +});