Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { SentryContext } from "../context.js";
import { looksLikePath, parseOrgProjectArg } from "../lib/arg-parsing.js";
import { buildCommand } from "../lib/command.js";
import { ContextError } from "../lib/errors.js";
import { warmOrgDetection } from "../lib/init/prefetch.js";
import { runWizard } from "../lib/init/wizard-runner.js";
import { validateResourceId } from "../lib/input-validation.js";
import { logger } from "../lib/logger.js";
Expand Down Expand Up @@ -229,7 +230,15 @@ export const initCommand = buildCommand<
const { org: explicitOrg, project: explicitProject } =
await resolveTarget(targetArg);

// 5. Run the wizard
// 5. Start background org detection when org is not yet known.
// The prefetch runs concurrently with the preamble, the wizard startup,
// and all early suspend/resume rounds — by the time the wizard needs the
// org (inside createSentryProject), the result is already cached.
if (!explicitOrg) {
warmOrgDetection(targetDir);
}

// 6. Run the wizard
await runWizard({
directory: targetDir,
yes: flags.yes,
Expand Down
17 changes: 13 additions & 4 deletions src/lib/init/local-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,6 +24,7 @@ import {
MAX_FILE_BYTES,
MAX_OUTPUT_BYTES,
} from "./constants.js";
import { resolveOrgPrefetched } from "./prefetch.js";
import type {
ApplyPatchsetPayload,
CreateSentryProjectPayload,
Expand Down Expand Up @@ -658,18 +658,27 @@ function applyPatchset(
/**
* Resolve the org slug from local config, env vars, or by listing the user's
* organizations from the API as a fallback.
* Returns the slug on success, or a LocalOpResult error to return early.
*
* 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.
*
* `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.
*
* @returns The org slug on success, or a {@link LocalOpResult} error to return early.
*/
async function resolveOrgSlug(
cwd: string,
yes: boolean
): Promise<string | LocalOpResult> {
const resolved = await resolveOrg({ cwd });
const resolved = await resolveOrgPrefetched(cwd);
if (resolved) {
return resolved.org;
}

// Fallback: list user's organizations from API
// Fallback: list user's organizations (SQLite-cached after login/first call)
const orgs = await listOrganizations();
if (orgs.length === 0) {
return {
Expand Down
64 changes: 64 additions & 0 deletions src/lib/init/prefetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Background Org Detection Prefetch
*
* Provides a warm/consume pattern for org resolution during `sentry init`.
* Call {@link warmOrgDetection} early (before the preamble) to start DSN
* scanning in the background. Later, call {@link resolveOrgPrefetched} —
* it returns the cached result instantly if the background work has
* finished, or falls back to a live call if it hasn't been warmed.
*
* `listOrganizations()` does NOT need prefetching because it has its own
* SQLite cache layer (PR #446). After `sentry login`, the org cache is
* pre-populated (PR #490), so subsequent calls return from cache instantly
* without any HTTP requests. Only `resolveOrg()` (DSN scanning) benefits
* from background prefetching since it performs filesystem I/O.
*
* This keeps the hot path (inside the wizard's `createSentryProject`)
* free of explicit promise-threading — callers just swap in the
* prefetch-aware functions.
*/

import type { ResolvedOrg } from "../resolve-target.js";
import { resolveOrg } from "../resolve-target.js";

type OrgResult = ResolvedOrg | null;

let orgPromise: Promise<OrgResult> | undefined;
let warmedCwd: string | undefined;

/**
* Kick off background DSN scanning + env var / config checks.
*
* Safe to call multiple times — subsequent calls are no-ops.
* Errors are silently swallowed so the foreground path can retry.
*/
export function warmOrgDetection(cwd: string): void {
if (!orgPromise) {
warmedCwd = cwd;
orgPromise = resolveOrg({ cwd }).catch(() => null);
}
}

/**
* Resolve the org, using the prefetched result if available.
*
* Returns the cached background result only when `cwd` matches the
* directory that was passed to {@link warmOrgDetection}. If `cwd`
* differs (or warming was never called), falls back to a live
* `resolveOrg()` call so callers always get results for the correct
* working directory.
*/
export function resolveOrgPrefetched(cwd: string): Promise<OrgResult> {
if (orgPromise && cwd === warmedCwd) {
return orgPromise;
}
return resolveOrg({ cwd }).catch(() => null);
}

/**
* Reset prefetch state. Used by tests to prevent cross-test leakage.
*/
export function resetPrefetch(): void {
orgPromise = undefined;
warmedCwd = undefined;
}
57 changes: 57 additions & 0 deletions test/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import path from "node:path";
import { initCommand } from "../../src/commands/init.js";
import { ContextError } from "../../src/lib/errors.js";
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
import * as prefetchNs from "../../src/lib/init/prefetch.js";
import { resetPrefetch } from "../../src/lib/init/prefetch.js";
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
import * as wizardRunner from "../../src/lib/init/wizard-runner.js";
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
import * as resolveTarget from "../../src/lib/resolve-target.js";
Expand All @@ -19,6 +22,7 @@ import * as resolveTarget from "../../src/lib/resolve-target.js";
let capturedArgs: Record<string, unknown> | undefined;
let runWizardSpy: ReturnType<typeof spyOn>;
let resolveProjectSpy: ReturnType<typeof spyOn>;
let warmSpy: ReturnType<typeof spyOn>;

const func = (await initCommand.loader()) as unknown as (
this: {
Expand All @@ -45,6 +49,7 @@ const DEFAULT_FLAGS = { yes: true, "dry-run": false } as const;

beforeEach(() => {
capturedArgs = undefined;
resetPrefetch();
runWizardSpy = spyOn(wizardRunner, "runWizard").mockImplementation(
(args: Record<string, unknown>) => {
capturedArgs = args;
Expand All @@ -59,11 +64,19 @@ beforeEach(() => {
org: "resolved-org",
project: slug,
}));
// Spy on warmOrgDetection to verify it's called/skipped appropriately.
// The mock prevents real DSN scans and API calls from the background.
warmSpy = spyOn(prefetchNs, "warmOrgDetection").mockImplementation(
// biome-ignore lint/suspicious/noEmptyBlockStatements: intentional no-op mock
() => {}
);
});

afterEach(() => {
runWizardSpy.mockRestore();
resolveProjectSpy.mockRestore();
warmSpy.mockRestore();
resetPrefetch();
});

describe("init command func", () => {
Expand Down Expand Up @@ -324,4 +337,48 @@ describe("init command func", () => {
expect(capturedArgs?.team).toBe("backend");
});
});

// ── Background org detection ──────────────────────────────────────────

describe("background org detection", () => {
test("warms prefetch when org is not explicit", async () => {
const ctx = makeContext();
await func.call(ctx, DEFAULT_FLAGS);
expect(warmSpy).toHaveBeenCalledTimes(1);
expect(warmSpy).toHaveBeenCalledWith("/projects/app");
});

test("skips prefetch when org is explicit", async () => {
const ctx = makeContext();
await func.call(ctx, DEFAULT_FLAGS, "acme/my-app");
expect(warmSpy).not.toHaveBeenCalled();
});

test("skips prefetch when org-only is explicit", async () => {
const ctx = makeContext();
await func.call(ctx, DEFAULT_FLAGS, "acme/");
expect(warmSpy).not.toHaveBeenCalled();
});

test("skips prefetch for bare slug (project-search resolves org)", async () => {
const ctx = makeContext();
await func.call(ctx, DEFAULT_FLAGS, "my-app");
// resolveProjectBySlug returns { org: "resolved-org" } → org is known
expect(warmSpy).not.toHaveBeenCalled();
});

test("warms prefetch for path-only arg", async () => {
const ctx = makeContext();
await func.call(ctx, DEFAULT_FLAGS, "./subdir");
expect(warmSpy).toHaveBeenCalledTimes(1);
});

test("warms prefetch with resolved directory path", async () => {
const ctx = makeContext("/projects/app");
await func.call(ctx, DEFAULT_FLAGS, "./subdir");
expect(warmSpy).toHaveBeenCalledWith(
path.resolve("/projects/app", "./subdir")
);
});
});
});
Loading