Skip to content

Commit 83cb197

Browse files
betegonclaude
andcommitted
fix(init): resolve numeric org ID from DSN and prompt when Sentry already configured
When `sentry init .` is run in a project with an existing Sentry DSN, the CLI was extracting the numeric org ID (e.g. `4507492088676352`) from the DSN and passing it directly to the API as an org slug. If the org belonged to a different Sentry account (or the org regions cache was empty after a fresh install), this caused a confusing 404 error: "Organization '4507492088676352' not found." Fix the numeric ID fallback in `resolveOrgFromDsn`: instead of returning the raw numeric ID, look it up in the org regions cache via `getOrgByNumericId`. If found, use the real slug. If not (empty cache or inaccessible org), return null so the caller falls through to `listOrganizations()` for proper interactive org selection. Also add a UX improvement: before creating a new Sentry project, detect whether the codebase already has a Sentry DSN that resolves to an accessible project. If so, prompt the user: "Found an existing Sentry project. Use it or create a new one?" This avoids silently creating a duplicate project when Sentry is already configured. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 88b6f36 commit 83cb197

3 files changed

Lines changed: 135 additions & 7 deletions

File tree

src/lib/init/local-ops.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,54 @@ async function tryGetExistingProject(
744744
}
745745
}
746746

747+
/**
748+
* Detect an existing Sentry project by looking for a DSN in the project.
749+
*
750+
* Returns org and project slugs when the DSN's project can be resolved —
751+
* either from the local cache or via API (when the org is accessible).
752+
* Returns null when no DSN is found or the org belongs to a different account.
753+
*/
754+
async function detectExistingProject(cwd: string): Promise<{
755+
orgSlug: string;
756+
projectSlug: string;
757+
} | null> {
758+
const { detectDsn } = await import("../dsn/index.js");
759+
const dsn = await detectDsn(cwd);
760+
if (!dsn?.publicKey) return null;
761+
762+
// Check public-key cache first (no API call if previously resolved)
763+
const { getCachedProjectByDsnKey } = await import("../db/project-cache.js");
764+
const cached = await getCachedProjectByDsnKey(dsn.publicKey);
765+
if (cached) {
766+
return { orgSlug: cached.orgSlug, projectSlug: cached.projectSlug };
767+
}
768+
769+
// Cache miss — try API (only succeeds if user has access to the org)
770+
try {
771+
const { findProjectByDsnKey } = await import("../api-client.js");
772+
const project = await findProjectByDsnKey(dsn.publicKey);
773+
if (project?.organization?.slug && project.slug) {
774+
const { setCachedProjectByDsnKey } = await import(
775+
"../db/project-cache.js"
776+
);
777+
await setCachedProjectByDsnKey(dsn.publicKey, {
778+
orgSlug: project.organization.slug,
779+
orgName: project.organization.name ?? project.organization.slug,
780+
projectSlug: project.slug,
781+
projectName: project.name,
782+
projectId: project.id,
783+
});
784+
return {
785+
orgSlug: project.organization.slug,
786+
projectSlug: project.slug,
787+
};
788+
}
789+
} catch {
790+
// Org inaccessible (different account) or network error — fall through
791+
}
792+
return null;
793+
}
794+
747795
async function createSentryProject(
748796
payload: CreateSentryProjectPayload,
749797
options: WizardOptions
@@ -773,6 +821,48 @@ async function createSentryProject(
773821
};
774822
}
775823

824+
// When no explicit org/project provided, check if Sentry is already set up
825+
if (!options.org && !options.project) {
826+
const existing = await detectExistingProject(payload.cwd);
827+
if (existing) {
828+
if (options.yes) {
829+
// Non-interactive: auto-use existing project
830+
const result = await tryGetExistingProject(
831+
existing.orgSlug,
832+
existing.projectSlug
833+
);
834+
if (result) return result;
835+
} else {
836+
const choice = await select({
837+
message: "Found an existing Sentry project in this codebase.",
838+
options: [
839+
{
840+
value: "existing" as const,
841+
label: `Use existing project (${existing.orgSlug}/${existing.projectSlug})`,
842+
hint: "Sentry is already configured here",
843+
},
844+
{
845+
value: "create" as const,
846+
label: "Create a new Sentry project",
847+
},
848+
],
849+
});
850+
if (isCancel(choice)) {
851+
return { ok: false, error: "Cancelled." };
852+
}
853+
if (choice === "existing") {
854+
const result = await tryGetExistingProject(
855+
existing.orgSlug,
856+
existing.projectSlug
857+
);
858+
if (result) return result;
859+
// Project deleted or inaccessible — fall through to creation
860+
}
861+
// choice === "create": fall through to normal org resolution + creation
862+
}
863+
}
864+
}
865+
776866
try {
777867
// 1. Resolve org — skip interactive resolution if explicitly provided via CLI arg
778868
let orgSlug: string;

src/lib/resolve-target.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,11 +231,18 @@ export async function resolveOrgFromDsn(
231231
}
232232
}
233233

