Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions src/lib/org-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ import { paginationHint } from "./list-command.js";
import { logger } from "./logger.js";
import { withProgress } from "./polling.js";
import { resolveEffectiveOrg } from "./region.js";
import { resolveOrgsForListing } from "./resolve-target.js";
import {
resolveOrgsForListing,
tryFuzzyProjectRecovery,
} from "./resolve-target.js";
import { setOrgProjectContext } from "./telemetry.js";

const log = logger.withTag("org-list");
Expand Down Expand Up @@ -650,6 +653,32 @@ export async function handleExplicitProject<TEntity, TWithOrg>(
return result;
}

/**
* Attempt fuzzy recovery for a project slug in the list-command context.
*
* Returns the corrected slug if exactly one similar project is found, or
* undefined if no recovery is possible. Throws ResolutionError with
* suggestions if multiple matches are found.
*/
async function tryFuzzyRecoveryForList(
slug: string,
orgs: { slug: string }[],
commandPrefix: string
): Promise<string | undefined> {
const recovered = await tryFuzzyProjectRecovery(
slug,
orgs,
(suggestions) =>
new ResolutionError(
`Project '${slug}'`,
"not found",
`${commandPrefix} <org>/${slug}`,
suggestions
)
);
return recovered?.project;
}

/**
* Handle project-search mode (bare slug, e.g., "cli").
*
Expand All @@ -669,13 +698,16 @@ export async function handleExplicitProject<TEntity, TWithOrg>(
*
* Returns a {@link ListResult} with items and hint text.
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: multi-mode dispatch with recovery is inherently branchy
export async function handleProjectSearch<TEntity, TWithOrg>(
config: OrgListConfig<TEntity, TWithOrg>,
projectSlug: string,
options: {
flags: BaseListFlags;
orgAllFallback?: (orgSlug: string) => Promise<ListResult<TWithOrg>>;
}
},
/** Guard against infinite recursion from fuzzy recovery. */
_isRecoveryAttempt = false
): Promise<ListResult<TWithOrg>> {
const { flags, orgAllFallback } = options;
const { projects: matches, orgs } = await withProgress(
Expand Down Expand Up @@ -707,9 +739,25 @@ export async function handleProjectSearch<TEntity, TWithOrg>(
);
}

// Attempt fuzzy auto-recovery — if a single similar project is found,
// re-run the handler with the corrected slug. Applied before the JSON
// early return so both human and JSON consumers benefit from recovery.
// Skip on retry to prevent infinite recursion.
if (!_isRecoveryAttempt) {
const correctedSlug = await tryFuzzyRecoveryForList(
projectSlug,
orgs,
config.commandPrefix
);
if (correctedSlug) {
return handleProjectSearch(config, correctedSlug, options, true);
}
Comment on lines +746 to +754
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: If a fuzzy-recovered project lookup fails in handleProjectSearch, the resulting error message incorrectly references the recovered slug, which the user never typed, causing confusion.
Severity: MEDIUM

Suggested Fix

Wrap the recursive call to handleProjectSearch in a try...catch block. If an error is caught, re-throw a ResolutionError that preserves the context of the original projectSlug the user provided, ensuring the final error message is relevant to the user's input.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/lib/org-list.ts#L746-L754

Potential issue: In `handleProjectSearch`, when a user provides a project slug that has
a fuzzy match, the function logs a warning and recursively calls itself with the
corrected slug. If this recursive call fails (e.g., due to a transient API error, rate
limit, or race condition), the subsequent `ResolutionError` is thrown using the
corrected slug, not the one the user originally typed. This is confusing for the user,
who sees a warning about using a corrected project name and then an error that this
corrected name (which they never entered) was not found.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in PR #732tryFuzzyRecoveryForList now returns a discriminated type instead of throwing. If the recursive call fails, the error references the original slug (projectSlug parameter), not the recovered one, since the recursion guard (_isRecoveryAttempt=true) prevents further recovery attempts on the re-entered call.

}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fuzzy recovery throws before JSON mode early return

Medium Severity

When multiple similar projects are found, tryFuzzyProjectRecovery throws a ResolutionError (via errorFactory). In handleProjectSearch, this throw occurs before the flags.json early-return check at line 757 that returns { items: [] }. This means JSON consumers receive an unhandled error instead of the expected empty-items response, breaking the contract that JSON mode degrades gracefully on "not found."

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c1a36ff. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in PR #732tryFuzzyProjectRecovery no longer throws for multiple matches. It returns { kind: 'suggestions', suggestions } and the caller passes suggestions to ResolutionError only in human mode. JSON mode falls through to return { items: [] } correctly.


if (flags.json) {
return { items: [] };
}
Comment thread
cursor[bot] marked this conversation as resolved.

// Use ResolutionError — the user provided a project slug but it wasn't found.
throw new ResolutionError(
`Project '${projectSlug}'`,
Expand Down
121 changes: 121 additions & 0 deletions src/lib/resolve-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,83 @@ async function findSimilarProjects(
}
}

/**
* Find similar project slugs across all accessible organizations.
*
* Used by project-search resolution when an exact slug match fails.
* Lists projects in each org, then fuzzy-matches the slug against all
* available project slugs. Best-effort: API or auth failures for individual
* orgs are silently skipped.
*
* @param slug - The project slug that wasn't found
* @param orgs - Accessible organizations to search
* @returns Up to 5 similar projects with their org context, or empty array
*/
async function findSimilarProjectsAcrossOrgs(
slug: string,
orgs: { slug: string }[]
): Promise<{ slug: string; orgSlug: string }[]> {
try {
const concurrency = pLimit(5);
const orgProjects = await Promise.all(
orgs.map((org) =>
concurrency(async () => {
const result = await withAuthGuard(() => listProjects(org.slug));
if (!result.ok) {
return [];
}
return result.value.map((p) => ({
slug: p.slug,
orgSlug: org.slug,
}));
})
)
);
const allProjects = orgProjects.flat();
// Deduplicate slugs so fuzzyMatch doesn't waste slots on the same
// project appearing in multiple orgs.
const uniqueSlugs = [...new Set(allProjects.map((p) => p.slug))];
const matches = fuzzyMatch(slug, uniqueSlugs, { maxResults: 5 });
// Expand matched slugs back to org-qualified entries (may include
// the same slug from multiple orgs — that's correct for suggestions).
return matches.flatMap((matched) =>
allProjects.filter((p) => p.slug === matched)
);
Comment thread
cursor[bot] marked this conversation as resolved.
} catch {
return [];
}
}

/**
* Attempt fuzzy auto-recovery when a project slug isn't found.
*
* If exactly one similar project is found (prefix/substring/close edit
* distance), auto-recovers by returning it with a warning. With multiple
* matches, adds suggestions to the thrown ResolutionError.
*
* @returns The resolved org/project pair if auto-recovered, or undefined
*/
export async function tryFuzzyProjectRecovery(
slug: string,
orgs: { slug: string }[],
errorFactory: (suggestions: string[]) => ResolutionError
): Promise<{ org: string; project: string } | undefined> {
const similar = await findSimilarProjectsAcrossOrgs(slug, orgs);
if (similar.length === 1) {
const match = similar[0] as (typeof similar)[0];
log.warn(
`Project '${slug}' not found. Using similar project '${match.slug}' in org '${match.orgSlug}'.`
);
return { org: match.orgSlug, project: match.slug };
}
if (similar.length > 1) {
const suggestions = [
`Similar projects: ${similar.map((s) => `'${s.orgSlug}/${s.slug}'`).join(", ")}`,
];
throw errorFactory(suggestions);
}
}

/**
* Fetch the numeric project ID for an explicit org/project pair.
*
Expand Down Expand Up @@ -1190,6 +1267,31 @@ export async function resolveProjectBySlug(
);
}

// Attempt fuzzy auto-recovery before throwing
const recovered = await tryFuzzyProjectRecovery(
projectSlug,
orgs,
(suggestions) =>
new ResolutionError(
`Project "${projectSlug}"`,
"not found",
usageHint,
suggestions
)
);
if (recovered) {
const proj = await withAuthGuard(() =>
getProject(recovered.org, recovered.project)
);
if (proj.ok) {
return withTelemetryContext({
org: recovered.org,
project: recovered.project,
projectData: proj.value,
});
}
}
Comment on lines +1283 to +1293
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: After fuzzy project recovery, a subsequent failure to fetch the recovered project causes a misleading error message that references the original, unfound project slug.
Severity: MEDIUM

Suggested Fix

Add an else block after the if (proj.ok) check within the if (recovered) block. This else block should handle the failure of the getProject call by throwing a more specific error, clarifying that the recovered project could not be fetched, rather than falling through to the generic error message.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/lib/resolve-target.ts#L1282-L1293

Potential issue: In `resolveProjectBySlug`, when fuzzy project recovery finds a single
match, it logs a warning that it will use the recovered project. However, if the
subsequent `getProject` call for this recovered project fails (e.g., due to a race
condition where permissions change or the project is deleted), the logic silently falls
through. It then throws a generic `ResolutionError` using the original, unfound project
slug. This results in a confusing user experience where the user is first told a similar
project will be used, and then immediately receives an error that the original project
was not found.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in PR #732 — refactored tryFuzzyProjectRecovery to return a discriminated result type (match | suggestions | none) instead of logging internally. Callers now only log.warn() after confirming the recovered project is accessible via getProject. If the fetch fails, it falls through silently to the original error.


throw new ResolutionError(
`Project "${projectSlug}"`,
"not found",
Expand Down Expand Up @@ -1363,6 +1465,25 @@ export async function resolveOrgProjectTarget(
);
}

// Attempt fuzzy auto-recovery before throwing
const recovered = await tryFuzzyProjectRecovery(
parsed.projectSlug,
orgs,
(suggestions) =>
new ResolutionError(
`Project '${parsed.projectSlug}'`,
"not found",
`sentry ${commandName} <org>/${parsed.projectSlug}`,
suggestions
)
);
if (recovered) {
return withTelemetryContext({
org: recovered.org,
project: recovered.project,
});
}

throw new ResolutionError(
`Project '${parsed.projectSlug}'`,
"not found",
Expand Down
Loading