diff --git a/src/commands/explore.ts b/src/commands/explore.ts index 6ecc25b13..608fe8095 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -8,18 +8,14 @@ import type { SentryContext } from "../context.js"; import { queryEvents } from "../lib/api-client.js"; -import { - buildProjectQuery, - parseOrgProjectArg, - validateLimit, -} from "../lib/arg-parsing.js"; +import { buildProjectQuery, validateLimit } from "../lib/arg-parsing.js"; import { advancePaginationState, buildPaginationContextKey, hasPreviousPage, resolveCursor, } from "../lib/db/pagination.js"; -import { ContextError, ValidationError } from "../lib/errors.js"; +import { ValidationError } from "../lib/errors.js"; import { filterFields } from "../lib/formatters/json.js"; import { buildMetaColumns } from "../lib/formatters/meta-table.js"; import { CommandOutput } from "../lib/formatters/output.js"; @@ -34,7 +30,7 @@ import { } from "../lib/list-command.js"; import { logger } from "../lib/logger.js"; import { withProgress } from "../lib/polling.js"; -import { resolveOrg, resolveProjectBySlug } from "../lib/resolve-target.js"; +import { resolveOrgOptionalProjectFromArg } from "../lib/resolve-target.js"; import { sanitizeQuery } from "../lib/search-query.js"; import { appendPeriodHint, @@ -292,62 +288,6 @@ function findFirstAggregate(fieldList: string[]): string | undefined { return fieldList.find((f) => f.includes("(") && f.includes(")")); } -// --------------------------------------------------------------------------- -// Org resolution helper — extracted from func() for complexity -// --------------------------------------------------------------------------- - -/** Resolved explore target: org slug + optional project filter */ -type ExploreTarget = { - org: string; - project?: string; -}; - -/** Usage hint shown when target resolution fails */ -const USAGE_HINT = "sentry explore "; - -/** - * Resolve the explore target to an org slug and optional project filter. - * - * Semantics: - * - `/` → explicit org with project filter (adds `project:slug` to query) - * - `/` → org-all mode (no project filter) - * - `` (bare) → fuzzy-search across all orgs for the project - * - undefined → auto-detect from DSN/config - */ -async function resolveExploreTarget( - target: string | undefined, - cwd: string -): Promise { - const parsed = parseOrgProjectArg(target); - - if (parsed.type === "explicit") { - return { org: parsed.org, project: parsed.project }; - } - if (parsed.type === "org-all") { - return { org: parsed.org }; - } - if (parsed.type === "project-search") { - // Bare slug — search across orgs to find the project - const found = await resolveProjectBySlug( - parsed.projectSlug, - USAGE_HINT, - `sentry explore /${parsed.projectSlug}`, - parsed.originalSlug - ); - return { org: found.org, project: found.project }; - } - - // auto-detect: resolve org only - const resolved = await resolveOrg({ cwd }); - if (!resolved) { - throw new ContextError("Organization", USAGE_HINT, [ - "SENTRY_ORG environment variable", - "sentry cli defaults", - ]); - } - return { org: resolved.org }; -} - /** * Determine the effective sort value, accounting for dataset restrictions. * Sort is only supported on the `spans` dataset. @@ -482,7 +422,11 @@ export const exploreCommand = buildListCommand("explore", { }, async *func(this: SentryContext, flags: ExploreFlags, target?: string) { const { cwd } = this; - const { org, project } = await resolveExploreTarget(target, cwd); + const { org, project } = await resolveOrgOptionalProjectFromArg( + target, + cwd, + "explore" + ); const fieldList = flags.field && flags.field.length > 0 ? flags.field : DEFAULT_FIELDS; diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 971660b1d..e7a159681 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -1734,3 +1734,82 @@ export function resolveOrgProjectFromArg( ): Promise { return resolveOrgProjectTarget(parseOrgProjectArg(target), cwd, commandName); } + +/** Resolved org and optional project — used by commands that accept org-all mode. */ +export type ResolvedOrgOptionalProject = { + /** Organization slug */ + org: string; + /** Project slug (absent in org-all and auto-detect modes) */ + project?: string; + /** Full project data when resolved via project-search (avoids redundant re-fetch) */ + projectData?: SentryProject; +}; + +/** + * Resolve an org/project target for commands that accept org-all mode + * (e.g., `sentry explore`). Unlike {@link resolveOrgProjectTarget}, this + * function allows `org-all` and `auto-detect` modes to resolve to an + * org-only result without requiring a project. + * + * Handles: + * - explicit `/` → delegate to {@link resolveOrgProjectTarget} + * - project-search `` → delegate to {@link resolveOrgProjectTarget} + * - org-all `/` → resolve the org slug only + * - auto-detect → resolve org only (no project required) + * + * @param parsed - Parsed org/project argument + * @param cwd - Current working directory for DSN auto-detection + * @param commandName - Command name used in error messages (e.g., "explore") + * @returns Resolved org and optional project slugs + * @throws {ContextError} When target cannot be resolved + */ +export async function resolveOrgOptionalProjectTarget( + parsed: ParsedOrgProject, + cwd: string, + commandName: string +): Promise { + // org-all: resolve the org slug only + if (parsed.type === "org-all") { + const org = await resolveEffectiveOrg(parsed.org); + return withTelemetryContext({ org }); + } + + // auto-detect: resolve org only (no project required) + if (parsed.type === "auto-detect") { + const resolved = await resolveOrg({ cwd }); + if (!resolved) { + throw new ContextError("Organization", `sentry ${commandName} `, [ + "SENTRY_ORG environment variable", + "sentry cli defaults", + ]); + } + return withTelemetryContext({ org: resolved.org }); + } + + // explicit and project-search: delegate to the project-required resolver + return resolveOrgProjectTarget(parsed, cwd, commandName); +} + +/** + * Resolve an org/project target from a raw CLI argument string for commands + * that accept org-all mode (e.g., `sentry explore`). + * + * Convenience wrapper around `resolveOrgOptionalProjectTarget` that also calls + * `parseOrgProjectArg` on the raw string argument. + * + * @param target - Raw CLI argument string (or undefined for auto-detect) + * @param cwd - Current working directory for DSN auto-detection + * @param commandName - Command name used in error messages (e.g., "explore") + * @returns Resolved org and optional project slugs + */ +export function resolveOrgOptionalProjectFromArg( + target: string | undefined, + cwd: string, + commandName: string +): Promise { + return resolveOrgOptionalProjectTarget( + parseOrgProjectArg(target), + cwd, + commandName + ); +} diff --git a/test/commands/explore.test.ts b/test/commands/explore.test.ts index 087ece799..e31f4872e 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -19,6 +19,7 @@ import { exploreCommand } from "../../src/commands/explore.js"; import * as apiClient from "../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as paginationDb from "../../src/lib/db/pagination.js"; +import { ContextError } from "../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../src/lib/resolve-target.js"; import { parsePeriod } from "../../src/lib/time-range.js"; @@ -71,8 +72,7 @@ const MOCK_EVENTS_RESPONSE = { }; let queryEventsSpy: ReturnType; -let resolveOrgSpy: ReturnType; -let resolveProjectBySlugSpy: ReturnType; +let resolveTargetSpy: ReturnType; let resolveCursorSpy: ReturnType; let advancePaginationStateSpy: ReturnType; let hasPreviousPageSpy: ReturnType; @@ -86,16 +86,9 @@ beforeEach(async () => { nextCursor: undefined, }); - resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); - resolveOrgSpy.mockResolvedValue({ org: "test-org" }); - - // Default: bare-slug lookups resolve to test-org/test-project - resolveProjectBySlugSpy = spyOn(resolveTarget, "resolveProjectBySlug"); - resolveProjectBySlugSpy.mockResolvedValue({ - org: "test-org", - project: "test-project", - projectData: {} as never, - }); + // Default: resolveOrgOptionalProjectFromArg returns org-only (auto-detect) + resolveTargetSpy = spyOn(resolveTarget, "resolveOrgOptionalProjectFromArg"); + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); resolveCursorSpy = spyOn(paginationDb, "resolveCursor").mockReturnValue({ cursor: undefined, @@ -112,8 +105,7 @@ beforeEach(async () => { afterEach(() => { queryEventsSpy.mockRestore(); - resolveOrgSpy.mockRestore(); - resolveProjectBySlugSpy.mockRestore(); + resolveTargetSpy.mockRestore(); resolveCursorSpy.mockRestore(); advancePaginationStateSpy.mockRestore(); hasPreviousPageSpy.mockRestore(); @@ -130,11 +122,16 @@ const DEFAULT_FLAGS = { describe("sentry explore", () => { describe("target resolution", () => { test("`/` uses org without project filter", async () => { + resolveTargetSpy.mockResolvedValue({ org: "my-org" }); const { context } = createContext(); await func.call(context, DEFAULT_FLAGS, "my-org/"); - // No resolveOrg call needed — org-all parses directly + expect(resolveTargetSpy).toHaveBeenCalledWith( + "my-org/", + "/tmp/test-explore", + "explore" + ); expect(queryEventsSpy).toHaveBeenCalledWith( "my-org", expect.objectContaining({ query: undefined }) @@ -142,6 +139,7 @@ describe("sentry explore", () => { }); test("`/` adds project: to query automatically", async () => { + resolveTargetSpy.mockResolvedValue({ org: "my-org", project: "cli" }); const { context } = createContext(); await func.call(context, DEFAULT_FLAGS, "my-org/cli"); @@ -153,6 +151,7 @@ describe("sentry explore", () => { }); test("`/` with --query merges project filter and user query", async () => { + resolveTargetSpy.mockResolvedValue({ org: "my-org", project: "cli" }); const { context } = createContext(); await func.call( @@ -168,15 +167,18 @@ describe("sentry explore", () => { }); test("bare slug resolves project across orgs and adds project filter", async () => { + resolveTargetSpy.mockResolvedValue({ + org: "test-org", + project: "test-project", + }); const { context } = createContext(); await func.call(context, DEFAULT_FLAGS, "cli"); - expect(resolveProjectBySlugSpy).toHaveBeenCalledWith( + expect(resolveTargetSpy).toHaveBeenCalledWith( "cli", - expect.any(String), - expect.any(String), - undefined + "/tmp/test-explore", + "explore" ); expect(queryEventsSpy).toHaveBeenCalledWith( "test-org", @@ -185,12 +187,15 @@ describe("sentry explore", () => { }); test("auto-detects org when no target provided", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context } = createContext(); await func.call(context, DEFAULT_FLAGS); - expect(resolveOrgSpy).toHaveBeenCalledWith( - expect.objectContaining({ cwd: "/tmp/test-explore" }) + expect(resolveTargetSpy).toHaveBeenCalledWith( + undefined, + "/tmp/test-explore", + "explore" ); expect(queryEventsSpy).toHaveBeenCalledWith( "test-org", @@ -198,8 +203,13 @@ describe("sentry explore", () => { ); }); - test("throws ContextError when auto-detect fails", async () => { - resolveOrgSpy.mockResolvedValue(null); + test("throws ContextError when resolution fails", async () => { + resolveTargetSpy.mockRejectedValue( + new ContextError("Organization", "sentry explore ", [ + "SENTRY_ORG environment variable", + "sentry cli defaults", + ]) + ); const { context } = createContext(); await expect(func.call(context, DEFAULT_FLAGS)).rejects.toThrow( @@ -210,6 +220,7 @@ describe("sentry explore", () => { describe("API call parameters", () => { test("passes default fields and dataset when none specified", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context } = createContext(); await func.call(context, DEFAULT_FLAGS, "test-org/"); @@ -224,6 +235,7 @@ describe("sentry explore", () => { }); test("passes custom fields from --field flag", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context } = createContext(); await func.call( @@ -244,6 +256,7 @@ describe("sentry explore", () => { }); test("passes custom dataset", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context } = createContext(); await func.call( @@ -259,6 +272,7 @@ describe("sentry explore", () => { }); test("passes user query unchanged when no project filter", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context } = createContext(); await func.call( @@ -274,6 +288,7 @@ describe("sentry explore", () => { }); test("passes limit", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context } = createContext(); await func.call(context, { ...DEFAULT_FLAGS, limit: 100 }, "test-org/"); @@ -287,6 +302,7 @@ describe("sentry explore", () => { describe("sort handling", () => { test("auto-sorts by first aggregate descending for non-spans", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context } = createContext(); await func.call(context, DEFAULT_FLAGS, "test-org/"); @@ -300,6 +316,7 @@ describe("sentry explore", () => { }); test("applies explicit sort on spans dataset", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context } = createContext(); await func.call( @@ -315,6 +332,7 @@ describe("sentry explore", () => { }); test("omits sort for non-spans dataset even when auto-detected", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context } = createContext(); await func.call( @@ -332,6 +350,7 @@ describe("sentry explore", () => { describe("output", () => { test("renders human-readable table with results", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context, getStdout } = createContext(); await func.call(context, DEFAULT_FLAGS, "test-org/"); @@ -342,6 +361,7 @@ describe("sentry explore", () => { }); test("includes project in human header when target is org/project", async () => { + resolveTargetSpy.mockResolvedValue({ org: "my-org", project: "cli" }); const { context, getStdout } = createContext(); await func.call(context, DEFAULT_FLAGS, "my-org/cli"); @@ -350,6 +370,7 @@ describe("sentry explore", () => { }); test("preserves user --field order in table columns", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); // API returns fields in a different order than requested queryEventsSpy.mockResolvedValue({ data: { @@ -392,6 +413,7 @@ describe("sentry explore", () => { }); test("renders JSON output with envelope", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context, getStdout } = createContext(); await func.call(context, { ...DEFAULT_FLAGS, json: true }, "test-org/"); @@ -405,6 +427,7 @@ describe("sentry explore", () => { }); test("shows empty message when no results", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); queryEventsSpy.mockResolvedValue({ data: { data: [], meta: { fields: {} } }, nextCursor: undefined, @@ -419,6 +442,7 @@ describe("sentry explore", () => { describe("pagination", () => { test("includes nextCursor in JSON when more results available", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); queryEventsSpy.mockResolvedValue({ data: MOCK_EVENTS_RESPONSE, nextCursor: "1735689600:0:1", @@ -433,6 +457,7 @@ describe("sentry explore", () => { }); test("advances pagination state after query", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); queryEventsSpy.mockResolvedValue({ data: MOCK_EVENTS_RESPONSE, nextCursor: "cursor123", diff --git a/test/lib/resolve-target.test.ts b/test/lib/resolve-target.test.ts index 4349b561a..55799e30a 100644 --- a/test/lib/resolve-target.test.ts +++ b/test/lib/resolve-target.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { array, constantFrom, assert as fcAssert, property } from "fast-check"; +import { parseOrgProjectArg } from "../../src/lib/arg-parsing.js"; import { DEFAULT_SENTRY_URL } from "../../src/lib/constants.js"; import { setAuthToken } from "../../src/lib/db/auth.js"; import { @@ -16,13 +17,18 @@ import { getCachedProjectBySlug, } from "../../src/lib/db/project-cache.js"; import { setOrgRegion } from "../../src/lib/db/regions.js"; -import { AuthError, ResolutionError } from "../../src/lib/errors.js"; +import { + AuthError, + ContextError, + ResolutionError, +} from "../../src/lib/errors.js"; import { fetchProjectId, isValidDirNameForInference, resolveAllTargets, resolveOrg, resolveOrgAndProject, + resolveOrgOptionalProjectTarget, resolveOrgsForListing, toNumericId, tryFuzzyProjectRecovery, @@ -744,3 +750,95 @@ describe("tryFuzzyProjectRecovery", () => { } }); }); + +// ============================================================================ +// resolveOrgOptionalProjectTarget — org-optional resolution for commands +// that accept org-all mode (e.g., sentry explore) +// ============================================================================ + +describe("resolveOrgOptionalProjectTarget", () => { + useTestConfigDir("test-resolve-optional-"); + + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + // Silence unmocked fetch calls — resolveEffectiveOrg catches errors and + // returns the original slug, so a 404 is sufficient. + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { status: 404 }) + ); + delete process.env.SENTRY_ORG; + delete process.env.SENTRY_PROJECT; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + delete process.env.SENTRY_ORG; + delete process.env.SENTRY_PROJECT; + }); + + test("org-all mode returns org without project", async () => { + const parsed = parseOrgProjectArg("myorg/"); + expect(parsed.type).toBe("org-all"); + + const result = await resolveOrgOptionalProjectTarget( + parsed, + "/tmp", + "explore" + ); + expect(result.org).toBe("myorg"); + expect(result.project).toBeUndefined(); + }); + + test("explicit mode returns both org and project", async () => { + const parsed = parseOrgProjectArg("myorg/myproject"); + expect(parsed.type).toBe("explicit"); + + const result = await resolveOrgOptionalProjectTarget( + parsed, + "/tmp", + "explore" + ); + expect(result.org).toBe("myorg"); + expect(result.project).toBe("myproject"); + }); + + test("auto-detect mode returns org only when SENTRY_ORG is set", async () => { + process.env.SENTRY_ORG = "env-org"; + const parsed = parseOrgProjectArg(undefined); + expect(parsed.type).toBe("auto-detect"); + + const result = await resolveOrgOptionalProjectTarget( + parsed, + "/tmp", + "explore" + ); + expect(result.org).toBe("env-org"); + expect(result.project).toBeUndefined(); + }); + + test("auto-detect mode throws ContextError when nothing resolves", async () => { + // No env vars, no defaults, no DSN — resolveOrg returns null + const parsed = parseOrgProjectArg(undefined); + + await expect( + resolveOrgOptionalProjectTarget(parsed, "/tmp", "explore") + ).rejects.toThrow(ContextError); + }); + + test("auto-detect ContextError mentions the command name", async () => { + const parsed = parseOrgProjectArg(undefined); + + try { + await resolveOrgOptionalProjectTarget(parsed, "/tmp", "explore"); + // Should not reach here + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(ContextError); + expect((err as ContextError).message).toContain("Organization"); + expect((err as ContextError).command).toContain("explore"); + } + }); +});