From b3f36501e9244351cd07dafe0758a0d4b21132f7 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 4 Apr 2026 09:33:38 +0000 Subject: [PATCH 1/5] feat(issue): add `sentry issue events` command to list events for an issue (#632) Add a new subcommand `sentry issue events ` that lists events belonging to a specific Sentry issue. This was the most requested unknown command in CLI telemetry. - Add IssueEvent type and IssueEventSchema for the list endpoint response - Add listIssueEvents() API function with auto-pagination support - Create events command with table output (EVENT ID, TIMESTAMP, TITLE, PLATFORM, USER) and standard JSON envelope - Support all issue ID formats (@latest, short IDs, numeric, org/ID) - Include --limit, --query, --full, --period, --cursor, --fresh flags - Bidirectional pagination via cursor-stack infrastructure - 16 unit tests covering JSON/human output, pagination, and edge cases - Regenerate SKILL.md with new command documentation --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 1 + .../skills/sentry-cli/references/issues.md | 32 ++ src/commands/issue/events.ts | 368 +++++++++++++ src/commands/issue/index.ts | 6 +- src/lib/api-client.ts | 1 + src/lib/api/events.ts | 92 +++- src/types/index.ts | 2 + src/types/sentry.ts | 87 +++ test/commands/issue/events.func.test.ts | 497 ++++++++++++++++++ 9 files changed, 1083 insertions(+), 3 deletions(-) create mode 100644 src/commands/issue/events.ts create mode 100644 test/commands/issue/events.func.test.ts diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 7cb991827..b5e9551b8 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -269,6 +269,7 @@ Work with Sentry projects Manage Sentry issues - `sentry issue list ` — List issues in a project +- `sentry issue events ` — List events for a specific issue - `sentry issue explain ` — Analyze an issue's root cause using Seer AI - `sentry issue plan ` — Generate a solution plan using Seer AI - `sentry issue view ` — View details of a specific issue diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issues.md b/plugins/sentry-cli/skills/sentry-cli/references/issues.md index d41ceac7e..493f01c7e 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issues.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issues.md @@ -70,6 +70,38 @@ sentry issue list my-org/frontend --query "is:resolved" sentry issue list my-org/frontend --sort freq --limit 20 ``` +### `sentry issue events ` + +List events for a specific issue + +**Flags:** +- `-n, --limit - Number of events (1-1000) - (default: "25")` +- `-q, --query - Search query (Sentry search syntax)` +- `--full - Include full event body (stacktraces)` +- `-t, --period - Time period (e.g., "1h", "24h", "7d", "30d") - (default: "7d")` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Internal event ID | +| `event.type` | string | Event type (error, default, transaction) | +| `groupID` | string \| null | Group (issue) ID | +| `eventID` | string | UUID-format event ID | +| `projectID` | string | Project ID | +| `message` | string | Event message | +| `title` | string | Event title | +| `location` | string \| null | Source location (file:line) | +| `culprit` | string \| null | Culprit function/module | +| `user` | object \| null | User context | +| `tags` | array | Event tags | +| `platform` | string \| null | Platform (python, javascript, etc.) | +| `dateCreated` | string | ISO 8601 creation timestamp | +| `crashFile` | string \| null | Crash file URL | +| `metadata` | unknown \| null | Event metadata | + ### `sentry issue explain ` Analyze an issue's root cause using Seer AI diff --git a/src/commands/issue/events.ts b/src/commands/issue/events.ts new file mode 100644 index 000000000..c9fbdb63b --- /dev/null +++ b/src/commands/issue/events.ts @@ -0,0 +1,368 @@ +/** + * sentry issue events + * + * List events for a specific Sentry issue. + */ + +import type { SentryContext } from "../../context.js"; +import { listIssueEvents } from "../../lib/api-client.js"; +import { validateLimit } from "../../lib/arg-parsing.js"; +import { + advancePaginationState, + buildPaginationContextKey, + hasPreviousPage, + resolveCursor, +} from "../../lib/db/pagination.js"; +import { ContextError } from "../../lib/errors.js"; +import { filterFields } from "../../lib/formatters/json.js"; +import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { type Column, formatTable } from "../../lib/formatters/table.js"; +import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; +import { + buildListCommand, + LIST_PERIOD_FLAG, + PERIOD_ALIASES, + paginationHint, +} from "../../lib/list-command.js"; +import { withProgress } from "../../lib/polling.js"; +import { type IssueEvent, IssueEventSchema } from "../../types/index.js"; +import { buildCommandHint, issueIdPositional, resolveIssue } from "./utils.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type EventsFlags = { + readonly limit: number; + readonly query?: string; + readonly full: boolean; + readonly period: string; + readonly json: boolean; + readonly cursor?: string; + readonly fresh: boolean; + readonly fields?: string[]; +}; + +/** + * Result data for the issue events command. + * + * Contains the events array plus pagination metadata and context + * needed by both the human formatter and JSON transform. + */ +type IssueEventsResult = { + /** The list of events returned by the API */ + events: IssueEvent[]; + /** Whether more pages are available */ + hasMore: boolean; + /** Whether a previous page exists (for bidirectional hints) */ + hasPrev: boolean; + /** Opaque cursor for fetching the next page */ + nextCursor?: string | null; + /** The issue short ID (for display in header) */ + issueShortId: string; + /** The numeric issue ID (for pagination context) */ + issueId: string; +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Maximum allowed value for --limit flag */ +const MAX_LIMIT = 1000; + +/** Minimum allowed value for --limit flag */ +const MIN_LIMIT = 1; + +/** Default number of events to show */ +const DEFAULT_LIMIT = 25; + +/** Command name used in issue resolution error messages */ +const COMMAND_NAME = "events"; + +/** Default time period for event queries */ +const DEFAULT_PERIOD = "7d"; + +/** Command key for pagination cursor storage */ +export const PAGINATION_KEY = "issue-events"; + +// --------------------------------------------------------------------------- +// Table columns +// --------------------------------------------------------------------------- + +/** Extract a display label for the event's user, falling back through available fields. */ +function getUserLabel(event: IssueEvent): string { + if (!event.user) { + return colorTag("muted", "—"); + } + const label = + event.user.email ?? + event.user.username ?? + event.user.ip_address ?? + event.user.id; + return label ? escapeMarkdownCell(label) : colorTag("muted", "—"); +} + +/** Table columns for issue events list */ +const EVENT_COLUMNS: Column[] = [ + { + header: "EVENT ID", + value: (e) => `\`${e.eventID.slice(0, 12)}\``, + shrinkable: false, + }, + { + header: "TIMESTAMP", + value: (e) => formatRelativeTime(e.dateCreated), + }, + { + header: "TITLE", + value: (e) => escapeMarkdownCell(e.title), + truncate: true, + }, + { + header: "PLATFORM", + value: (e) => escapeMarkdownCell(e.platform ?? "—"), + }, + { + header: "USER", + value: getUserLabel, + }, +]; + +// --------------------------------------------------------------------------- +// Formatters +// --------------------------------------------------------------------------- + +/** + * Format issue events data for human-readable terminal output. + * + * Handles three display states: + * - Empty list with more pages → "No events on this page." + * - Empty list, no more pages → "No events found for this issue." + * - Non-empty → header line + formatted table + */ +function formatIssueEventsHuman(result: IssueEventsResult): string { + const { events, hasMore, issueShortId } = result; + + if (events.length === 0) { + return hasMore + ? "No events on this page." + : "No events found for this issue."; + } + + return `Events for ${issueShortId}:\n\n${formatTable(events, EVENT_COLUMNS)}`; +} + +/** + * Transform issue events data into the JSON list envelope. + * + * Produces the standard `{ data, hasMore, hasPrev, nextCursor? }` envelope. + * Field filtering is applied per-element inside `data`. + */ +function jsonTransformIssueEvents( + result: IssueEventsResult, + fields?: string[] +): unknown { + const items = + fields && fields.length > 0 + ? result.events.map((e) => filterFields(e, fields)) + : result.events; + + const envelope: Record = { + data: items, + hasMore: result.hasMore, + hasPrev: result.hasPrev, + }; + if ( + result.nextCursor !== null && + result.nextCursor !== undefined && + result.nextCursor !== "" + ) { + envelope.nextCursor = result.nextCursor; + } + return envelope; +} + +// --------------------------------------------------------------------------- +// Pagination hints +// --------------------------------------------------------------------------- + +/** Append active non-default flags to a base command string. */ +function appendEventsFlags( + base: string, + flags: Pick +): string { + const parts: string[] = []; + if (flags.query) { + parts.push(`-q "${flags.query}"`); + } + if (flags.full) { + parts.push("--full"); + } + if (flags.period !== DEFAULT_PERIOD) { + parts.push(`--period ${flags.period}`); + } + return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; +} + +/** Build the CLI hint for fetching the next page, preserving active flags. */ +function nextPageHint( + issueArg: string, + flags: Pick +): string { + return appendEventsFlags(`sentry issue events ${issueArg} -c next`, flags); +} + +/** Build the CLI hint for fetching the previous page, preserving active flags. */ +function prevPageHint( + issueArg: string, + flags: Pick +): string { + return appendEventsFlags(`sentry issue events ${issueArg} -c prev`, flags); +} + +// --------------------------------------------------------------------------- +// Flag parsing +// --------------------------------------------------------------------------- + +/** Parse --limit flag, delegating range validation to shared utility. */ +function parseLimit(value: string): number { + return validateLimit(value, MIN_LIMIT, MAX_LIMIT); +} + +// --------------------------------------------------------------------------- +// Command definition +// --------------------------------------------------------------------------- + +export const eventsCommand = buildListCommand("issue", { + docs: { + brief: "List events for a specific issue", + fullDescription: + "List events belonging to a Sentry issue.\n\n" + + "Issue formats:\n" + + " @latest - Most recent unresolved issue\n" + + " @most_frequent - Issue with highest event frequency\n" + + " /ID - Explicit org: sentry/EXTENSION-7\n" + + " -suffix - Project + suffix: cli-G\n" + + " ID - Short ID: CLI-G\n" + + " numeric - Numeric ID: 123456789\n\n" + + "Examples:\n" + + " sentry issue events CLI-G\n" + + " sentry issue events @latest --limit 50\n" + + " sentry issue events 123456789 --full\n" + + ' sentry issue events CLI-G -q "user.email:foo@bar.com"\n' + + " sentry issue events CLI-G --json", + }, + output: { + human: formatIssueEventsHuman, + jsonTransform: jsonTransformIssueEvents, + schema: IssueEventSchema, + }, + parameters: { + positional: issueIdPositional, + flags: { + limit: { + kind: "parsed", + parse: parseLimit, + brief: `Number of events (${MIN_LIMIT}-${MAX_LIMIT})`, + default: String(DEFAULT_LIMIT), + }, + query: { + kind: "parsed", + parse: String, + brief: "Search query (Sentry search syntax)", + optional: true, + }, + full: { + kind: "boolean", + brief: "Include full event body (stacktraces)", + default: false, + }, + period: LIST_PERIOD_FLAG, + }, + aliases: { + ...PERIOD_ALIASES, + n: "limit", + q: "query", + }, + }, + async *func(this: SentryContext, flags: EventsFlags, issueArg: string) { + const { cwd } = this; + + // Resolve issue using shared resolution logic (supports @latest, short IDs, etc.) + const { org, issue } = await resolveIssue({ + issueArg, + cwd, + command: COMMAND_NAME, + }); + + // Org is required for region-routed events endpoint + if (!org) { + throw new ContextError( + "Organization", + buildCommandHint(COMMAND_NAME, issueArg) + ); + } + + // Build context key for pagination (keyed by issue ID + query-varying params) + const contextKey = buildPaginationContextKey( + "issue-events", + `${org}/${issue.id}`, + { q: flags.query, period: flags.period } + ); + const { cursor, direction } = resolveCursor( + flags.cursor, + PAGINATION_KEY, + contextKey + ); + + const { data: events, nextCursor } = await withProgress( + { + message: `Fetching events for ${issue.shortId} (up to ${flags.limit})...`, + json: flags.json, + }, + () => + listIssueEvents(org, issue.id, { + limit: flags.limit, + query: flags.query, + full: flags.full, + cursor, + statsPeriod: flags.period, + }) + ); + + // Update pagination state (handles both advance and truncation) + advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); + const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); + + const hasMore = !!nextCursor; + + // Build footer hint based on result state + const nav = paginationHint({ + hasMore, + hasPrev, + prevHint: prevPageHint(issueArg, flags), + nextHint: nextPageHint(issueArg, flags), + }); + let hint: string | undefined; + if (events.length === 0 && nav) { + hint = `No events on this page. ${nav}`; + } else if (events.length > 0) { + const countText = `Showing ${events.length} event${events.length === 1 ? "" : "s"}.`; + hint = nav + ? `${countText} ${nav}` + : `${countText} Use 'sentry event view ' to view full event details.`; + } + + yield new CommandOutput({ + events, + hasMore, + hasPrev, + nextCursor, + issueShortId: issue.shortId, + issueId: issue.id, + }); + return { hint }; + }, +}); diff --git a/src/commands/issue/index.ts b/src/commands/issue/index.ts index 9d59e4fcb..79b24c23d 100644 --- a/src/commands/issue/index.ts +++ b/src/commands/issue/index.ts @@ -1,4 +1,5 @@ import { buildRouteMap } from "@stricli/core"; +import { eventsCommand } from "./events.js"; import { explainCommand } from "./explain.js"; import { listCommand } from "./list.js"; import { planCommand } from "./plan.js"; @@ -7,6 +8,7 @@ import { viewCommand } from "./view.js"; export const issueRoute = buildRouteMap({ routes: { list: listCommand, + events: eventsCommand, explain: explainCommand, plan: planCommand, view: viewCommand, @@ -17,14 +19,16 @@ export const issueRoute = buildRouteMap({ "View and manage issues from your Sentry projects.\n\n" + "Commands:\n" + " list List issues in a project\n" + + " events List events for a specific issue\n" + " view View details of a specific issue\n" + " explain Analyze an issue using Seer AI\n" + " plan Generate a solution plan using Seer AI\n\n" + - "Magic selectors (available for view, explain, plan):\n" + + "Magic selectors (available for view, events, explain, plan):\n" + " @latest Most recent unresolved issue\n" + " @most_frequent Issue with the highest event frequency\n\n" + "Examples:\n" + " sentry issue view @latest\n" + + " sentry issue events CLI-G\n" + " sentry issue explain @most_frequent\n" + " sentry issue plan my-org/@latest\n\n" + "Alias: `sentry issues` → `sentry issue list`", diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index c78eb0fa4..9d6eefb5b 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -30,6 +30,7 @@ export { findEventAcrossOrgs, getEvent, getLatestEvent, + listIssueEvents, type ResolvedEvent, resolveEventInOrg, } from "./api/events.js"; diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts index 21b78b1a5..8b718ae9b 100644 --- a/src/lib/api/events.ts +++ b/src/lib/api/events.ts @@ -1,23 +1,28 @@ /** * Event API functions * - * Functions for retrieving and resolving Sentry events. + * Functions for retrieving, listing, and resolving Sentry events. */ import { + listAnIssue_sEvents, retrieveAnEventForAProject, retrieveAnIssueEvent, resolveAnEventId as sdkResolveAnEventId, } from "@sentry/api"; import pLimit from "p-limit"; -import type { SentryEvent } from "../../types/index.js"; +import type { IssueEvent, SentryEvent } from "../../types/index.js"; import { ApiError, AuthError } from "../errors.js"; import { + API_MAX_PER_PAGE, getOrgSdkConfig, + MAX_PAGINATION_PAGES, ORG_FANOUT_CONCURRENCY, + type PaginatedResponse, + unwrapPaginatedResult, unwrapResult, } from "./infrastructure.js"; import { listOrganizations } from "./organizations.js"; @@ -152,3 +157,86 @@ export async function findEventAcrossOrgs( } return null; } + +// --------------------------------------------------------------------------- +// Issue event listing +// --------------------------------------------------------------------------- + +/** Options for {@link listIssueEvents}. */ +export type ListIssueEventsOptions = { + /** Max items to return (total across all auto-paginated pages). @default 25 */ + limit?: number; + /** Search query (Sentry search syntax). */ + query?: string; + /** Include full event body (stacktraces, breadcrumbs). */ + full?: boolean; + /** Pagination cursor from a previous response. */ + cursor?: string; + /** Relative time period (e.g., "7d", "24h"). Overrides start/end on the API. */ + statsPeriod?: string; +}; + +/** + * List events for a specific issue. + * + * Uses the SDK's `listAnIssue_sEvents` endpoint with region-aware routing. + * When `limit` exceeds {@link API_MAX_PER_PAGE} (100), auto-paginates through + * multiple API calls to fill the requested limit, bounded by {@link MAX_PAGINATION_PAGES}. + * + * @param orgSlug - Organization slug for region routing + * @param issueId - Numeric issue ID + * @param options - Query and pagination options + * @returns Paginated response with events array and optional next cursor + */ +export async function listIssueEvents( + orgSlug: string, + issueId: string, + options: ListIssueEventsOptions = {} +): Promise> { + const { limit = 25, query, full, cursor, statsPeriod } = options; + + const config = await getOrgSdkConfig(orgSlug); + + let allEvents: IssueEvent[] = []; + let currentCursor = cursor; + let nextCursor: string | undefined; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const result = await listAnIssue_sEvents({ + ...config, + path: { + organization_id_or_slug: orgSlug, + issue_id: Number(issueId), + }, + query: { + query: query || undefined, + full, + cursor: currentCursor, + statsPeriod, + }, + }); + + const paginated = unwrapPaginatedResult( + result as + | { data: IssueEvent[]; error: undefined } + | { data: undefined; error: unknown }, + "Failed to list issue events" + ); + + allEvents.push(...(paginated.data as IssueEvent[])); + nextCursor = paginated.nextCursor; + + if (allEvents.length >= limit || !nextCursor) { + break; + } + currentCursor = nextCursor; + } + + // Trim to exact limit; discard nextCursor if we overshot to avoid + // cursor-position skips (same pattern as listIssuesAllPages). + if (allEvents.length > limit) { + allEvents = allEvents.slice(0, limit); + } + + return { data: allEvents, nextCursor }; +} diff --git a/src/types/index.ts b/src/types/index.ts index e27d821b3..300a89bbb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -70,6 +70,7 @@ export type { DeviceContext, ExceptionEntry, ExceptionValue, + IssueEvent, IssueLevel, IssueStatus, LogsResponse, @@ -106,6 +107,7 @@ export { DetailedSentryLogSchema, ISSUE_LEVELS, ISSUE_STATUSES, + IssueEventSchema, LogsResponseSchema, ProductTrialSchema, RegionSchema, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 683920974..59234b118 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -239,6 +239,93 @@ export type SentryEvent = Omit< culprit?: string | null; }; +// Issue Event (list endpoint) + +/** + * A lightweight event from the issue events list endpoint. + * + * This is a subset of the full event detail — the list endpoint returns + * minimal event metadata without stacktraces, breadcrumbs, or contexts. + * Use {@link SentryEvent} for full event details from the detail endpoint. + */ +export type IssueEvent = { + /** Internal event ID (numeric string) */ + id: string; + /** Event type (e.g., "error", "default", "transaction") */ + "event.type": string; + /** The group (issue) ID this event belongs to */ + groupID: string | null; + /** UUID-format event ID */ + eventID: string; + /** Project ID (numeric string) */ + projectID: string; + /** Event message */ + message: string; + /** Event title (typically the error type + message) */ + title: string; + /** Source location (file:line) where the event originated */ + location: string | null; + /** The culprit (function/module that caused the error) */ + culprit: string | null; + /** User context if available */ + user: { + id?: string | null; + email?: string | null; + username?: string | null; + ip_address?: string | null; + name?: string | null; + } | null; + /** Event tags */ + tags: Array<{ key: string; value: string }>; + /** Platform (e.g., "python", "javascript") */ + platform: string | null; + /** ISO 8601 timestamp when the event was created */ + dateCreated: string; + /** Crash file URL if available */ + crashFile: string | null; + /** Event metadata */ + metadata: Record | null; +}; + +/** + * Zod schema for {@link IssueEvent} — used for `--fields` documentation in `--help`. + */ +export const IssueEventSchema = z + .object({ + id: z.string().describe("Internal event ID"), + "event.type": z + .string() + .describe("Event type (error, default, transaction)"), + groupID: z.string().nullable().describe("Group (issue) ID"), + eventID: z.string().describe("UUID-format event ID"), + projectID: z.string().describe("Project ID"), + message: z.string().describe("Event message"), + title: z.string().describe("Event title"), + location: z.string().nullable().describe("Source location (file:line)"), + culprit: z.string().nullable().describe("Culprit function/module"), + user: z + .object({ + id: z.string().nullish().describe("User ID"), + email: z.string().nullish().describe("User email"), + username: z.string().nullish().describe("Username"), + ip_address: z.string().nullish().describe("IP address"), + name: z.string().nullish().describe("User display name"), + }) + .nullable() + .describe("User context"), + tags: z + .array(z.object({ key: z.string(), value: z.string() })) + .describe("Event tags"), + platform: z + .string() + .nullable() + .describe("Platform (python, javascript, etc.)"), + dateCreated: z.string().describe("ISO 8601 creation timestamp"), + crashFile: z.string().nullable().describe("Crash file URL"), + metadata: z.record(z.unknown()).nullable().describe("Event metadata"), + }) + .describe("Issue event (list endpoint)"); + // Project Keys (DSN) /** diff --git a/test/commands/issue/events.func.test.ts b/test/commands/issue/events.func.test.ts new file mode 100644 index 000000000..f3c54839a --- /dev/null +++ b/test/commands/issue/events.func.test.ts @@ -0,0 +1,497 @@ +/** + * Issue Events Command Tests + * + * Tests for the `sentry issue events` command func() body and formatters. + * + * Uses spyOn with namespace imports to mock api-client, issue utils, + * and pagination DB functions without real HTTP calls or database access. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { eventsCommand } from "../../../src/commands/issue/events.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as issueUtils from "../../../src/commands/issue/utils.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +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 type { IssueEvent, SentryIssue } from "../../../src/types/sentry.js"; + +// Reference paginationDb early to prevent import stripping by auto-organize +const _paginationDbRef = paginationDb; + +// ============================================================================ +// Test fixtures +// ============================================================================ + +function makeMockIssue(overrides?: Partial): SentryIssue { + return { + id: "123456789", + shortId: "CLI-G5", + title: "TypeError: Cannot read property 'foo' of undefined", + culprit: "handleRequest", + count: "42", + userCount: 5, + firstSeen: "2026-03-01T00:00:00Z", + lastSeen: "2026-04-03T12:00:00Z", + level: "error", + status: "unresolved", + permalink: "https://sentry.io/organizations/test-org/issues/123456789/", + project: { id: "456", slug: "test-project", name: "Test Project" }, + ...overrides, + } as SentryIssue; +} + +function makeMockEvent(overrides?: Partial): IssueEvent { + return { + id: "1", + "event.type": "error", + groupID: "123456789", + eventID: "abcdef1234567890abcdef1234567890", + projectID: "456", + message: "TypeError: Cannot read property 'foo' of undefined", + title: "TypeError: Cannot read property 'foo' of undefined", + location: "src/app.js:42", + culprit: "handleRequest", + user: { email: "user@example.com" }, + tags: [{ key: "environment", value: "production" }], + platform: "javascript", + dateCreated: new Date().toISOString(), + crashFile: null, + metadata: null, + ...overrides, + }; +} + +const sampleEvents: IssueEvent[] = [ + makeMockEvent({ + id: "1", + eventID: "aaaa1111bbbb2222cccc3333dddd4444", + title: "TypeError: Cannot read property 'foo' of undefined", + platform: "javascript", + user: { email: "alice@example.com" }, + }), + makeMockEvent({ + id: "2", + eventID: "eeee5555ffff6666aaaa7777bbbb8888", + title: "ReferenceError: x is not defined", + platform: "python", + user: { username: "bob" }, + }), +]; + +// ============================================================================ +// listCommand.func() tests +// ============================================================================ + +describe("eventsCommand.func()", () => { + let listIssueEventsSpy: ReturnType; + let resolveIssueSpy: ReturnType; + let resolveCursorSpy: ReturnType; + let advancePaginationStateSpy: ReturnType; + let hasPreviousPageSpy: ReturnType; + + function createMockContext() { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; + } + + beforeEach(() => { + listIssueEventsSpy = spyOn(apiClient, "listIssueEvents"); + resolveIssueSpy = spyOn(issueUtils, "resolveIssue"); + resolveCursorSpy = spyOn(paginationDb, "resolveCursor").mockReturnValue({ + cursor: undefined, + direction: "next" as const, + }); + advancePaginationStateSpy = spyOn( + paginationDb, + "advancePaginationState" + ).mockReturnValue(undefined); + hasPreviousPageSpy = spyOn(paginationDb, "hasPreviousPage").mockReturnValue( + false + ); + }); + + afterEach(() => { + listIssueEventsSpy.mockRestore(); + resolveIssueSpy.mockRestore(); + resolveCursorSpy.mockRestore(); + advancePaginationStateSpy.mockRestore(); + hasPreviousPageSpy.mockRestore(); + }); + + test("outputs JSON with data array when --json flag is set", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ data: sampleEvents }); + + const { context, stdoutWrite } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 25, json: true, full: false, period: "7d" }, + "CLI-G5" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.hasMore).toBe(false); + expect(parsed.hasPrev).toBe(false); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.data).toHaveLength(2); + expect(parsed.data[0].eventID).toBe("aaaa1111bbbb2222cccc3333dddd4444"); + }); + + test("outputs empty JSON with hasMore false when no events found", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ data: [] }); + + const { context, stdoutWrite } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 25, json: true, full: false, period: "7d" }, + "CLI-G5" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(JSON.parse(output)).toEqual({ + data: [], + hasMore: false, + hasPrev: false, + }); + }); + + test("writes 'No events found' when empty without --json", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ data: [] }); + + const { context, stdoutWrite } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 25, json: false, full: false, period: "7d" }, + "CLI-G5" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No events found for this issue."); + }); + + test("writes header and table for human output", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ data: sampleEvents }); + + const { context, stdoutWrite } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 25, json: false, full: false, period: "7d" }, + "CLI-G5" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Events for CLI-G5:"); + expect(output).toContain("TypeError"); + expect(output).toContain("ReferenceError"); + expect(output).toContain("Showing 2 events."); + }); + + test("shows next page hint when nextCursor is present", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ + data: sampleEvents, + nextCursor: "1735689600:0:0", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 2, json: false, full: false, period: "7d" }, + "CLI-G5" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Next:"); + expect(output).toContain("-c next"); + }); + + test("shows event view tip when no nextCursor", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ data: sampleEvents }); + + const { context, stdoutWrite } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 100, json: false, full: false, period: "7d" }, + "CLI-G5" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).not.toContain("Next:"); + expect(output).toContain("sentry event view"); + }); + + test("uses singular 'event' for single result", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ data: [sampleEvents[0]] }); + + const { context, stdoutWrite } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 25, json: false, full: false, period: "7d" }, + "CLI-G5" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Showing 1 event."); + expect(output).not.toContain("Showing 1 events."); + }); + + test("passes query and full flags to API", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ data: [] }); + + const { context } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { + limit: 10, + json: false, + full: true, + query: "user.email:test@example.com", + period: "24h", + }, + "CLI-G5" + ); + + expect(listIssueEventsSpy).toHaveBeenCalledWith("test-org", "123456789", { + limit: 10, + query: "user.email:test@example.com", + full: true, + cursor: undefined, + statsPeriod: "24h", + }); + }); + + test("passes period flag as statsPeriod to API", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ data: [] }); + + const { context } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 25, json: false, full: false, period: "30d" }, + "CLI-G5" + ); + + expect(listIssueEventsSpy).toHaveBeenCalledWith( + "test-org", + "123456789", + expect.objectContaining({ statsPeriod: "30d" }) + ); + }); + + test("includes nextCursor in JSON envelope when present", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ + data: sampleEvents, + nextCursor: "abc123:0:0", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 2, json: true, full: false, period: "7d" }, + "CLI-G5" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.nextCursor).toBe("abc123:0:0"); + expect(parsed.hasMore).toBe(true); + }); + + test("omits nextCursor from JSON envelope when not present", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ data: sampleEvents }); + + const { context, stdoutWrite } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 25, json: true, full: false, period: "7d" }, + "CLI-G5" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.nextCursor).toBeUndefined(); + }); + + test("calls advancePaginationState after fetching", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ + data: sampleEvents, + nextCursor: "cursor123", + }); + + const { context } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 25, json: true, full: false, period: "7d" }, + "CLI-G5" + ); + + expect(advancePaginationStateSpy).toHaveBeenCalledWith( + "issue-events", + expect.any(String), + "next", + "cursor123" + ); + }); + + test("shows user email in human output", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ + data: [makeMockEvent({ user: { email: "alice@example.com" } })], + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 25, json: false, full: false, period: "7d" }, + "CLI-G5" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + // Email may wrap across lines in the table; check for the key parts + expect(output).toContain("alice@example"); + }); + + test("falls back to username when email is missing", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ + data: [makeMockEvent({ user: { username: "bob" } })], + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 25, json: false, full: false, period: "7d" }, + "CLI-G5" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("bob"); + }); + + test("shows previous page hint when hasPreviousPage is true", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + listIssueEventsSpy.mockResolvedValue({ + data: sampleEvents, + nextCursor: "next123", + }); + hasPreviousPageSpy.mockReturnValue(true); + + const { context, stdoutWrite } = createMockContext(); + const func = await eventsCommand.loader(); + await func.call( + context, + { limit: 25, json: false, full: false, period: "7d" }, + "CLI-G5" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Prev:"); + expect(output).toContain("-c prev"); + expect(output).toContain("Next:"); + expect(output).toContain("-c next"); + }); + + test("throws ContextError when org is undefined", async () => { + resolveIssueSpy.mockResolvedValue({ + org: undefined, + issue: makeMockIssue(), + }); + + const { context } = createMockContext(); + const func = await eventsCommand.loader(); + + await expect( + func.call( + context, + { limit: 25, json: false, full: false, period: "7d" }, + "123456789" + ) + ).rejects.toThrow("Organization"); + }); +}); From 5b944ad2de08e30646d16377ba952f3c2b7c1248 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 4 Apr 2026 09:34:19 +0000 Subject: [PATCH 2/5] chore: regenerate skill files and command docs --- docs/src/content/docs/commands/issue.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/src/content/docs/commands/issue.md b/docs/src/content/docs/commands/issue.md index 2d08599f3..ac0d06bdc 100644 --- a/docs/src/content/docs/commands/issue.md +++ b/docs/src/content/docs/commands/issue.md @@ -29,6 +29,27 @@ List issues in a project | `--compact` | Single-line rows for compact output (auto-detects if omitted) | | `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | +### `sentry issue events ` + +List events for a specific issue + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `` | Issue: @latest, @most_frequent, <org>/ID, <project>-suffix, ID, or suffix | + +**Options:** + +| Option | Description | +|--------|-------------| +| `-n, --limit ` | Number of events (1-1000) (default: "25") | +| `-q, --query ` | Search query (Sentry search syntax) | +| `--full` | Include full event body (stacktraces) | +| `-t, --period ` | Time period (e.g., "1h", "24h", "7d", "30d") (default: "7d") | +| `-f, --fresh` | Bypass cache, re-detect projects, and fetch fresh data | +| `-c, --cursor ` | Navigate pages: "next", "prev", "first" (or raw cursor string) | + ### `sentry issue explain ` Analyze an issue's root cause using Seer AI From e6d434054f9fd5b2b99b11f181bb40a00e1df454 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 4 Apr 2026 09:41:03 +0000 Subject: [PATCH 3/5] fix(completions): add issue events to ORG_PROJECT_COMMANDS set The drift detection test requires all commands with org-positional parameters to be registered in the completion set. --- src/lib/complete.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/complete.ts b/src/lib/complete.ts index 23fa69963..804081779 100644 --- a/src/lib/complete.ts +++ b/src/lib/complete.ts @@ -89,6 +89,7 @@ export function handleComplete(args: string[]): void { */ export const ORG_PROJECT_COMMANDS = new Set([ "issue list", + "issue events", "issue view", "issue explain", "issue plan", From 01ce094491ff8dccb8177dd16961dbfe9d84e580 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 4 Apr 2026 10:24:50 +0000 Subject: [PATCH 4/5] fix(api): discard nextCursor when trimming overshot events in listIssueEvents When the API returns more events than the requested limit, the function now returns without nextCursor to prevent cursor-position skips. This matches the pattern in listIssuesAllPages. --- src/lib/api/events.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts index 8b718ae9b..1bc964273 100644 --- a/src/lib/api/events.ts +++ b/src/lib/api/events.ts @@ -197,7 +197,7 @@ export async function listIssueEvents( const config = await getOrgSdkConfig(orgSlug); - let allEvents: IssueEvent[] = []; + const allEvents: IssueEvent[] = []; let currentCursor = cursor; let nextCursor: string | undefined; @@ -235,7 +235,7 @@ export async function listIssueEvents( // Trim to exact limit; discard nextCursor if we overshot to avoid // cursor-position skips (same pattern as listIssuesAllPages). if (allEvents.length > limit) { - allEvents = allEvents.slice(0, limit); + return { data: allEvents.slice(0, limit) }; } return { data: allEvents, nextCursor }; From 86a5aaf48b039c7b8b7b453565d451fcff4f53e7 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 4 Apr 2026 10:34:11 +0000 Subject: [PATCH 5/5] fix(api): preserve nextCursor when trimming events to limit The issue events API has no per-page limit parameter, so the server may return more events than requested. Preserve nextCursor so the command-level cursor stack can navigate to subsequent API pages. --- src/lib/api/events.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts index 1bc964273..f8172ec07 100644 --- a/src/lib/api/events.ts +++ b/src/lib/api/events.ts @@ -232,11 +232,12 @@ export async function listIssueEvents( currentCursor = nextCursor; } - // Trim to exact limit; discard nextCursor if we overshot to avoid - // cursor-position skips (same pattern as listIssuesAllPages). - if (allEvents.length > limit) { - return { data: allEvents.slice(0, limit) }; - } - - return { data: allEvents, nextCursor }; + // Trim to exact limit. Unlike listIssuesAllPages (which controls per_page), + // the issue events endpoint has no per-page parameter, so the API may return + // more items than requested. We preserve nextCursor so the command-level + // cursor stack can navigate to subsequent pages. + const trimmed = + allEvents.length > limit ? allEvents.slice(0, limit) : allEvents; + + return { data: trimmed, nextCursor }; }