234-
// Fall back to numeric org ID (API accepts both slug and numeric ID)
235-
return {
236-
org: dsn.orgId,
237-
detectedFrom,
238-
};
234+
// Check org regions cache for a slug mapped to this numeric ID.
235+
// The cache is populated by `sentry auth login` and `org list`.
236+
const { getOrgByNumericId } = await import("./db/regions.js");
237+
const orgMatch = await getOrgByNumericId(dsn.orgId);
238+
if (orgMatch) {
239+
return { org: orgMatch.slug, detectedFrom };
240+
}
241+
242+
// Can't resolve to slug (empty cache or org belongs to a different account).
243+
// Return null so the caller falls through to listOrganizations() for proper
244+
// interactive org selection instead of passing a raw numeric ID to the API.
245+
return null;
239246
}
240247

241248
/**

test/isolated/resolve-target.test.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const mockGetProject = mock(() =>
5353
);
5454
const mockFindProjectByDsnKey = mock(() => Promise.resolve(null));
5555
const mockFindProjectsByPattern = mock(() => Promise.resolve([]));
56+
const mockGetOrgByNumericId = mock(() => Promise.resolve(undefined));
5657

5758
// Mock all dependency modules
5859
mock.module("../../src/lib/db/defaults.js", () => ({
@@ -91,6 +92,10 @@ mock.module("../../src/lib/api-client.js", () => ({
9192
findProjectsByPattern: mockFindProjectsByPattern,
9293
}));
9394

95+
mock.module("../../src/lib/db/regions.js", () => ({
96+
getOrgByNumericId: mockGetOrgByNumericId,
97+
}));
98+
9499
import { ContextError } from "../../src/lib/errors.js";
95100
// Now import the module under test (after mocks are set up)
96101
import {
@@ -118,6 +123,7 @@ function resetAllMocks() {
118123
mockGetProject.mockReset();
119124
mockFindProjectByDsnKey.mockReset();
120125
mockFindProjectsByPattern.mockReset();
126+
mockGetOrgByNumericId.mockReset();
121127

122128
// Set sensible defaults
123129
mockGetDefaultOrganization.mockResolvedValue(null);
@@ -139,6 +145,7 @@ function resetAllMocks() {
139145
mockGetCachedProject.mockResolvedValue(null);
140146
mockGetCachedProjectByDsnKey.mockResolvedValue(null);
141147
mockGetCachedDsn.mockResolvedValue(null);
148+
mockGetOrgByNumericId.mockResolvedValue(undefined);
142149
mockFindProjectsByPattern.mockResolvedValue([]);
143150
}
144151

@@ -198,7 +205,26 @@ describe("resolveOrg", () => {
198205
expect(mockDetectDsn).toHaveBeenCalled();
199206
});
200207

201-
test("returns numeric orgId when DSN detected but no cache", async () => {
208+
test("returns null when DSN detected but no project/org cache", async () => {
209+
mockGetDefaultOrganization.mockResolvedValue(null);
210+
mockDetectDsn.mockResolvedValue({
211+
raw: "https://abc@o123.ingest.sentry.io/456",
212+
protocol: "https",
213+
publicKey: "abc",
214+
host: "o123.ingest.sentry.io",
215+
projectId: "456",
216+
orgId: "123",
217+
source: "env",
218+
});
219+
mockGetCachedProject.mockResolvedValue(null);
220+
mockGetOrgByNumericId.mockResolvedValue(undefined);
221+
222+
const result = await resolveOrg({ cwd: "/test" });
223+
224+
expect(result).toBeNull();
225+
});
226+
227+
test("resolves org slug from regions cache when project cache misses", async () => {
202228
mockGetDefaultOrganization.mockResolvedValue(null);
203229
mockDetectDsn.mockResolvedValue({
204230
raw: "https://abc@o123.ingest.sentry.io/456",
@@ -210,11 +236,16 @@ describe("resolveOrg", () => {
210236
source: "env",
211237
});
212238
mockGetCachedProject.mockResolvedValue(null);
239+
mockGetOrgByNumericId.mockResolvedValue({
240+
slug: "my-org",
241+
regionUrl: "https://sentry.io",
242+
});
213243

214244
const result = await resolveOrg({ cwd: "/test" });
215245

216246
expect(result).not.toBeNull();
217-
expect(result?.org).toBe("123");
247+
expect(result?.org).toBe("my-org");
248+
expect(mockGetOrgByNumericId).toHaveBeenCalledWith("123");
218249
});
219250

220251
test("returns null when no org found from any source", async () => {

0 commit comments

Comments
 (0)