Skip to content
Merged
73 changes: 53 additions & 20 deletions src/commands/project/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ListResult<ProjectWithOrg>> {
const { originalSlug, isRecoveryAttempt = false } = options ?? {};
const { originalSlug, isRecoveryAttempt = false, scopedOrg } = options ?? {};
const displaySlug = originalSlug ?? projectSlug;

// Skip triage on recovery attempts to prevent infinite recursion.
Expand Down Expand Up @@ -563,36 +567,49 @@ 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);
// 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,
});
Comment thread
sentry[bot] marked this conversation as resolved.
}

// JSON mode returns empty array; human mode throws a helpful error
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 <org>/${projectSlug}`,
outcome.suggestions.length > 0
? outcome.suggestions
: ["No project with this slug found in any accessible organization"]
outcome.suggestions.length > 0 ? outcome.suggestions : fallback
);
}

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
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<ListResult<ProjectWithOrg>> {
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;
const { projects, orgs } = isDisplayName
const { projects, orgs: foundOrgs } = isDisplayName
? { projects: [], orgs: await listOrganizations() }
: await withProgress(
{
Expand All @@ -601,10 +618,26 @@ export async function handleProjectSearch(
},
() => findProjectsBySlug(projectSlug)
);
const filtered = filterByPlatform(projects, flags.platform);

// 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. 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(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}'.`,
Expand All @@ -613,7 +646,8 @@ export async function handleProjectSearch(

return handleProjectNotFound(projectSlug, orgs, flags, {
originalSlug,
isRecoveryAttempt: _isRecoveryAttempt,
isRecoveryAttempt,
scopedOrg,
});
}

Expand Down Expand Up @@ -731,11 +765,10 @@ export const listCommand = buildListCommand("project", {
});
},
"project-search": (ctx) =>
handleProjectSearch(
ctx.parsed.projectSlug,
flags,
ctx.parsed.originalSlug
),
handleProjectSearch(ctx.parsed.projectSlug, flags, {
originalSlug: ctx.parsed.originalSlug,
scopedOrg: ctx.parsed.org,
}),
},
});

Expand Down
18 changes: 18 additions & 0 deletions src/lib/arg-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Comment thread
sentry-warden[bot] marked this conversation as resolved.
};
}
const np = normalizeSlug(rawProject);
validateResourceId(np.slug, "project slug");
const normalized = no.normalized || np.normalized;
Expand Down
68 changes: 50 additions & 18 deletions src/lib/org-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,17 +773,19 @@ export async function handleProjectSearch<TEntity, TWithOrg>(
orgAllFallback?: (orgSlug: string) => Promise<ListResult<TWithOrg>>;
/** 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<ListResult<TWithOrg>> {
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: rawMatches, orgs: foundOrgs } = isDisplayName
? { projects: [], orgs: await listOrganizations() }
: await withProgress(
{
Expand All @@ -793,6 +795,21 @@ export async function handleProjectSearch<TEntity, TWithOrg>(
() => findProjectsBySlug(projectSlug)
);

// 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. 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;

Comment thread
cursor[bot] marked this conversation as resolved.
if (matches.length === 0) {
// Skip triage on recovery attempts to prevent infinite recursion.
const outcome: ProjectNotFoundOutcome = _isRecoveryAttempt
Expand Down Expand Up @@ -833,13 +850,17 @@ export async function handleProjectSearch<TEntity, TWithOrg>(
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} <org>/${projectSlug}`,
outcome.suggestions.length > 0
? outcome.suggestions
: ["No project with this slug found in any accessible organization"]
outcome.suggestions.length > 0 ? outcome.suggestions : fallback
);
}

Expand Down Expand Up @@ -953,6 +974,7 @@ function buildDefaultHandlers<TEntity, TWithOrg>(
flags: ctx.flags,
orgAllFallback: (orgSlug) => runOrgAll(config, orgSlug, ctx.flags),
originalSlug: ctx.parsed.originalSlug,
org: ctx.parsed.org,
}),

"org-all": (ctx) => {
Expand Down Expand Up @@ -1052,6 +1074,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<ParsedOrgProject> {
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.
*
Expand Down Expand Up @@ -1101,19 +1145,7 @@ export async function dispatchOrgScopedList<TEntity, TWithOrg>(
);
}

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 };
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down
Loading
Loading