-
-
Notifications
You must be signed in to change notification settings - Fork 6
fix(resolve): fuzzy auto-recovery for project slug resolution #728
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"); | ||
|
|
@@ -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"). | ||
| * | ||
|
|
@@ -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( | ||
|
|
@@ -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); | ||
| } | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fuzzy recovery throws before JSON mode early returnMedium Severity When multiple similar projects are found, Additional Locations (1)Reviewed by Cursor Bugbot for commit c1a36ff. Configure here.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in PR #732 — |
||
|
|
||
| if (flags.json) { | ||
| return { items: [] }; | ||
| } | ||
|
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}'`, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| ); | ||
|
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. | ||
| * | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Suggested FixAdd an Prompt for AI Agent
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in PR #732 — refactored |
||
|
|
||
| throw new ResolutionError( | ||
| `Project "${projectSlug}"`, | ||
| "not found", | ||
|
|
@@ -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", | ||
|
|
||


There was a problem hiding this comment.
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
handleProjectSearchin atry...catchblock. If an error is caught, re-throw aResolutionErrorthat preserves the context of the originalprojectSlugthe user provided, ensuring the final error message is relevant to the user's input.Prompt for AI Agent
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in PR #732 —
tryFuzzyRecoveryForListnow returns a discriminated type instead of throwing. If the recursive call fails, the error references the original slug (projectSlugparameter), not the recovered one, since the recursion guard (_isRecoveryAttempt=true) prevents further recovery attempts on the re-entered call.