From de0baac00bbac53049aaf2a6b1a35e128cd3bd7d Mon Sep 17 00:00:00 2001 From: betegon Date: Sun, 25 Oct 2026 22:39:42 +0100 Subject: [PATCH] fix(cli): honor event IDs after issue short IDs --- apps/cli-docs/src/fragments/commands/event.md | 7 + .../skills/sentry-cli/references/event.md | 9 ++ packages/cli/src/commands/event/view.ts | 39 ++++-- packages/cli/test/commands/event/view.test.ts | 77 +++-------- packages/cli/test/e2e/event.test.ts | 121 +++++++++++++++++- 5 files changed, 177 insertions(+), 76 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/event.md b/apps/cli-docs/src/fragments/commands/event.md index 8472ba6dd..786626269 100644 --- a/apps/cli-docs/src/fragments/commands/event.md +++ b/apps/cli-docs/src/fragments/commands/event.md @@ -19,6 +19,13 @@ The DSN is resolved in priority order: 2. `SENTRY_DSN` environment variable 3. Auto-detection from the current project (`.env`, source code, env files) +## Selecting an event from an issue + +Copy a full event ID from `sentry issue events PROJ-ABC` and pass it after the +issue short ID to open that event. The issue resolves the project; omitting the +event ID selects the latest event. Invalid event IDs return an error instead of +selecting the latest event. + ## Attachments `sentry event view --json` lists an event's attachment metadata. Download an diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md index 9d12fbdc8..7c7bdc2e7 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -78,6 +78,15 @@ View details of one or more events # View an event sentry event view abc123def456abc123def456abc12345 +# View a specific event using an issue short ID +sentry event view PROJ-ABC 7388f6a62b7d436ab77bb5365f97a1ac + +# View a specific event with an explicit organization +sentry event view my-org/PROJ-ABC 7388f6a62b7d436ab77bb5365f97a1ac + +# View the latest event for an issue +sentry event view PROJ-ABC + # Open in the browser sentry event view abc123def456abc123def456abc12345 -w diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index cd234f051..9f64345eb 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -374,6 +374,7 @@ type ParsedPositionalArgs = { * Handles: * - `` — event ID only (auto-detect org/project) * - ` ` — explicit target + event ID + * - `[/] ` — resolve the project from the issue * - `` — extract eventId and org from a Sentry event URL * (e.g., `https://sentry.example.com/organizations/my-org/issues/123/events/abc/`) * - `` — extract issueId and org; caller fetches latest event @@ -458,16 +459,15 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { }; } - // Detect issue short ID passed as first arg (e.g., "CAM-82X 95fd7f5a"). - // Auto-redirect to the issue's latest event instead of treating the short - // ID as a project slug (which would fail — slugs are lowercase, short IDs - // are uppercase). The second arg is ignored since we fetch the latest event. - if (looksLikeIssueShortId(first)) { + // An issue identifies the project; an explicit event ID must not be replaced + // by latest. Reuse the single-arg parser to preserve an optional org prefix. + const issueShortId = + slashCount === 1 ? first.slice(first.indexOf("/") + 1) : first; + if (looksLikeIssueShortId(issueShortId)) { return { - eventId: LATEST_EVENT_SENTINEL, - targetArg: undefined, - issueShortId: first, - warning: `'${first}' is an issue short ID, not a project slug. Ignoring second argument '${second}'.`, + ...parseSingleArg(first), + // Issue shortcuts run before the normal event-ID validation path. + eventId: validateHexId(second, "event ID"), }; } @@ -985,8 +985,8 @@ async function resolveIssueIdShortcut( /** * Handle issue-based shortcuts: issue URLs and issue short IDs. * - * Both paths resolve an issue and fetch its latest event. Extracted from - * func() to reduce cyclomatic complexity. + * Resolve the issue's project for an explicit event ID, or fetch the latest + * event when no event was selected. Extracted from func() to reduce complexity. * * @returns Result with org, data, and hint — or null if not an issue shortcut */ @@ -1022,7 +1022,7 @@ async function resolveIssueShortcut( ); } - // When the user specified a specific event ID (SHORT-ID/EVENT-ID), + // When the user specified an event ID (SHORT-ID/ID or SHORT-ID ID), // resolve the issue to get the project, then fetch the specific event. if (eventId !== LATEST_EVENT_SENTINEL) { const issue = await getIssueByShortId(resolved.org, issueShortId); @@ -1169,6 +1169,8 @@ export const viewCommand = buildCommand({ brief: "View details of one or more events", fullDescription: "View detailed information about Sentry events by their IDs.\n\n" + + "Pass an issue short ID followed by an event ID to resolve the project from the issue.\n" + + "An issue short ID without an event ID shows its latest event.\n\n" + "Multiple event IDs can be passed as separate arguments or newline-separated\n" + "within a single argument (handy when piping from other commands).\n\n" + "Attachment metadata (id, name, size, mimetype, download URL) is included when available.\n" + @@ -1178,6 +1180,19 @@ export const viewCommand = buildCommand({ description: "View an event", command: "sentry event view abc123def456abc123def456abc12345", }, + { + description: "View a specific event using an issue short ID", + command: "sentry event view PROJ-ABC 7388f6a62b7d436ab77bb5365f97a1ac", + }, + { + description: "View a specific event with an explicit organization", + command: + "sentry event view my-org/PROJ-ABC 7388f6a62b7d436ab77bb5365f97a1ac", + }, + { + description: "View the latest event for an issue", + command: "sentry event view PROJ-ABC", + }, { description: "Open in the browser", command: "sentry event view abc123def456abc123def456abc12345 -w", diff --git a/packages/cli/test/commands/event/view.test.ts b/packages/cli/test/commands/event/view.test.ts index 310aa9bae..ae41ad7d5 100644 --- a/packages/cli/test/commands/event/view.test.ts +++ b/packages/cli/test/commands/event/view.test.ts @@ -209,25 +209,24 @@ describe("parsePositionalArgs", () => { expect(result.eventId).toBe("abc123def456"); }); - test("auto-redirects issue short ID in first arg to issueShortId (CLI-MP)", () => { - // "JAVASCRIPT-NUXT-52" matches looksLikeIssueShortId → redirect to issue's - // latest event instead of treating it as a project slug (which would fail). - const result = parsePositionalArgs([ - "JAVASCRIPT-NUXT-52", - "abc123def456", - ]); - expect(result.issueShortId).toBe("JAVASCRIPT-NUXT-52"); - expect(result.eventId).toBe("@latest"); - expect(result.targetArg).toBeUndefined(); - expect(result.warning).toContain("issue short ID"); - }); + test.each([ + "7388f6a62b7d436ab77bb5365f97a1ac", + "7388F6A6-2B7D-436A-B77B-B5365F97A1AC", + ])( + "preserves and normalizes the event ID after an issue short ID: %s", + (eventId) => { + const result = parsePositionalArgs(["JAVASCRIPT-NUXT-52", eventId]); + expect(result.issueShortId).toBe("JAVASCRIPT-NUXT-52"); + expect(result.eventId).toBe("7388f6a62b7d436ab77bb5365f97a1ac"); + expect(result.targetArg).toBeUndefined(); + expect(result.warning).toBeUndefined(); + }, + ); - test("auto-redirects simple issue short ID like CAM-82X", () => { - const result = parsePositionalArgs(["CAM-82X", "95fd7f5a"]); - expect(result.issueShortId).toBe("CAM-82X"); - expect(result.eventId).toBe("@latest"); - expect(result.targetArg).toBeUndefined(); - expect(result.warning).toContain("issue short ID"); + test("rejects an incomplete event ID instead of selecting latest", () => { + expect(() => parsePositionalArgs(["CAM-82X", "95fd7f5a"])).toThrow( + ValidationError, + ); }); }); @@ -1190,48 +1189,6 @@ describe("viewCommand.func", () => { expect(output.attachments).toEqual([]); }); - test("auto-redirects issue short ID in two-arg form via issueShortId path", async () => { - // "CAM-82X" as first arg matches looksLikeIssueShortId → sets issueShortId, - // NOT targetArg. The resolveIssueShortcut path fetches the latest event. - const resolveOrgSpy = vi - .spyOn(resolveTarget, "resolveOrg") - .mockResolvedValue({ - org: "cam-org", - }); - const getIssueByShortIdSpy = vi - .spyOn(apiClient, "getIssueByShortId") - .mockResolvedValue({ id: "999", shortId: "CAM-82X" } as never); - const getLatestEventSpy = vi - .spyOn(apiClient, "getLatestEvent") - .mockResolvedValue(sampleEvent); - getSpanTreeLinesSpy.mockResolvedValue({ - lines: [], - spans: null, - traceId: null, - success: false, - }); - - const { context } = createMockContext(); - const func = await viewCommand.loader(); - // First arg "CAM-82X" is an issue short ID, second arg "95fd7f5a" is ignored - await func.call( - context, - { json: true, web: false, spans: 0 }, - "CAM-82X", - "95fd7f5a", - ); - - // Should NOT go through resolveProjectBoundSlug (the old buggy path) - expect(resolveProjectBoundSlugSpy).not.toHaveBeenCalled(); - // Should resolve via issue short ID path - expect(getIssueByShortIdSpy).toHaveBeenCalledWith("cam-org", "CAM-82X"); - expect(getLatestEventSpy).toHaveBeenCalled(); - - resolveOrgSpy.mockRestore(); - getIssueByShortIdSpy.mockRestore(); - getLatestEventSpy.mockRestore(); - }); - test("numeric issue ID resolves org via resolveOrg then fetches latest event", async () => { // Regression: the numeric-issue-ID path must auto-detect the org via // resolveOrg (env/config/DSN) rather than resolveEffectiveOrg(""), which diff --git a/packages/cli/test/e2e/event.test.ts b/packages/cli/test/e2e/event.test.ts index bb11cb872..658df8e5a 100644 --- a/packages/cli/test/e2e/event.test.ts +++ b/packages/cli/test/e2e/event.test.ts @@ -1,7 +1,7 @@ /** * Event Command E2E Tests * - * Tests for sentry event get command. + * Tests event lookup and the list-to-view flow through the real CLI. */ import { @@ -15,21 +15,64 @@ import { } from "vitest"; import { EXIT } from "../../src/lib/errors.js"; import { createE2EContext, type E2EContext } from "../fixture.js"; +import eventFixture from "../fixtures/event.json"; import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; import { - createSentryMockServer, + apiRoutes, + TEST_ISSUE_ID, + TEST_ISSUE_SHORT_ID, TEST_ORG, TEST_PROJECT, TEST_TOKEN, } from "../mocks/routes.js"; -import type { MockServer } from "../mocks/server.js"; +import { createMockServer, type MockServer } from "../mocks/server.js"; + +const EVENT_ID = "abcd1234abcd5678abcd1234abcd5678"; +const event = { ...eventFixture, id: EVENT_ID, eventID: EVENT_ID }; +const LATEST_EVENT_ID = "fedc1234fedc5678fedc1234fedc5678"; +const latestEvent = { + ...event, + id: LATEST_EVENT_ID, + eventID: LATEST_EVENT_ID, + dateCreated: "2024-01-16T09:45:00.000000Z", +}; let testConfigDir: string; let mockServer: MockServer; let ctx: E2EContext; beforeAll(async () => { - mockServer = createSentryMockServer(); + mockServer = createMockServer( + [ + { + method: "GET", + path: `/api/0/organizations/${TEST_ORG}/issues/${TEST_ISSUE_ID}/events/`, + response: [latestEvent, event], + }, + { + method: "GET", + path: `/api/0/projects/${TEST_ORG}/${TEST_PROJECT}/events/${EVENT_ID}/`, + response: event, + }, + { + method: "GET", + path: `/api/0/projects/${TEST_ORG}/${TEST_PROJECT}/events/${EVENT_ID}/attachments/`, + response: [], + }, + { + method: "GET", + path: `/api/0/organizations/${TEST_ORG}/issues/${TEST_ISSUE_ID}/events/latest/`, + response: latestEvent, + }, + { + method: "GET", + path: `/api/0/projects/${TEST_ORG}/${TEST_PROJECT}/events/${LATEST_EVENT_ID}/attachments/`, + response: [], + }, + ...apiRoutes, + ], + { validTokens: [TEST_TOKEN] }, + ); await mockServer.start(); }); @@ -47,6 +90,76 @@ afterEach(async () => { }); describe("sentry event view", () => { + test.each([TEST_ISSUE_SHORT_ID, `${TEST_ORG}/${TEST_ISSUE_SHORT_ID}`])( + "opens the listed event instead of the latest for %s ", + async (issueArg) => { + await ctx.setAuthToken(TEST_TOKEN); + const defaults = await ctx.run(["cli", "defaults", "org", TEST_ORG]); + expect(defaults.exitCode, defaults.stderr).toBe(0); + + const latest = await ctx.run([ + "event", + "view", + issueArg, + "--json", + "--spans", + "0", + ]); + expect(latest.exitCode, latest.stderr).toBe(0); + expect(JSON.parse(latest.stdout)).toMatchObject({ + eventID: LATEST_EVENT_ID, + }); + + const listed = await ctx.run([ + "issue", + "events", + issueArg, + "--limit", + "2", + ]); + expect(listed.exitCode, listed.stderr).toBe(0); + const copiedId = listed.stdout.match(/\b[0-9a-f]{32}\b/g)?.[1] ?? ""; + expect(copiedId, listed.stdout).toBe(EVENT_ID); + + const viewed = await ctx.run([ + "event", + "view", + issueArg, + copiedId, + "--json", + "--spans", + "0", + ]); + expect(viewed.exitCode, viewed.stderr).toBe(0); + expect(JSON.parse(viewed.stdout)).toMatchObject({ eventID: copiedId }); + }, + ); + + test.each([ + { id: "abcd1234abcd", error: /32-character hexadecimal/i }, + { id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", error: /not found/i }, + ])( + "fails instead of showing latest when the selected ID is $id", + async ({ id, error }) => { + await ctx.setAuthToken(TEST_TOKEN); + const defaults = await ctx.run(["cli", "defaults", "org", TEST_ORG]); + expect(defaults.exitCode, defaults.stderr).toBe(0); + + const result = await ctx.run([ + "event", + "view", + TEST_ISSUE_SHORT_ID, + id, + "--json", + "--spans", + "0", + ]); + expect(result.exitCode, result.stderr).not.toBe(0); + expect(result.stderr).toMatch(error); + expect(result.stdout).not.toContain(LATEST_EVENT_ID); + }, + ); + test("requires authentication", async () => { // Use positional arg format: / const result = await ctx.run([