Skip to content

Commit 946190f

Browse files
authored
fix: reject @-selectors in parseOrgProjectArg with helpful redirect (#557)
Fixes [CLI-MH](https://sentry.sentry.io/issues/7361340621/): `ResolutionError: Project '@latest' not found.` A user ran `sentry issues @latest` expecting the `@latest` magic selector to work. However, `@latest` is only recognized by `parseIssueArg()` (used in `issue view/explain/plan`), not by `parseOrgProjectArg()` (used in list commands). Since `@` was not in `RESOURCE_ID_FORBIDDEN`, it passed validation, entered `project-search` mode, and produced a confusing "Project '@latest' not found" error. ### Fix Added `rejectAtSelector()` in `src/lib/arg-parsing.ts` that catches `@`-prefixed values before they reach project resolution: - **Known selectors** (`@latest`, `@most_frequent`) → `ValidationError` with redirect: `Use: sentry issue view @latest` - **Unknown `@`-prefixed values** → `ValidationError`: slugs don't start with `@` Applied in three locations: 1. `parseOrgProjectArg()` bare-slug branch (`@latest`) 2. `parseSlashOrgProject()` leading-slash branch (`/@latest`) 3. `parseSlashOrgProject()` explicit org branch (`sentry/@latest`)
1 parent 7feea05 commit 946190f

File tree

2 files changed

+97
-0
lines changed

2 files changed

+97
-0
lines changed

src/lib/arg-parsing.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,39 @@ function issueArgFromUrl(parsed: ParsedSentryUrl): ParsedIssueArg | null {
418418
};
419419
}
420420

421+
/**
422+
* Reject `@`-prefixed values in org/project positions.
423+
*
424+
* `@latest` and `@most_frequent` are issue selectors supported by
425+
* `parseIssueArg()` (for `issue view`, `explain`, `plan`). They are not
426+
* valid project slugs. This guard provides a helpful redirect instead of
427+
* the confusing "Project '@latest' not found" resolution error.
428+
*
429+
* Unknown `@`-prefixed values are also rejected — `@` is never valid in
430+
* Sentry slugs.
431+
*/
432+
function rejectAtSelector(value: string, label: string): void {
433+
if (!value.startsWith("@")) {
434+
return;
435+
}
436+
437+
const selector = parseSelector(value);
438+
if (selector) {
439+
const article = "aeiouAEIOU".includes(label.charAt(0)) ? "an" : "a";
440+
throw new ValidationError(
441+
`'${value}' is an issue selector, not ${article} ${label}.\n` +
442+
` Use: sentry issue view ${value}`,
443+
label
444+
);
445+
}
446+
447+
throw new ValidationError(
448+
`Invalid ${label}: '${value}' starts with '@'.\n` +
449+
" Slugs contain only letters, numbers, hyphens, and underscores.",
450+
label
451+
);
452+
}
453+
421454
/**
422455
* Parse a slash-delimited `org/project` string into a {@link ParsedOrgProject}.
423456
* Applies {@link normalizeSlug} to both components and validates against
@@ -435,6 +468,7 @@ function parseSlashOrgProject(input: string): ParsedOrgProject {
435468
'Invalid format: "/" requires a project slug (e.g., "/cli")'
436469
);
437470
}
471+
rejectAtSelector(rawProject, "project slug");
438472
validateResourceId(rawProject, "project slug");
439473
const np = normalizeSlug(rawProject);
440474
return {
@@ -444,6 +478,7 @@ function parseSlashOrgProject(input: string): ParsedOrgProject {
444478
};
445479
}
446480

481+
rejectAtSelector(rawOrg, "organization slug");
447482
validateResourceId(rawOrg, "organization slug");
448483
const no = normalizeSlug(rawOrg);
449484

@@ -457,6 +492,7 @@ function parseSlashOrgProject(input: string): ParsedOrgProject {
457492
}
458493

459494
// "sentry/cli" → explicit org and project
495+
rejectAtSelector(rawProject, "project slug");
460496
validateResourceId(rawProject, "project slug");
461497
const np = normalizeSlug(rawProject);
462498
const normalized = no.normalized || np.normalized;
@@ -508,6 +544,7 @@ export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject {
508544
parsed = parseSlashOrgProject(trimmed);
509545
} else {
510546
// No slash → search for project across all orgs
547+
rejectAtSelector(trimmed, "project slug");
511548
validateResourceId(trimmed, "project slug");
512549
const np = normalizeSlug(trimmed);
513550
parsed = {

test/lib/arg-parsing.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,66 @@ describe("parseOrgProjectArg", () => {
251251
expect(stderrOutput).not.toContain("Normalized slug");
252252
});
253253
});
254+
255+
describe("@-selector rejection", () => {
256+
test("@latest throws with redirect to issue view", () => {
257+
expect(() => parseOrgProjectArg("@latest")).toThrow(
258+
"is an issue selector, not a project slug"
259+
);
260+
expect(() => parseOrgProjectArg("@latest")).toThrow(
261+
"sentry issue view @latest"
262+
);
263+
});
264+
265+
test("@most_frequent throws with redirect to issue view", () => {
266+
expect(() => parseOrgProjectArg("@most_frequent")).toThrow(
267+
"is an issue selector, not a project slug"
268+
);
269+
expect(() => parseOrgProjectArg("@most_frequent")).toThrow(
270+
"sentry issue view @most_frequent"
271+
);
272+
});
273+
274+
test("case-insensitive selector variants are rejected", () => {
275+
expect(() => parseOrgProjectArg("@Latest")).toThrow(
276+
"is an issue selector"
277+
);
278+
expect(() => parseOrgProjectArg("@LATEST")).toThrow(
279+
"is an issue selector"
280+
);
281+
expect(() => parseOrgProjectArg("@mostFrequent")).toThrow(
282+
"is an issue selector"
283+
);
284+
});
285+
286+
test("unknown @-prefixed value throws as invalid slug", () => {
287+
expect(() => parseOrgProjectArg("@unknown")).toThrow("starts with '@'");
288+
});
289+
290+
test("/@latest (leading slash) throws with redirect", () => {
291+
expect(() => parseOrgProjectArg("/@latest")).toThrow(
292+
"is an issue selector"
293+
);
294+
});
295+
296+
test("sentry/@latest (org/selector) throws with redirect", () => {
297+
expect(() => parseOrgProjectArg("sentry/@latest")).toThrow(
298+
"is an issue selector"
299+
);
300+
});
301+
302+
test("@latest/project (selector as org) throws", () => {
303+
expect(() => parseOrgProjectArg("@latest/cli")).toThrow(
304+
"is an issue selector, not an organization slug"
305+
);
306+
});
307+
308+
test("@unknown/project (unknown @ as org) throws", () => {
309+
expect(() => parseOrgProjectArg("@unknown/cli")).toThrow(
310+
"starts with '@'"
311+
);
312+
});
313+
});
254314
});
255315

256316
describe("parseIssueArg", () => {

0 commit comments

Comments
 (0)