Skip to content

Commit c8b6330

Browse files
committed
fix: support numeric project IDs in project slug resolution
When users pass a numeric project ID (e.g., `sentry event view 7275560680 <event-id>`), `findProjectsBySlug` would reject the API result because the returned slug didn't match the numeric input. The Sentry API already accepts numeric IDs via its `project_id_or_slug` parameter, but the CLI discarded these results. Now `findProjectsBySlug` accepts the API result when the input is all-digits, allowing numeric project IDs to resolve correctly across all commands that use project slug search (event view, project view, trace view, log view). When a numeric ID resolves successfully, a stderr hint suggests using the slug form for faster lookups. If resolution fails (wrong ID or no access), the error message notes that numeric IDs may not be supported. Fixes CLI-7W
1 parent 089a521 commit c8b6330

File tree

8 files changed

+127
-9
lines changed

8 files changed

+127
-9
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: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -790,15 +790,19 @@ 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+
if (!isNumericId && project.slug !== projectSlug) {
802806
return null;
803807
}
804808
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+
? "Numeric project IDs are not supported — 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/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)