Skip to content

Commit 7bb8ccf

Browse files
betegonclaude
andauthored
refactor(init): reuse resolveOrg for offline-first org detection (#666)
## Summary The init wizard's `resolveOrgSlug()` was reimplementing org detection that `resolveOrg()` in `resolve-target.ts` already handles. The shared resolver covers CLI flags, env vars (`SENTRY_ORG`), config defaults, and DSN auto-detection with numeric ID normalization — the init version only did DSN scanning and missed env vars and config defaults entirely. Now `resolveOrgSlug()` calls `resolveOrg({ cwd })` first, and only falls through to `listOrganizations()` + interactive select when nothing is detected offline. ## Test Plan - [x] `bun test test/lib/init/` — 165 tests passing - [ ] `sentry init` with `SENTRY_ORG=<slug>` set — should pick it up without prompting - [ ] `sentry init` without env vars — should still show org picker as before --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 04a1efd commit 7bb8ccf

File tree

2 files changed

+29
-43
lines changed

2 files changed

+29
-43
lines changed

src/lib/init/local-ops.ts

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,6 @@ import type {
4040
WizardOptions,
4141
} from "./types.js";
4242

43-
/** Matches a bare numeric org ID extracted from a DSN (e.g. "4507492088676352"). */
44-
const NUMERIC_ORG_ID_RE = /^\d+$/;
45-
4643
/** Whitespace characters used for JSON indentation. */
4744
const Indenter = {
4845
SPACE: " ",
@@ -709,39 +706,38 @@ async function applyPatchset(
709706
return { ok: true, data: { applied } };
710707
}
711708

709+
/** Matches a bare numeric org ID extracted from a DSN (e.g. "4507492088676352"). */
710+
const NUMERIC_ORG_ID_RE = /^\d+$/;
711+
712712
/**
713-
* Resolve the org slug from local config, env vars, or by listing the user's
714-
* organizations from the API as a fallback.
713+
* Resolve the org slug using the shared offline-first resolver, falling back
714+
* to interactive selection when multiple orgs are available.
715715
*
716-
* DSN scanning uses the prefetch-aware helper from `./prefetch.ts` — if
716+
* Uses the prefetch-aware helper from `./prefetch.ts` — if
717717
* {@link warmOrgDetection} was called earlier (by `init.ts`), the result is
718718
* already cached and returns near-instantly.
719719
*
720-
* `listOrganizations()` uses SQLite caching for near-instant warm lookups
721-
* (populated after `sentry login` or the first API call), so it does not
722-
* need background prefetching.
720+
* Resolution priority (via `resolveOrg`):
721+
* 1. CLI `--org` flag
722+
* 2. `SENTRY_ORG` / `SENTRY_PROJECT` env vars
723+
* 3. Config defaults (SQLite)
724+
* 4. DSN auto-detection (with numeric ID normalization)
725+
*
726+
* If none of the above resolve, lists the user's organizations (SQLite-cached
727+
* after `sentry login`) and prompts for selection.
723728
*
724729
* @returns The org slug on success, or a {@link LocalOpResult} error to return early.
725730
*/
726731
export async function resolveOrgSlug(
727732
cwd: string,
728733
yes: boolean
729734
): Promise<string | LocalOpResult> {
735+
// normalizeNumericOrg inside resolveOrg may return a raw numeric ID when
736+
// the cache is cold and the API refresh fails. Numeric IDs break write
737+
// operations (project/team creation), so fall through to the org picker.
730738
const resolved = await resolveOrgPrefetched(cwd);
731-
if (resolved) {
732-
// If the detected org is a raw numeric ID (extracted from a DSN), try to
733-
// resolve it to a real slug. Numeric IDs can fail for write operations like
734-
// project/team creation, and may belong to a different Sentry account.
735-
if (NUMERIC_ORG_ID_RE.test(resolved.org)) {
736-
const { getOrgByNumericId } = await import("../db/regions.js");
737-
const match = getOrgByNumericId(resolved.org);
738-
if (match) {
739-
return match.slug;
740-
}
741-
// Cache miss — fall through to listOrganizations() for proper selection
742-
} else {
743-
return resolved.org;
744-
}
739+
if (resolved && !NUMERIC_ORG_ID_RE.test(resolved.org)) {
740+
return resolved.org;
745741
}
746742

747743
// Fallback: list user's organizations (SQLite-cached after login/first call)

test/lib/init/local-ops.create-sentry-project.test.ts

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -295,23 +295,18 @@ describe("create-sentry-project", () => {
295295
expect(data.dsn).toBe("");
296296
});
297297

298-
describe("resolveOrgSlug — numeric org ID from DSN", () => {
299-
test("numeric ID + cache hit → resolved to slug", async () => {
300-
resolveOrgPrefetchedSpy.mockResolvedValue({ org: "4507492088676352" });
301-
getOrgByNumericIdSpy.mockReturnValue({
302-
slug: "acme-corp",
303-
regionUrl: "https://us.sentry.io",
304-
});
298+
describe("resolveOrgSlug — resolveOrg integration", () => {
299+
test("returns org slug when resolveOrg resolves", async () => {
300+
resolveOrgPrefetchedSpy.mockResolvedValue({ org: "acme-corp" });
305301

306302
const result = await resolveOrgSlug("/tmp/test", false);
307303

308304
expect(result).toBe("acme-corp");
309-
expect(getOrgByNumericIdSpy).toHaveBeenCalledWith("4507492088676352");
305+
expect(listOrgsSpy).not.toHaveBeenCalled();
310306
});
311307

312-
test("numeric ID + cache miss → falls through to single org in listOrganizations", async () => {
313-
resolveOrgPrefetchedSpy.mockResolvedValue({ org: "4507492088676352" });
314-
getOrgByNumericIdSpy.mockReturnValue(undefined);
308+
test("falls through to listOrganizations when resolveOrg returns null", async () => {
309+
resolveOrgPrefetchedSpy.mockResolvedValue(null);
315310
listOrgsSpy.mockResolvedValue([
316311
{ id: "1", slug: "solo-org", name: "Solo Org" },
317312
]);
@@ -321,20 +316,15 @@ describe("create-sentry-project", () => {
321316
expect(result).toBe("solo-org");
322317
});
323318

324-
test("numeric ID + cache miss + multiple orgs + --yes → error with org list", async () => {
319+
test("numeric ID from resolveOrg falls through to org picker", async () => {
325320
resolveOrgPrefetchedSpy.mockResolvedValue({ org: "4507492088676352" });
326-
getOrgByNumericIdSpy.mockReturnValue(undefined);
327321
listOrgsSpy.mockResolvedValue([
328-
{ id: "1", slug: "org-a", name: "Org A" },
329-
{ id: "2", slug: "org-b", name: "Org B" },
322+
{ id: "1", slug: "solo-org", name: "Solo Org" },
330323
]);
331324

332-
const result = await resolveOrgSlug("/tmp/test", true);
325+
const result = await resolveOrgSlug("/tmp/test", false);
333326

334-
expect(typeof result).toBe("object");
335-
const err = result as { ok: boolean; error: string };
336-
expect(err.ok).toBe(false);
337-
expect(err.error).toContain("Multiple organizations found");
327+
expect(result).toBe("solo-org");
338328
});
339329
});
340330

0 commit comments

Comments
 (0)