Skip to content

Commit 5ff91e4

Browse files
authored
fix: support numeric project IDs in project slug resolution (#284)
## Summary - Fixes **CLI-7W**: Users passing a numeric Sentry project ID (e.g., `sentry event view 7275560680 <event-id>`) got a confusing "Project not found" error - Also resolves **CLI-80** as already fixed by PR #279 (cursor format validation) ## Changes **`findProjectsBySlug` (api-client.ts):** When the input is all-digits, accept the API result even when the returned slug differs from the input. The Sentry API's `project_id_or_slug` parameter already resolves numeric IDs — the CLI just wasn't accepting those results. **`resolveProjectBySlug` (resolve-target.ts):** - When a numeric ID resolves successfully, prints a stderr hint: `Tip: Resolved project ID 7275560680 to acme/frontend. Use the slug form for faster lookups.` - When resolution fails with an all-digit input, the error message says "Numeric project IDs are not supported" instead of the generic "Check that you have access" **View commands:** Pass `this.stderr` to `resolveProjectBySlug` so the hint can be displayed. ## Testing - 2 new tests for `findProjectsBySlug`: numeric ID resolution accepted, non-numeric slug mismatch still rejected - 1 updated test for `project view` func (new `stderr` parameter) - All 2010 tests pass
1 parent 919c59a commit 5ff91e4

File tree

9 files changed

+195
-10
lines changed

9 files changed

+195
-10
lines changed

src/commands/event/view.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,8 @@ export const viewCommand = buildCommand({
206206
const resolved = await resolveProjectBySlug(
207207
parsed.projectSlug,
208208
USAGE_HINT,
209-
`sentry event view <org>/${parsed.projectSlug} ${eventId}`
209+
`sentry event view <org>/${parsed.projectSlug} ${eventId}`,
210+
this.stderr
210211
);
211212
target = {
212213
...resolved,

src/commands/log/view.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ export const viewCommand = buildCommand({
160160
target = await resolveProjectBySlug(
161161
parsed.projectSlug,
162162
USAGE_HINT,
163-
`sentry log view <org>/${parsed.projectSlug} ${logId}`
163+
`sentry log view <org>/${parsed.projectSlug} ${logId}`,
164+
this.stderr
164165
);
165166
break;
166167

src/commands/project/view.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,8 @@ export const viewCommand = buildCommand({
265265
const resolved = await resolveProjectBySlug(
266266
parsed.projectSlug,
267267
USAGE_HINT,
268-
`sentry project view <org>/${parsed.projectSlug}`
268+
`sentry project view <org>/${parsed.projectSlug}`,
269+
this.stderr
269270
);
270271
resolvedTargets = [
271272
{

src/commands/trace/view.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ export const viewCommand = buildCommand({
170170
target = await resolveProjectBySlug(
171171
parsed.projectSlug,
172172
USAGE_HINT,
173-
`sentry trace view <org>/${parsed.projectSlug} ${traceId}`
173+
`sentry trace view <org>/${parsed.projectSlug} ${traceId}`,
174+
this.stderr
174175
);
175176
break;
176177

src/lib/api-client.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -790,15 +790,21 @@ export async function findProjectsBySlug(
790790
projectSlug: string
791791
): Promise<ProjectWithOrg[]> {
792792
const orgs = await listOrganizations();
793+
const isNumericId = isAllDigits(projectSlug);
793794

794795
// Direct lookup in parallel — one API call per org instead of paginating all projects
795796
const searchResults = await Promise.all(
796797
orgs.map(async (org) => {
797798
try {
798799
const project = await getProject(org.slug, projectSlug);
799800
// The API accepts project_id_or_slug, so a numeric input could
800-
// resolve by ID. Verify the returned slug actually matches.
801-
if (project.slug !== projectSlug) {
801+
// resolve by ID instead of slug. When the input is all digits,
802+
// accept the match (the user passed a numeric project ID).
803+
// For non-numeric inputs, verify the slug actually matches to
804+
// avoid false positives from coincidental ID collisions.
805+
// Note: Sentry enforces that project slugs must start with a letter,
806+
// so an all-digits input can only ever be a numeric ID, never a slug.
807+
if (!isNumericId && project.slug !== projectSlug) {
802808
return null;
803809
}
804810
return { ...project, orgSlug: org.slug };

src/lib/resolve-target.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import {
3838
getDsnSourceDescription,
3939
} from "./dsn/index.js";
4040
import { AuthError, ContextError, ValidationError } from "./errors.js";
41+
import { warning } from "./formatters/colors.js";
42+
import { isAllDigits } from "./utils.js";
4143

4244
/**
4345
* Resolved organization and project target for API calls.
@@ -786,12 +788,15 @@ export async function resolveOrg(
786788
export async function resolveProjectBySlug(
787789
projectSlug: string,
788790
usageHint: string,
789-
disambiguationExample?: string
791+
disambiguationExample?: string,
792+
stderr?: { write(s: string): void }
790793
): Promise<{ org: string; project: string }> {
791794
const found = await findProjectsBySlug(projectSlug);
792795
if (found.length === 0) {
793796
throw new ContextError(`Project "${projectSlug}"`, usageHint, [
794-
"Check that you have access to a project with this slug",
797+
isAllDigits(projectSlug)
798+
? "No project with this ID was found — check the ID or use the project slug instead"
799+
: "Check that you have access to a project with this slug",
795800
]);
796801
}
797802
if (found.length > 1) {
@@ -805,6 +810,17 @@ export async function resolveProjectBySlug(
805810
);
806811
}
807812
const foundProject = found[0] as (typeof found)[0];
813+
814+
// When a numeric project ID resolved successfully, hint about using the slug
815+
if (stderr && isAllDigits(projectSlug) && foundProject.slug !== projectSlug) {
816+
stderr.write(
817+
warning(
818+
`Tip: Resolved project ID ${projectSlug} to ${foundProject.orgSlug}/${foundProject.slug}. ` +
819+
"Use the slug form for faster lookups.\n"
820+
)
821+
);
822+
}
823+
808824
return {
809825
org: foundProject.orgSlug,
810826
project: foundProject.slug,

test/commands/event/view.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@
55
* in src/commands/event/view.ts
66
*/
77

8-
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
8+
import {
9+
afterEach,
10+
beforeEach,
11+
describe,
12+
expect,
13+
mock,
14+
spyOn,
15+
test,
16+
} from "bun:test";
917
import { parsePositionalArgs } from "../../../src/commands/event/view.js";
1018
import type { ProjectWithOrg } from "../../../src/lib/api-client.js";
1119
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
@@ -327,4 +335,61 @@ describe("resolveProjectBySlug", () => {
327335
expect(result.project).toBe("web-frontend");
328336
});
329337
});
338+
339+
describe("numeric project ID", () => {
340+
test("uses numeric-ID-specific error when not found", async () => {
341+
findProjectsBySlugSpy.mockResolvedValue([]);
342+
343+
try {
344+
await resolveProjectBySlug("7275560680", HINT);
345+
expect.unreachable("Should have thrown");
346+
} catch (error) {
347+
expect(error).toBeInstanceOf(ContextError);
348+
const message = (error as ContextError).message;
349+
expect(message).toContain('Project "7275560680"');
350+
expect(message).toContain("No project with this ID was found");
351+
}
352+
});
353+
354+
test("writes stderr hint when numeric ID resolves to a different slug", async () => {
355+
findProjectsBySlugSpy.mockResolvedValue([
356+
{
357+
slug: "my-frontend",
358+
orgSlug: "acme",
359+
id: "7275560680",
360+
name: "Frontend",
361+
},
362+
] as ProjectWithOrg[]);
363+
const stderrWrite = mock(() => true);
364+
const stderr = { write: stderrWrite };
365+
366+
const result = await resolveProjectBySlug(
367+
"7275560680",
368+
HINT,
369+
undefined,
370+
stderr
371+
);
372+
373+
expect(result).toEqual({ org: "acme", project: "my-frontend" });
374+
expect(stderrWrite).toHaveBeenCalledTimes(1);
375+
const hint = stderrWrite.mock.calls[0][0] as string;
376+
expect(hint).toContain("7275560680");
377+
expect(hint).toContain("acme/my-frontend");
378+
});
379+
380+
test("does not write hint when stderr is not provided", async () => {
381+
findProjectsBySlugSpy.mockResolvedValue([
382+
{
383+
slug: "my-frontend",
384+
orgSlug: "acme",
385+
id: "7275560680",
386+
name: "Frontend",
387+
},
388+
] as ProjectWithOrg[]);
389+
390+
// Should not throw even without stderr
391+
const result = await resolveProjectBySlug("7275560680", HINT);
392+
expect(result).toEqual({ org: "acme", project: "my-frontend" });
393+
});
394+
});
330395
});

test/commands/project/view.func.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ describe("viewCommand.func", () => {
170170
expect(resolveProjectBySlugSpy).toHaveBeenCalledWith(
171171
"frontend",
172172
"sentry project view <org>/<project>",
173-
"sentry project view <org>/frontend"
173+
"sentry project view <org>/frontend",
174+
context.stderr
174175
);
175176
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
176177
const parsed = JSON.parse(output);

test/lib/api-client.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,99 @@ describe("findProjectsBySlug", () => {
729729
expect(results).toHaveLength(1);
730730
expect(results[0].orgSlug).toBe("acme");
731731
});
732+
733+
test("resolves numeric project ID when slug differs", async () => {
734+
const { findProjectsBySlug } = await import("../../src/lib/api-client.js");
735+
736+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
737+
const req = new Request(input, init);
738+
const url = req.url;
739+
740+
// Regions endpoint
741+
if (url.includes("/users/me/regions/")) {
742+
return new Response(
743+
JSON.stringify({
744+
regions: [{ name: "us", url: "https://us.sentry.io" }],
745+
}),
746+
{ status: 200, headers: { "Content-Type": "application/json" } }
747+
);
748+
}
749+
750+
// Organizations list
751+
if (url.includes("/organizations/") && !url.includes("/projects/")) {
752+
return new Response(
753+
JSON.stringify([{ id: "1", slug: "acme", name: "Acme Corp" }]),
754+
{ status: 200, headers: { "Content-Type": "application/json" } }
755+
);
756+
}
757+
758+
// getProject for acme/7275560680 - API resolves by numeric ID,
759+
// returns project with a different slug
760+
if (url.includes("/projects/acme/7275560680/")) {
761+
return new Response(
762+
JSON.stringify({
763+
id: "7275560680",
764+
slug: "frontend",
765+
name: "Frontend",
766+
}),
767+
{ status: 200, headers: { "Content-Type": "application/json" } }
768+
);
769+
}
770+
771+
return new Response(JSON.stringify({ detail: "Not found" }), {
772+
status: 404,
773+
headers: { "Content-Type": "application/json" },
774+
});
775+
};
776+
777+
// Numeric ID should resolve even though returned slug differs
778+
const results = await findProjectsBySlug("7275560680");
779+
expect(results).toHaveLength(1);
780+
expect(results[0].slug).toBe("frontend");
781+
expect(results[0].orgSlug).toBe("acme");
782+
});
783+
784+
test("rejects non-numeric input when returned slug differs", async () => {
785+
const { findProjectsBySlug } = await import("../../src/lib/api-client.js");
786+
787+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
788+
const req = new Request(input, init);
789+
const url = req.url;
790+
791+
if (url.includes("/users/me/regions/")) {
792+
return new Response(
793+
JSON.stringify({
794+
regions: [{ name: "us", url: "https://us.sentry.io" }],
795+
}),
796+
{ status: 200, headers: { "Content-Type": "application/json" } }
797+
);
798+
}
799+
800+
if (url.includes("/organizations/") && !url.includes("/projects/")) {
801+
return new Response(
802+
JSON.stringify([{ id: "1", slug: "acme", name: "Acme Corp" }]),
803+
{ status: 200, headers: { "Content-Type": "application/json" } }
804+
);
805+
}
806+
807+
// API returns project with different slug (coincidental ID match)
808+
if (url.includes("/projects/acme/wrong-slug/")) {
809+
return new Response(
810+
JSON.stringify({ id: "999", slug: "actual-slug", name: "Actual" }),
811+
{ status: 200, headers: { "Content-Type": "application/json" } }
812+
);
813+
}
814+
815+
return new Response(JSON.stringify({ detail: "Not found" }), {
816+
status: 404,
817+
headers: { "Content-Type": "application/json" },
818+
});
819+
};
820+
821+
// Non-numeric input with slug mismatch should be rejected
822+
const results = await findProjectsBySlug("wrong-slug");
823+
expect(results).toHaveLength(0);
824+
});
732825
});
733826

734827
describe("listTeamsPaginated", () => {

0 commit comments

Comments
 (0)