Skip to content

Commit b6a860b

Browse files
authored
fix(resolve-target): reference original input in fuzzy-recovery warnings (#772) (#774)
## Summary Fixes #772. Replaces the space→dash slug normalization with direct display-name matching. When a user types a project display name (e.g. \`"My Project"\`) instead of a slug, the CLI now searches by name instead of guessing \`"my-project"\`. ### Before \`\`\` $ sentry issue list "My Project" WARN Normalized slug to 'my-project' WARN Project 'my-project' not found. Using similar project 'my_project' in org 'acme'. \`\`\` Two confusing warnings referencing \`my-project\` — a slug the CLI invented and the user never typed. ### After \`\`\` $ sentry issue list "My Project" WARN No project matching 'My Project'. Using 'my_project' in org 'acme'. \`\`\` One clean message. No misleading normalization step, no wasted API calls for a doomed slug lookup. ## How it works 1. **Spaces = display name** — \`looksLikeDisplayName()\` detects spaces in the input, which are never valid in slugs 2. **Skip slug path** — The parser bypasses \`normalizeSlug()\`, \`validateResourceId()\`, and \`findProjectsBySlug()\` (all would fail on spaces) 3. **Fuzzy name matching** — \`findSimilarProjectsAcrossOrgs()\` now matches against both project slugs AND display names, resolving \`"My Project"\` → \`my_project\` 4. **Shared helper** — \`triageProjectNotFound()\` consolidates the org-check + fuzzy-recovery + warn pattern across all 4 resolution sites ## Changes ### \`src/lib/arg-parsing.ts\` - \`normalizeSlug()\`: now a no-op (spaces were its only remaining case after #771) - \`warnNormalized()\`: removed (no normalization to warn about) - \`parseOrgProjectArg\` / \`parseSlashOrgProject\`: spaces trigger display-name search path — skip slug validation, set \`originalSlug\` - \`looksLikeDisplayName()\`: new helper detecting spaces in input ### \`src/lib/resolve-target.ts\` - \`findSimilarProjectsAcrossOrgs\`: matches against project names (not just slugs) when \`displayName\` provided - \`triageProjectNotFound\` **(new)**: shared helper returning discriminated union (\`org-match\` | \`fuzzy-match\` | \`not-found\`) - \`resolveProjectBySlug\`: skips \`findProjectsBySlug\` for display-name input; uses \`triageProjectNotFound\` - \`resolveOrgProjectTarget\`: same display-name fast path - \`Array.concat\` instead of spread for merging fuzzy results ### \`src/lib/org-list.ts\` - \`handleProjectSearch\`: display-name fast path; uses \`triageProjectNotFound\` - Removed \`tryFuzzyRecoveryForList\` (superseded by shared helper) ### \`src/commands/project/list.ts\` - Display-name fast path; fuzzy recovery via \`triageProjectNotFound\` (was previously missing) - \`handleProjectNotFound\` helper with recursion guard ### Callers updated - \`event/view.ts\`, \`log/view.ts\`, \`project/view.ts\`, \`dashboard/create.ts\`, \`trace-target.ts\` — pass \`originalSlug\` ### Tests - Rewrote space-normalization tests → display-name search tests - Updated \`normalizeSlug\` unit and property tests for no-op behavior - Updated spy assertions for new parameter
1 parent 6f1d9e3 commit b6a860b

14 files changed

Lines changed: 488 additions & 478 deletions

File tree

AGENTS.md

Lines changed: 53 additions & 54 deletions
Large diffs are not rendered by default.

src/commands/dashboard/create.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ async function resolveDashboardTarget(
101101
case "project-search": {
102102
const found = await resolveProjectBySlug(
103103
parsed.projectSlug,
104-
"sentry dashboard create <org>/<project> <title>"
104+
"sentry dashboard create <org>/<project> <title>",
105+
undefined,
106+
parsed.originalSlug
105107
);
106108
const pid = toNumericId(found.projectData.id);
107109
return {

src/commands/event/view.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,8 @@ export async function resolveEventTarget(
328328
const resolved = await resolveProjectBySlug(
329329
parsed.projectSlug,
330330
USAGE_HINT,
331-
`sentry event view <org>/${parsed.projectSlug} ${eventId}`
331+
`sentry event view <org>/${parsed.projectSlug} ${eventId}`,
332+
parsed.originalSlug
332333
);
333334
return {
334335
org: resolved.org,

src/commands/log/view.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,8 @@ async function resolveTarget(
162162
const result = await resolveProjectBySlug(
163163
parsed.projectSlug,
164164
USAGE_HINT,
165-
`sentry log view <org>/${parsed.projectSlug} ${logIds.join(" ")}`
165+
`sentry log view <org>/${parsed.projectSlug} ${logIds.join(" ")}`,
166+
parsed.originalSlug
166167
);
167168
if (
168169
ALL_DIGITS_RE.test(parsed.projectSlug) &&

src/commands/project/list.ts

Lines changed: 88 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
paginationHint,
4747
targetPatternExplanation,
4848
} from "../../lib/list-command.js";
49+
4950
import {
5051
dispatchOrgScopedList,
5152
jsonTransformListResult,
@@ -54,8 +55,10 @@ import {
5455
} from "../../lib/org-list.js";
5556
import { withProgress } from "../../lib/polling.js";
5657
import {
58+
type ProjectNotFoundOutcome,
5759
type ResolvedTarget,
5860
resolveAllTargets,
61+
triageProjectNotFound,
5962
} from "../../lib/resolve-target.js";
6063
import { getApiBaseUrl } from "../../lib/sentry-client.js";
6164
import type { SentryProject } from "../../types/index.js";
@@ -519,17 +522,85 @@ export async function handleOrgAll(
519522
* Handle project-search mode (bare slug, e.g., "sentry").
520523
* Searches for the project across all accessible organizations.
521524
*/
522-
export async function handleProjectSearch(
525+
/**
526+
* Handle the "no matching project" case — check for org match, attempt
527+
* fuzzy recovery (including display-name matching), or throw an error.
528+
*
529+
* Extracted from {@link handleProjectSearch} to stay within the cognitive
530+
* complexity budget.
531+
*/
532+
async function handleProjectNotFound(
523533
projectSlug: string,
524-
flags: ListFlags
534+
orgs: { slug: string }[],
535+
flags: ListFlags,
536+
options?: { originalSlug?: string; isRecoveryAttempt?: boolean }
525537
): Promise<ListResult<ProjectWithOrg>> {
526-
const { projects, orgs } = await withProgress(
527-
{
528-
message: `Fetching projects (up to ${flags.limit})...`,
529-
json: flags.json,
530-
},
531-
() => findProjectsBySlug(projectSlug)
538+
const { originalSlug, isRecoveryAttempt = false } = options ?? {};
539+
const displaySlug = originalSlug ?? projectSlug;
540+
541+
// Skip triage on recovery attempts to prevent infinite recursion.
542+
const outcome: ProjectNotFoundOutcome = isRecoveryAttempt
543+
? { kind: "not-found", displaySlug, suggestions: [] }
544+
: await triageProjectNotFound(projectSlug, orgs, originalSlug);
545+
546+
if (outcome.kind === "org-match") {
547+
const contextKey = buildContextKey(
548+
{ type: "org-all", org: projectSlug },
549+
flags,
550+
getApiBaseUrl()
551+
);
552+
const result = await handleOrgAll({
553+
org: projectSlug,
554+
flags,
555+
contextKey,
556+
cursor: undefined,
557+
direction: "first",
558+
});
559+
const r = result as ProjectListResult;
560+
r.title = `'${projectSlug}' is an organization, not a project. Showing all projects in '${projectSlug}'`;
561+
return r;
562+
}
563+
564+
if (outcome.kind === "fuzzy-match") {
565+
// Pass isRecoveryAttempt=true to prevent infinite recursion if the
566+
// fuzzy-recovered slug also fails to resolve.
567+
return handleProjectSearch(outcome.project, flags, undefined, true);
568+
}
569+
570+
// JSON mode returns empty array; human mode throws a helpful error
571+
if (flags.json) {
572+
return { items: [] };
573+
}
574+
throw new ResolutionError(
575+
`Project '${displaySlug}'`,
576+
"not found",
577+
`sentry project list <org>/${projectSlug}`,
578+
outcome.suggestions.length > 0
579+
? outcome.suggestions
580+
: ["No project with this slug found in any accessible organization"]
532581
);
582+
}
583+
584+
export async function handleProjectSearch(
585+
projectSlug: string,
586+
flags: ListFlags,
587+
/** Original user input before normalization — for clearer messages. */
588+
originalSlug?: string,
589+
/** @internal — prevents infinite recursion from fuzzy recovery. */
590+
_isRecoveryAttempt = false
591+
): Promise<ListResult<ProjectWithOrg>> {
592+
// When the input is a display name (originalSlug set, contains spaces),
593+
// skip the slug-based API lookup and go straight to name-based matching.
594+
const isDisplayName = originalSlug !== undefined;
595+
const { projects, orgs } = isDisplayName
596+
? { projects: [], orgs: await listOrganizations() }
597+
: await withProgress(
598+
{
599+
message: `Fetching projects (up to ${flags.limit})...`,
600+
json: flags.json,
601+
},
602+
() => findProjectsBySlug(projectSlug)
603+
);
533604
const filtered = filterByPlatform(projects, flags.platform);
534605

535606
if (filtered.length === 0) {
@@ -540,36 +611,10 @@ export async function handleProjectSearch(
540611
};
541612
}
542613

543-
// Check if slug matches an org — user likely meant "project list <org>/"
544-
const matchingOrg = orgs.find((o) => o.slug === projectSlug);
545-
if (matchingOrg) {
546-
const contextKey = buildContextKey(
547-
{ type: "org-all", org: projectSlug },
548-
flags,
549-
getApiBaseUrl()
550-
);
551-
const result = await handleOrgAll({
552-
org: projectSlug,
553-
flags,
554-
contextKey,
555-
cursor: undefined,
556-
direction: "first",
557-
});
558-
const r = result as ProjectListResult;
559-
r.title = `'${projectSlug}' is an organization, not a project. Showing all projects in '${projectSlug}'`;
560-
return r;
561-
}
562-
563-
// JSON mode returns empty array; human mode throws a helpful error
564-
if (flags.json) {
565-
return { items: [] };
566-
}
567-
throw new ResolutionError(
568-
`Project '${projectSlug}'`,
569-
"not found",
570-
`sentry project list <org>/${projectSlug}`,
571-
["No project with this slug found in any accessible organization"]
572-
);
614+
return handleProjectNotFound(projectSlug, orgs, flags, {
615+
originalSlug,
616+
isRecoveryAttempt: _isRecoveryAttempt,
617+
});
573618
}
574619

575620
const limited = filtered.slice(0, flags.limit);
@@ -686,7 +731,11 @@ export const listCommand = buildListCommand("project", {
686731
});
687732
},
688733
"project-search": (ctx) =>
689-
handleProjectSearch(ctx.parsed.projectSlug, flags),
734+
handleProjectSearch(
735+
ctx.parsed.projectSlug,
736+
flags,
737+
ctx.parsed.originalSlug
738+
),
690739
},
691740
});
692741

src/commands/project/view.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,8 @@ export const viewCommand = buildCommand({
238238
const resolved = await resolveProjectBySlug(
239239
parsed.projectSlug,
240240
USAGE_HINT,
241-
`sentry project view <org>/${parsed.projectSlug}`
241+
`sentry project view <org>/${parsed.projectSlug}`,
242+
parsed.originalSlug
242243
);
243244
resolvedTargets = [
244245
{

src/lib/arg-parsing.ts

Lines changed: 47 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import type { LogSortDirection } from "./api/logs.js";
1010
import { ContextError, ValidationError } from "./errors.js";
1111
import { validateResourceId } from "./input-validation.js";
12-
import { logger } from "./logger.js";
12+
1313
import type { ParsedSentryUrl } from "./sentry-url-parser.js";
1414
import { applySentryUrlContext, parseSentryUrl } from "./sentry-url-parser.js";
1515
import { isAllDigits } from "./utils.js";
@@ -19,81 +19,33 @@ import { isAllDigits } from "./utils.js";
1919
// ---------------------------------------------------------------------------
2020

2121
/**
22-
* Normalize a Sentry slug by converting display-name-style input to slug form.
23-
*
24-
* Sentry slugs are always lowercase and use dashes as separators. Users
25-
* sometimes paste display names like `"My Project"` instead of
26-
* `"my-project"`; without normalization, `validateResourceId` would reject
27-
* those outright for containing spaces. Normalization gives us a chance to
28-
* recover the intent before validation runs.
22+
* Normalize a Sentry slug from user input.
2923
*
30-
* Normalization rules (applied only when spaces are present):
31-
* 1. Lowercase the entire string
32-
* 2. Collapse runs of spaces into a single dash
33-
* 3. Trim leading/trailing dashes (from leading/trailing whitespace)
24+
* After the removal of underscore normalization (#771) and the move to
25+
* display-name matching for space-containing inputs (#772), this function
26+
* is effectively a no-op — valid slug characters pass through unchanged.
27+
* It remains as a stable call-site for future normalization rules.
3428
*
35-
* Underscores are **not** normalized — Sentry accepts underscores in project
36-
* slugs at creation time, so rewriting them here would silently break
37-
* lookups for any customer who has one (see #770). Since underscores are
38-
* not in `validateResourceId`'s forbidden set either, they flow through
39-
* untouched.
29+
* Spaces are handled separately: {@link looksLikeDisplayName} detects them
30+
* and the parsing layer routes to a display-name search path that skips
31+
* slug validation and API lookup entirely.
4032
*
4133
* @param slug - Raw slug string from CLI input
42-
* @returns Normalized slug and whether normalization was applied
43-
*
44-
* @example
45-
* normalizeSlug("My Project") // { slug: "my-project", normalized: true }
46-
* normalizeSlug("My Project") // { slug: "my-project", normalized: true }
47-
* normalizeSlug("selfbase_admin_backend") // { slug: "selfbase_admin_backend", normalized: false }
48-
* normalizeSlug("my-project") // { slug: "my-project", normalized: false }
34+
* @returns The slug unchanged with `normalized: false`
4935
*/
5036
export function normalizeSlug(slug: string): {
5137
slug: string;
5238
normalized: boolean;
5339
} {
54-
if (!slug.includes(" ")) {
55-
return { slug, normalized: false };
56-
}
57-
58-
// Spaces imply a display name — slugs are always lowercase.
59-
// Collapse runs of spaces into a single dash, then trim any edge dashes
60-
// introduced by leading/trailing whitespace in the input.
61-
const result = slug
62-
.toLowerCase()
63-
.replace(/ +/g, "-")
64-
.replace(/^-+|-+$/g, "");
65-
66-
return { slug: result, normalized: true };
40+
return { slug, normalized: false };
6741
}
6842

69-
const log = logger.withTag("arg-parsing");
70-
7143
/**
72-
* Emit a warning when slug normalization changed the input.
73-
* Called internally by {@link parseOrgProjectArg} — callers do not need to
74-
* check `parsed.normalized` themselves.
44+
* Check if a string looks like a display name rather than a slug.
45+
* Display names contain spaces, which are never valid in slugs.
7546
*/
76-
function warnNormalized(
77-
parsed: Exclude<ParsedOrgProject, { type: "auto-detect" }>
78-
): void {
79-
let slug: string;
80-
switch (parsed.type) {
81-
case "explicit":
82-
slug = `${parsed.org}/${parsed.project}`;
83-
break;
84-
case "org-all":
85-
slug = `${parsed.org}/`;
86-
break;
87-
case "project-search":
88-
slug = parsed.projectSlug;
89-
break;
90-
default:
91-
return;
92-
}
93-
94-
log.warn(
95-
`Normalized slug to '${slug}' (Sentry slugs use lowercase with dashes, not spaces)`
96-
);
47+
function looksLikeDisplayName(input: string): boolean {
48+
return input.includes(" ");
9749
}
9850

9951
// ---------------------------------------------------------------------------
@@ -413,6 +365,13 @@ export type ParsedOrgProject =
413365
projectSlug: string;
414366
/** True if project slug was normalized */
415367
normalized?: boolean;
368+
/**
369+
* Pre-normalization input when {@link normalized} is `true`.
370+
* Used by the resolution layer to produce user-friendly messages
371+
* that reference what the user actually typed rather than the
372+
* intermediate normalized form.
373+
*/
374+
originalSlug?: string;
416375
}
417376
| { type: typeof ProjectSpecificationType.AutoDetect };
418377

@@ -541,12 +500,20 @@ function parseSlashOrgProject(input: string): ParsedOrgProject {
541500
);
542501
}
543502
rejectAtSelector(rawProject, "project slug");
503+
if (looksLikeDisplayName(rawProject)) {
504+
// Spaces → display name, not a slug. Skip slug validation and let
505+
// the resolution layer do a fuzzy name-based search.
506+
return {
507+
type: "project-search",
508+
projectSlug: rawProject,
509+
originalSlug: rawProject,
510+
};
511+
}
544512
const np = normalizeSlug(rawProject);
545513
validateResourceId(np.slug, "project slug");
546514
return {
547515
type: "project-search",
548516
projectSlug: np.slug,
549-
...(np.normalized && { normalized: true }),
550517
};
551518
}
552519

@@ -617,17 +584,22 @@ export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject {
617584
} else {
618585
// No slash → search for project across all orgs
619586
rejectAtSelector(trimmed, "project slug");
620-
const np = normalizeSlug(trimmed);
621-
validateResourceId(np.slug, "project slug");
622-
parsed = {
623-
type: "project-search",
624-
projectSlug: np.slug,
625-
...(np.normalized && { normalized: true }),
626-
};
627-
}
628-
629-
if (parsed.type !== "auto-detect" && parsed.normalized) {
630-
warnNormalized(parsed);
587+
if (looksLikeDisplayName(trimmed)) {
588+
// Spaces → display name, not a slug. Skip slug validation and let
589+
// the resolution layer do a fuzzy name-based search.
590+
parsed = {
591+
type: "project-search",
592+
projectSlug: trimmed,
593+
originalSlug: trimmed,
594+
};
595+
} else {
596+
const np = normalizeSlug(trimmed);
597+
validateResourceId(np.slug, "project slug");
598+
parsed = {
599+
type: "project-search",
600+
projectSlug: np.slug,
601+
};
602+
}
631603
}
632604

633605
return parsed;

0 commit comments

Comments
 (0)