From bf80e532e8d23b67b87c279cd6e38409ba92060a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 6 Apr 2026 16:42:21 +0200 Subject: [PATCH 1/2] refactor(init): reuse resolveOrg for offline-first org detection resolveOrgSlug was reimplementing org detection (DSN scanning + numeric ID resolution) that resolveOrg in resolve-target.ts already handles, including env vars and config defaults that the init version missed. Now calls resolveOrg() first (flags, env vars, defaults, DSN), and only falls through to listOrganizations + interactive select when it returns null. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/init/local-ops.ts | 39 ++++++----------- .../local-ops.create-sentry-project.test.ts | 43 +++++-------------- 2 files changed, 24 insertions(+), 58 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 92760e692..9091c06fa 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -16,6 +16,7 @@ import { tryGetPrimaryDsn, } from "../api-client.js"; import { ApiError } from "../errors.js"; +import { resolveOrg } from "../resolve-target.js"; import { resolveOrCreateTeam } from "../resolve-team.js"; import { buildProjectUrl } from "../sentry-urls.js"; import { slugify } from "../utils.js"; @@ -25,7 +26,6 @@ import { MAX_FILE_BYTES, MAX_OUTPUT_BYTES, } from "./constants.js"; -import { resolveOrgPrefetched } from "./prefetch.js"; import type { ApplyPatchsetPayload, CreateSentryProjectPayload, @@ -40,9 +40,6 @@ import type { WizardOptions, } from "./types.js"; -/** Matches a bare numeric org ID extracted from a DSN (e.g. "4507492088676352"). */ -const NUMERIC_ORG_ID_RE = /^\d+$/; - /** Whitespace characters used for JSON indentation. */ const Indenter = { SPACE: " ", @@ -710,16 +707,17 @@ async function applyPatchset( } /** - * Resolve the org slug from local config, env vars, or by listing the user's - * organizations from the API as a fallback. + * Resolve the org slug using the shared offline-first resolver, falling back + * to interactive selection when multiple orgs are available. * - * DSN scanning uses the prefetch-aware helper from `./prefetch.ts` — if - * {@link warmOrgDetection} was called earlier (by `init.ts`), the result is - * already cached and returns near-instantly. + * Resolution priority (via {@link resolveOrg}): + * 1. CLI `--org` flag + * 2. `SENTRY_ORG` / `SENTRY_PROJECT` env vars + * 3. Config defaults (SQLite) + * 4. DSN auto-detection (with numeric ID normalization) * - * `listOrganizations()` uses SQLite caching for near-instant warm lookups - * (populated after `sentry login` or the first API call), so it does not - * need background prefetching. + * If none of the above resolve, lists the user's organizations (SQLite-cached + * after `sentry login`) and prompts for selection. * * @returns The org slug on success, or a {@link LocalOpResult} error to return early. */ @@ -727,21 +725,10 @@ export async function resolveOrgSlug( cwd: string, yes: boolean ): Promise { - const resolved = await resolveOrgPrefetched(cwd); + // Reuse the shared offline-first resolver (flags, env vars, defaults, DSN) + const resolved = await resolveOrg({ cwd }); if (resolved) { - // If the detected org is a raw numeric ID (extracted from a DSN), try to - // resolve it to a real slug. Numeric IDs can fail for write operations like - // project/team creation, and may belong to a different Sentry account. - if (NUMERIC_ORG_ID_RE.test(resolved.org)) { - const { getOrgByNumericId } = await import("../db/regions.js"); - const match = getOrgByNumericId(resolved.org); - if (match) { - return match.slug; - } - // Cache miss — fall through to listOrganizations() for proper selection - } else { - return resolved.org; - } + return resolved.org; } // Fallback: list user's organizations (SQLite-cached after login/first call) diff --git a/test/lib/init/local-ops.create-sentry-project.test.ts b/test/lib/init/local-ops.create-sentry-project.test.ts index a67035709..d1ce83ede 100644 --- a/test/lib/init/local-ops.create-sentry-project.test.ts +++ b/test/lib/init/local-ops.create-sentry-project.test.ts @@ -194,7 +194,7 @@ describe("create-sentry-project", () => { describe("resolveOrgSlug (called directly)", () => { test("single org fallback when resolveOrg returns null", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); + resolveOrgSpy.mockResolvedValue(null); listOrgsSpy.mockResolvedValue([ { id: "1", slug: "solo-org", name: "Solo Org" }, ]); @@ -206,7 +206,7 @@ describe("create-sentry-project", () => { }); test("no orgs (not authenticated) returns error result", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); + resolveOrgSpy.mockResolvedValue(null); listOrgsSpy.mockResolvedValue([]); const result = await resolveOrgSlug("/tmp/test", false); @@ -218,7 +218,7 @@ describe("create-sentry-project", () => { }); test("multiple orgs + yes flag returns error with slug list", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); + resolveOrgSpy.mockResolvedValue(null); listOrgsSpy.mockResolvedValue([ { id: "1", slug: "org-a", name: "Org A" }, { id: "2", slug: "org-b", name: "Org B" }, @@ -235,7 +235,7 @@ describe("create-sentry-project", () => { }); test("multiple orgs + interactive select picks chosen org", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); + resolveOrgSpy.mockResolvedValue(null); listOrgsSpy.mockResolvedValue([ { id: "1", slug: "org-a", name: "Org A" }, { id: "2", slug: "org-b", name: "Org B" }, @@ -249,7 +249,7 @@ describe("create-sentry-project", () => { }); test("multiple orgs + user cancels select throws WizardCancelledError", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); + resolveOrgSpy.mockResolvedValue(null); listOrgsSpy.mockResolvedValue([ { id: "1", slug: "org-a", name: "Org A" }, { id: "2", slug: "org-b", name: "Org B" }, @@ -295,23 +295,18 @@ describe("create-sentry-project", () => { expect(data.dsn).toBe(""); }); - describe("resolveOrgSlug — numeric org ID from DSN", () => { - test("numeric ID + cache hit → resolved to slug", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue({ org: "4507492088676352" }); - getOrgByNumericIdSpy.mockReturnValue({ - slug: "acme-corp", - regionUrl: "https://us.sentry.io", - }); + describe("resolveOrgSlug — resolveOrg integration", () => { + test("returns org from resolveOrg when it resolves", async () => { + resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); const result = await resolveOrgSlug("/tmp/test", false); expect(result).toBe("acme-corp"); - expect(getOrgByNumericIdSpy).toHaveBeenCalledWith("4507492088676352"); + expect(listOrgsSpy).not.toHaveBeenCalled(); }); - test("numeric ID + cache miss → falls through to single org in listOrganizations", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue({ org: "4507492088676352" }); - getOrgByNumericIdSpy.mockReturnValue(undefined); + test("falls through to listOrganizations when resolveOrg returns null", async () => { + resolveOrgSpy.mockResolvedValue(null); listOrgsSpy.mockResolvedValue([ { id: "1", slug: "solo-org", name: "Solo Org" }, ]); @@ -320,22 +315,6 @@ describe("create-sentry-project", () => { expect(result).toBe("solo-org"); }); - - test("numeric ID + cache miss + multiple orgs + --yes → error with org list", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue({ org: "4507492088676352" }); - getOrgByNumericIdSpy.mockReturnValue(undefined); - listOrgsSpy.mockResolvedValue([ - { id: "1", slug: "org-a", name: "Org A" }, - { id: "2", slug: "org-b", name: "Org B" }, - ]); - - const result = await resolveOrgSlug("/tmp/test", true); - - expect(typeof result).toBe("object"); - const err = result as { ok: boolean; error: string }; - expect(err.ok).toBe(false); - expect(err.error).toContain("Multiple organizations found"); - }); }); describe("detectExistingProject (called directly)", () => { From 174bad69ad88220b38b9ffd78249d72e65d9a86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 6 Apr 2026 16:54:07 +0200 Subject: [PATCH 2/2] fix(init): restore prefetch consumption and numeric ID fallback Use resolveOrgPrefetched instead of calling resolveOrg directly so the background DSN scan from warmOrgDetection isn't wasted. Also guard against resolveOrg returning a raw numeric ID (when normalizeNumericOrg cache is cold and API refresh fails) by falling through to the interactive org picker, matching the original behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/init/local-ops.ts | 19 +++++++++---- .../local-ops.create-sentry-project.test.ts | 27 +++++++++++++------ 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 9091c06fa..799402ae4 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -16,7 +16,6 @@ import { tryGetPrimaryDsn, } from "../api-client.js"; import { ApiError } from "../errors.js"; -import { resolveOrg } from "../resolve-target.js"; import { resolveOrCreateTeam } from "../resolve-team.js"; import { buildProjectUrl } from "../sentry-urls.js"; import { slugify } from "../utils.js"; @@ -26,6 +25,7 @@ import { MAX_FILE_BYTES, MAX_OUTPUT_BYTES, } from "./constants.js"; +import { resolveOrgPrefetched } from "./prefetch.js"; import type { ApplyPatchsetPayload, CreateSentryProjectPayload, @@ -706,11 +706,18 @@ async function applyPatchset( return { ok: true, data: { applied } }; } +/** Matches a bare numeric org ID extracted from a DSN (e.g. "4507492088676352"). */ +const NUMERIC_ORG_ID_RE = /^\d+$/; + /** * Resolve the org slug using the shared offline-first resolver, falling back * to interactive selection when multiple orgs are available. * - * Resolution priority (via {@link resolveOrg}): + * Uses the prefetch-aware helper from `./prefetch.ts` — if + * {@link warmOrgDetection} was called earlier (by `init.ts`), the result is + * already cached and returns near-instantly. + * + * Resolution priority (via `resolveOrg`): * 1. CLI `--org` flag * 2. `SENTRY_ORG` / `SENTRY_PROJECT` env vars * 3. Config defaults (SQLite) @@ -725,9 +732,11 @@ export async function resolveOrgSlug( cwd: string, yes: boolean ): Promise { - // Reuse the shared offline-first resolver (flags, env vars, defaults, DSN) - const resolved = await resolveOrg({ cwd }); - if (resolved) { + // normalizeNumericOrg inside resolveOrg may return a raw numeric ID when + // the cache is cold and the API refresh fails. Numeric IDs break write + // operations (project/team creation), so fall through to the org picker. + const resolved = await resolveOrgPrefetched(cwd); + if (resolved && !NUMERIC_ORG_ID_RE.test(resolved.org)) { return resolved.org; } diff --git a/test/lib/init/local-ops.create-sentry-project.test.ts b/test/lib/init/local-ops.create-sentry-project.test.ts index d1ce83ede..1d348e4fb 100644 --- a/test/lib/init/local-ops.create-sentry-project.test.ts +++ b/test/lib/init/local-ops.create-sentry-project.test.ts @@ -194,7 +194,7 @@ describe("create-sentry-project", () => { describe("resolveOrgSlug (called directly)", () => { test("single org fallback when resolveOrg returns null", async () => { - resolveOrgSpy.mockResolvedValue(null); + resolveOrgPrefetchedSpy.mockResolvedValue(null); listOrgsSpy.mockResolvedValue([ { id: "1", slug: "solo-org", name: "Solo Org" }, ]); @@ -206,7 +206,7 @@ describe("create-sentry-project", () => { }); test("no orgs (not authenticated) returns error result", async () => { - resolveOrgSpy.mockResolvedValue(null); + resolveOrgPrefetchedSpy.mockResolvedValue(null); listOrgsSpy.mockResolvedValue([]); const result = await resolveOrgSlug("/tmp/test", false); @@ -218,7 +218,7 @@ describe("create-sentry-project", () => { }); test("multiple orgs + yes flag returns error with slug list", async () => { - resolveOrgSpy.mockResolvedValue(null); + resolveOrgPrefetchedSpy.mockResolvedValue(null); listOrgsSpy.mockResolvedValue([ { id: "1", slug: "org-a", name: "Org A" }, { id: "2", slug: "org-b", name: "Org B" }, @@ -235,7 +235,7 @@ describe("create-sentry-project", () => { }); test("multiple orgs + interactive select picks chosen org", async () => { - resolveOrgSpy.mockResolvedValue(null); + resolveOrgPrefetchedSpy.mockResolvedValue(null); listOrgsSpy.mockResolvedValue([ { id: "1", slug: "org-a", name: "Org A" }, { id: "2", slug: "org-b", name: "Org B" }, @@ -249,7 +249,7 @@ describe("create-sentry-project", () => { }); test("multiple orgs + user cancels select throws WizardCancelledError", async () => { - resolveOrgSpy.mockResolvedValue(null); + resolveOrgPrefetchedSpy.mockResolvedValue(null); listOrgsSpy.mockResolvedValue([ { id: "1", slug: "org-a", name: "Org A" }, { id: "2", slug: "org-b", name: "Org B" }, @@ -296,8 +296,8 @@ describe("create-sentry-project", () => { }); describe("resolveOrgSlug — resolveOrg integration", () => { - test("returns org from resolveOrg when it resolves", async () => { - resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); + test("returns org slug when resolveOrg resolves", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue({ org: "acme-corp" }); const result = await resolveOrgSlug("/tmp/test", false); @@ -306,7 +306,18 @@ describe("create-sentry-project", () => { }); test("falls through to listOrganizations when resolveOrg returns null", async () => { - resolveOrgSpy.mockResolvedValue(null); + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrgsSpy.mockResolvedValue([ + { id: "1", slug: "solo-org", name: "Solo Org" }, + ]); + + const result = await resolveOrgSlug("/tmp/test", false); + + expect(result).toBe("solo-org"); + }); + + test("numeric ID from resolveOrg falls through to org picker", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue({ org: "4507492088676352" }); listOrgsSpy.mockResolvedValue([ { id: "1", slug: "solo-org", name: "Solo Org" }, ]);