diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index e56d514b..e6b6a946 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -344,9 +344,6 @@ export const viewCommand = buildCommand({ log.warn(suggestion); } const parsed = parseOrgProjectArg(targetArg); - if (parsed.type !== "auto-detect" && parsed.normalized) { - log.warn("Normalized slug (Sentry slugs use dashes, not underscores)"); - } const target = await resolveEventTarget({ parsed, diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index d7e96672..8f6a202a 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -358,9 +358,6 @@ export const viewCommand = buildCommand({ cmdLog.warn(suggestion); } const parsed = parseOrgProjectArg(targetArg); - if (parsed.type !== "auto-detect" && parsed.normalized) { - cmdLog.warn("Normalized slug (Sentry slugs use dashes, not underscores)"); - } const target = await resolveTarget(parsed, logIds, cwd); diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index b734b43b..eec9680e 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -35,7 +35,6 @@ import { FRESH_FLAG, LIST_CURSOR_FLAG, } from "../../lib/list-command.js"; -import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject, resolveProjectBySlug, @@ -281,14 +280,10 @@ export const listCommand = buildCommand({ async *func(this: SentryContext, flags: ListFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd, setContext } = this; - const log = logger.withTag("span.list"); // Parse positional args const { traceId, targetArg } = parsePositionalArgs(args); const parsed = parseOrgProjectArg(targetArg); - if (parsed.type !== "auto-detect" && parsed.normalized) { - log.warn("Normalized slug (Sentry slugs use dashes, not underscores)"); - } // Resolve target let target: { org: string; project: string } | null = null; diff --git a/src/commands/span/view.ts b/src/commands/span/view.ts index 819e4f30..0d8aa34a 100644 --- a/src/commands/span/view.ts +++ b/src/commands/span/view.ts @@ -284,14 +284,10 @@ export const viewCommand = buildCommand({ async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd, setContext } = this; - const cmdLog = logger.withTag("span.view"); // Parse positional args: first is trace ID (with optional target), rest are span IDs const { traceId, spanIds, targetArg } = parsePositionalArgs(args); const parsed = parseOrgProjectArg(targetArg); - if (parsed.type !== "auto-detect" && parsed.normalized) { - cmdLog.warn("Normalized slug (Sentry slugs use dashes, not underscores)"); - } const target = await resolveTarget(parsed, traceId, cwd); diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 5c97b76a..a8ae90ee 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -202,9 +202,6 @@ export const viewCommand = buildCommand({ log.warn(suggestion); } const parsed = parseOrgProjectArg(targetArg); - if (parsed.type !== "auto-detect" && parsed.normalized) { - log.warn("Normalized slug (Sentry slugs use dashes, not underscores)"); - } let target: ResolvedTraceTarget | null = null; diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index b9ee3209..c52a68d3 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -8,6 +8,7 @@ import { ContextError, ValidationError } from "./errors.js"; import { validateResourceId } from "./input-validation.js"; +import { logger } from "./logger.js"; import type { ParsedSentryUrl } from "./sentry-url-parser.js"; import { applySentryUrlContext, parseSentryUrl } from "./sentry-url-parser.js"; import { isAllDigits } from "./utils.js"; @@ -40,6 +41,36 @@ export function normalizeSlug(slug: string): { return { slug, normalized: false }; } +const log = logger.withTag("arg-parsing"); + +/** + * Emit a warning when slug normalization replaced underscores with dashes. + * Called internally by {@link parseOrgProjectArg} — callers do not need to + * check `parsed.normalized` themselves. + */ +function warnNormalized( + parsed: Exclude +): void { + let slug: string; + switch (parsed.type) { + case "explicit": + slug = `${parsed.org}/${parsed.project}`; + break; + case "org-all": + slug = `${parsed.org}/`; + break; + case "project-search": + slug = parsed.projectSlug; + break; + default: + return; + } + + log.warn( + `Normalized slug to '${slug}' (Sentry slugs use dashes, never underscores)` + ); +} + // --------------------------------------------------------------------------- // Issue short ID detection // --------------------------------------------------------------------------- @@ -420,18 +451,25 @@ export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject { return orgProjectFromUrl(urlParsed); } + let parsed: ParsedOrgProject; if (trimmed.includes("/")) { - return parseSlashOrgProject(trimmed); + parsed = parseSlashOrgProject(trimmed); + } else { + // No slash → search for project across all orgs + validateResourceId(trimmed, "project slug"); + const np = normalizeSlug(trimmed); + parsed = { + type: "project-search", + projectSlug: np.slug, + ...(np.normalized && { normalized: true }), + }; } - // No slash → search for project across all orgs - validateResourceId(trimmed, "project slug"); - const np = normalizeSlug(trimmed); - return { - type: "project-search", - projectSlug: np.slug, - ...(np.normalized && { normalized: true }), - }; + if (parsed.type !== "auto-detect" && parsed.normalized) { + warnNormalized(parsed); + } + + return parsed; } /** diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 0542170d..0684403c 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -6,7 +6,7 @@ * error messages and edge cases. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { detectSwappedTrialArgs, detectSwappedViewArgs, @@ -177,6 +177,80 @@ describe("parseOrgProjectArg", () => { }); }); }); + + describe("slug normalization warning", () => { + let stderrSpy: ReturnType; + let stderrOutput: string; + + beforeEach(() => { + stderrOutput = ""; + stderrSpy = spyOn(process.stderr, "write").mockImplementation( + (chunk: string | Uint8Array) => { + stderrOutput += typeof chunk === "string" ? chunk : ""; + return true; + } + ); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + }); + + test("emits warning for underscored project slug", () => { + const result = parseOrgProjectArg("my_project"); + expect(result).toEqual({ + type: "project-search", + projectSlug: "my-project", + normalized: true, + }); + expect(stderrOutput).toContain("Normalized slug to 'my-project'"); + expect(stderrOutput).toContain( + "Sentry slugs use dashes, never underscores" + ); + }); + + test("emits warning for underscored org in explicit mode", () => { + const result = parseOrgProjectArg("my_org/cli"); + expect(result).toEqual({ + type: "explicit", + org: "my-org", + project: "cli", + normalized: true, + }); + expect(stderrOutput).toContain("Normalized slug to 'my-org/cli'"); + }); + + test("emits warning for underscored project in explicit mode", () => { + const result = parseOrgProjectArg("sentry/my_project"); + expect(result).toEqual({ + type: "explicit", + org: "sentry", + project: "my-project", + normalized: true, + }); + expect(stderrOutput).toContain("Normalized slug to 'sentry/my-project'"); + }); + + test("emits warning for underscored org in org-all mode", () => { + const result = parseOrgProjectArg("my_org/"); + expect(result).toEqual({ + type: "org-all", + org: "my-org", + normalized: true, + }); + expect(stderrOutput).toContain("Normalized slug to 'my-org/'"); + }); + + test("does not emit warning for auto-detect", () => { + parseOrgProjectArg(undefined); + expect(stderrOutput).not.toContain("Normalized slug"); + }); + + test("does not emit warning when no underscores present", () => { + parseOrgProjectArg("sentry/cli"); + expect(stderrOutput).not.toContain("Normalized slug"); + }); + }); }); describe("parseIssueArg", () => {