From 828ec0269cddc00d6074d4c3ef1e43416d67fc43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 17 Jul 2026 14:02:05 +0200 Subject: [PATCH 01/10] feat(feedback): add list and view commands --- docs/src/content/docs/contributing.md | 1 + docs/src/fragments/commands/feedback.md | 56 +++ plugins/sentry-cli/skills/sentry-cli/SKILL.md | 9 + .../skills/sentry-cli/references/feedback.md | 133 +++++++ src/app.ts | 2 + src/commands/feedback/index.ts | 26 ++ src/commands/feedback/list.ts | 272 ++++++++++++++ src/commands/feedback/utils.ts | 68 ++++ src/commands/feedback/view.ts | 156 ++++++++ src/commands/issue/utils.ts | 60 +++- src/lib/api-client.ts | 8 + src/lib/api/events.ts | 27 ++ src/lib/api/feedback.ts | 103 ++++++ src/lib/api/issues.ts | 14 +- src/lib/complete.ts | 2 + src/lib/formatters/feedback.ts | 339 ++++++++++++++++++ src/lib/formatters/index.ts | 1 + src/types/feedback.ts | 143 ++++++++ src/types/index.ts | 12 + test/commands/feedback/list.test.ts | 316 ++++++++++++++++ test/commands/feedback/utils.test.ts | 90 +++++ test/commands/feedback/view.test.ts | 266 ++++++++++++++ test/commands/issue/utils.test.ts | 22 ++ test/e2e/feedback.test.ts | 204 +++++++++++ test/fixtures/feedback-event.json | 33 ++ test/fixtures/feedback.json | 48 +++ test/fixtures/feedbacks.json | 34 ++ test/lib/api-client.coverage.test.ts | 31 ++ test/lib/api/feedback.property.test.ts | 47 +++ test/lib/completions.property.test.ts | 1 + test/lib/formatters/feedback.test.ts | 146 ++++++++ test/mocks/routes.ts | 100 +++++- 32 files changed, 2743 insertions(+), 27 deletions(-) create mode 100644 docs/src/fragments/commands/feedback.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/feedback.md create mode 100644 src/commands/feedback/index.ts create mode 100644 src/commands/feedback/list.ts create mode 100644 src/commands/feedback/utils.ts create mode 100644 src/commands/feedback/view.ts create mode 100644 src/lib/api/feedback.ts create mode 100644 src/lib/formatters/feedback.ts create mode 100644 src/types/feedback.ts create mode 100644 test/commands/feedback/list.test.ts create mode 100644 test/commands/feedback/utils.test.ts create mode 100644 test/commands/feedback/view.test.ts create mode 100644 test/e2e/feedback.test.ts create mode 100644 test/fixtures/feedback-event.json create mode 100644 test/fixtures/feedback.json create mode 100644 test/fixtures/feedbacks.json create mode 100644 test/lib/api/feedback.property.test.ts create mode 100644 test/lib/formatters/feedback.test.ts diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index 313ce00799..445cf8ecb3 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -61,6 +61,7 @@ cli/ │ │ ├── dashboard/ # add, create, delete, edit, list, restore, revisions, view │ │ ├── debug-files/ # bundle-jvm, bundle-sources, check, find, print-sources, upload │ │ ├── event/ # list, send, view +│ │ ├── feedback/ # list, view │ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view │ │ ├── local/ # run, serve │ │ ├── log/ # list, view diff --git a/docs/src/fragments/commands/feedback.md b/docs/src/fragments/commands/feedback.md new file mode 100644 index 0000000000..919861fc37 --- /dev/null +++ b/docs/src/fragments/commands/feedback.md @@ -0,0 +1,56 @@ +## Examples + +### List User Feedback + +Modern User Feedback is stored as Feedback issues. The command always limits +issue searches to `issue.category:feedback`; it does not use the legacy User +Reports API. + +```bash +# Auto-detect the organization from the current project +sentry feedback list + +# List Feedback for one project +sentry feedback list my-org/frontend + +# List Feedback across every project in an organization +sentry feedback list my-org/ + +# Search for a project across accessible organizations +sentry feedback list frontend +``` + +The unresolved inbox from the last 14 days is shown by default. Select another +mailbox or expand the time range with flags: + +```bash +sentry feedback list my-org/frontend --status resolved +sentry feedback list my-org/frontend --status spam +sentry feedback list my-org/frontend --status all --period 90d +sentry feedback list my-org/frontend --query "message:*checkout*" +``` + +Use `--json` for the standard paginated envelope. Navigate pages in either +direction with `--cursor next` and `--cursor prev`. + +### View User Feedback + +```bash +# Short ID or numeric ID +sentry feedback view FRONTEND-2SDJ +sentry feedback view 5146636313 + +# Explicit organization +sentry feedback view my-org/FRONTEND-2SDJ + +# `view` is the default command; `show` is an alias +sentry feedback my-org/FRONTEND-2SDJ +sentry feedback show my-org/FRONTEND-2SDJ + +# Open the Feedback item in Sentry +sentry feedback view my-org/FRONTEND-2SDJ --web +``` + +The detail view includes the complete message and, when available, its latest +event, linked error, Session Replays, and attachment metadata. If the supplied +ID belongs to another issue category, use `sentry issue view` instead. diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 620acb853b..edb52f1bb1 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -512,6 +512,15 @@ Query aggregate event data (Explore) → Full flags and examples: `references/explore.md` +### Feedback + +Search and inspect User Feedback + +- `sentry feedback list ` — List and search User Feedback +- `sentry feedback view ` — View a User Feedback item + +→ Full flags and examples: `references/feedback.md` + ### Log View Sentry logs diff --git a/plugins/sentry-cli/skills/sentry-cli/references/feedback.md b/plugins/sentry-cli/skills/sentry-cli/references/feedback.md new file mode 100644 index 0000000000..e4b68bb95a --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/feedback.md @@ -0,0 +1,133 @@ +--- +name: sentry-cli-feedback +version: 0.39.0-dev.0 +description: Search and inspect User Feedback +requires: + bins: ["sentry"] + auth: true +--- + +# Feedback Commands + +Search and inspect User Feedback + +### `sentry feedback list ` + +List and search User Feedback + +**Flags:** +- `--status - Mailbox: unresolved, resolved, spam, or all - (default: "unresolved")` +- `-n, --limit - Number of feedback items (1-1000) - (default: "25")` +- `-q, --query - Search query (Sentry issue search syntax)` +- `-t, --period - Time range: "7d", "2026-06-01..2026-07-01", ">=2026-06-01" - (default: "14d")` +- `-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 | Numeric issue ID | +| `shortId` | string | Human-readable short ID (e.g. PROJ-ABC) | +| `title` | string | Issue title | +| `culprit` | string \| null | Culprit string | +| `count` | string | Total event count | +| `userCount` | number | Number of affected users | +| `firstSeen` | string \| null | First occurrence (ISO 8601) | +| `lastSeen` | string \| null | Most recent occurrence (ISO 8601) | +| `level` | string | Severity level | +| `status` | string | Issue status | +| `permalink` | string | URL to the issue in Sentry | +| `project` | object | Project info | +| `metadata` | object | Feedback metadata | +| `assignedTo` | object \| null | Assigned user or team | +| `priority` | string | Triage priority | +| `platform` | string | Platform | +| `substatus` | string \| null | Issue substatus | +| `isUnhandled` | boolean | Whether the issue is unhandled | +| `seerFixabilityScore` | number \| null | Seer AI fixability score (0-1) | +| `issueCategory` | string | Issue category discriminator | +| `issueType` | string | Issue type discriminator | +| `hasSeen` | boolean | Whether the feedback has been read | +| `latestEventHasAttachments` | boolean | Whether the latest event has attachments | + +**Examples:** + +```bash +# Auto-detect the organization from the current project +sentry feedback list + +# List Feedback for one project +sentry feedback list my-org/frontend + +# List Feedback across every project in an organization +sentry feedback list my-org/ + +# Search for a project across accessible organizations +sentry feedback list frontend + +sentry feedback list my-org/frontend --status resolved +sentry feedback list my-org/frontend --status spam +sentry feedback list my-org/frontend --status all --period 90d +sentry feedback list my-org/frontend --query "message:*checkout*" +``` + +### `sentry feedback view ` + +View a User Feedback item + +**Flags:** +- `-w, --web - Open in browser` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Numeric issue ID | +| `shortId` | string | Human-readable short ID (e.g. PROJ-ABC) | +| `title` | string | Issue title | +| `culprit` | string \| null | Culprit string | +| `count` | string | Total event count | +| `userCount` | number | Number of affected users | +| `firstSeen` | string \| null | First occurrence (ISO 8601) | +| `lastSeen` | string \| null | Most recent occurrence (ISO 8601) | +| `level` | string | Severity level | +| `status` | string | Issue status | +| `permalink` | string | URL to the issue in Sentry | +| `project` | object | Project info | +| `metadata` | object | Feedback metadata | +| `assignedTo` | object \| null | Assigned user or team | +| `priority` | string | Triage priority | +| `platform` | string | Platform | +| `substatus` | string \| null | Issue substatus | +| `isUnhandled` | boolean | Whether the issue is unhandled | +| `seerFixabilityScore` | number \| null | Seer AI fixability score (0-1) | +| `issueCategory` | string | Issue category discriminator | +| `issueType` | string | Issue type discriminator | +| `hasSeen` | boolean | Whether the feedback has been read | +| `latestEventHasAttachments` | boolean | Whether the latest event has attachments | +| `org` | string \| null | Organization slug | +| `event` | unknown \| null | Latest feedback event | +| `replayIds` | array | Related Session Replay IDs | +| `attachments` | array | Attachments on the latest feedback event | + +**Examples:** + +```bash +# Short ID or numeric ID +sentry feedback view FRONTEND-2SDJ +sentry feedback view 5146636313 + +# Explicit organization +sentry feedback view my-org/FRONTEND-2SDJ + +# `view` is the default command; `show` is an alias +sentry feedback my-org/FRONTEND-2SDJ +sentry feedback show my-org/FRONTEND-2SDJ + +# Open the Feedback item in Sentry +sentry feedback view my-org/FRONTEND-2SDJ --web +``` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/src/app.ts b/src/app.ts index e4098a923c..00e799e9be 100644 --- a/src/app.ts +++ b/src/app.ts @@ -20,6 +20,7 @@ import { debugFilesRoute } from "./commands/debug-files/index.js"; import { eventRoute } from "./commands/event/index.js"; import { listCommand as eventListCommand } from "./commands/event/list.js"; import { exploreCommand } from "./commands/explore.js"; +import { feedbackRoute } from "./commands/feedback/index.js"; import { helpCommand } from "./commands/help.js"; import { infoCommand } from "./commands/info.js"; import { initCommand } from "./commands/init.js"; @@ -119,6 +120,7 @@ export const routes = buildRouteMap({ event: eventRoute, events: eventListCommand, explore: exploreCommand, + feedback: feedbackRoute, log: logRoute, monitor: monitorRoute, snapshots: snapshotsRoute, diff --git a/src/commands/feedback/index.ts b/src/commands/feedback/index.ts new file mode 100644 index 0000000000..4a4484537b --- /dev/null +++ b/src/commands/feedback/index.ts @@ -0,0 +1,26 @@ +/** + * sentry feedback + * + * Search and inspect modern Sentry User Feedback. + */ + +import { buildRouteMap } from "../../lib/route-map.js"; +import { listCommand } from "./list.js"; +import { viewCommand } from "./view.js"; + +export const feedbackRoute = buildRouteMap({ + routes: { + list: listCommand, + view: viewCommand, + }, + defaultCommand: "view", + docs: { + brief: "Search and inspect User Feedback", + fullDescription: + "Search and inspect modern User Feedback from your Sentry organization.\n\n" + + "Commands:\n" + + " list List and search feedback\n" + + " view View feedback with its latest event context", + hideRoute: {}, + }, +}); diff --git a/src/commands/feedback/list.ts b/src/commands/feedback/list.ts new file mode 100644 index 0000000000..3dd537aaa9 --- /dev/null +++ b/src/commands/feedback/list.ts @@ -0,0 +1,272 @@ +/** + * sentry feedback list + * + * List modern User Feedback from the organization issue index. + */ + +import type { SentryContext } from "../../context.js"; +import { + type FeedbackStatus, + getProject, + listFeedback, +} from "../../lib/api-client.js"; +import { validateLimit } from "../../lib/arg-parsing.js"; +import { + advancePaginationState, + buildPaginationContextKey, + hasPreviousPage, + resolveCursor, +} from "../../lib/db/pagination.js"; +import { toSearchQueryError } from "../../lib/errors.js"; +import { formatFeedbackList } from "../../lib/formatters/feedback.js"; +import { filterFields } from "../../lib/formatters/json.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { + appendQueryHint, + buildListCommand, + LIST_DEFAULT_LIMIT, + LIST_MAX_LIMIT, + LIST_MIN_LIMIT, + PERIOD_ALIASES, + paginationHint, + targetPatternExplanation, +} from "../../lib/list-command.js"; +import { withProgress } from "../../lib/polling.js"; +import { + type ResolvedOrgOptionalProject, + resolveOrgOptionalProjectFromArg, + toNumericId, +} from "../../lib/resolve-target.js"; +import { sanitizeQuery } from "../../lib/search-query.js"; +import { + appendPeriodHint, + PERIOD_BRIEF, + parsePeriod, + serializeTimeRange, + type TimeRange, + timeRangeToApiParams, +} from "../../lib/time-range.js"; +import { + type FeedbackListResult, + SentryFeedbackSchema, +} from "../../types/index.js"; + +const DEFAULT_PERIOD = "14d"; +const DEFAULT_STATUS: FeedbackStatus = "unresolved"; +const PAGINATION_KEY = "feedback-list"; +const COMMAND_NAME = "feedback list"; + +type ListFlags = { + readonly status: FeedbackStatus; + readonly limit: number; + readonly query?: string; + readonly period: TimeRange; + readonly json: boolean; + readonly cursor?: string; + readonly fresh: boolean; + readonly fields?: string[]; +}; + +function parseLimit(value: string): number { + return validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT); +} + +function formatScope(org: string, project?: string): string { + return project ? `${org}/${project}` : `${org}/`; +} + +async function projectIdFor( + resolved: ResolvedOrgOptionalProject +): Promise { + if (!resolved.project) { + return; + } + const project = + resolved.projectData ?? (await getProject(resolved.org, resolved.project)); + return toNumericId(project.id); +} + +function appendFeedbackFlags( + base: string, + flags: Pick +): string { + const parts: string[] = []; + if (flags.status !== DEFAULT_STATUS) { + parts.push(`--status ${flags.status}`); + } + appendQueryHint(parts, flags.query); + appendPeriodHint(parts, flags.period, DEFAULT_PERIOD); + return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; +} + +function pageHint( + direction: "next" | "prev", + org: string, + project: string | undefined, + flags: Pick +): string { + return appendFeedbackFlags( + `sentry feedback list ${formatScope(org, project)} -c ${direction}`, + flags + ); +} + +function jsonTransformFeedbackList( + result: FeedbackListResult, + fields?: string[] +): unknown { + const items = fields?.length + ? result.feedback.map((item) => filterFields(item, fields)) + : result.feedback; + const envelope: Record = { + data: items, + hasMore: result.hasMore, + hasPrev: result.hasPrev, + }; + if (result.nextCursor) { + envelope.nextCursor = result.nextCursor; + } + return envelope; +} + +export const listCommand = buildListCommand("feedback", { + docs: { + brief: "List and search User Feedback", + fullDescription: + "List modern User Feedback captured by Sentry. Feedback is queried from the issue index with a mandatory category filter.\n\n" + + "Target patterns:\n" + + " sentry feedback list # auto-detect organization\n" + + " sentry feedback list / # all projects in an organization\n" + + " sentry feedback list / # one project\n" + + " sentry feedback list # find project across organizations\n\n" + + `${targetPatternExplanation()}\n\n` + + "Mailboxes:\n" + + " unresolved Inbox feedback (default)\n" + + " resolved Resolved feedback\n" + + " spam Feedback marked as spam\n" + + " all All feedback statuses", + }, + output: { + human: formatFeedbackList, + jsonTransform: jsonTransformFeedbackList, + schema: SentryFeedbackSchema, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: "/, /, or (search)", + parse: String, + optional: true, + }, + ], + }, + flags: { + status: { + kind: "enum", + values: ["unresolved", "resolved", "spam", "all"], + brief: "Mailbox: unresolved, resolved, spam, or all", + default: DEFAULT_STATUS, + }, + limit: { + kind: "parsed", + parse: parseLimit, + brief: `Number of feedback items (${LIST_MIN_LIMIT}-${LIST_MAX_LIMIT})`, + default: String(LIST_DEFAULT_LIMIT), + }, + query: { + kind: "parsed", + parse: sanitizeQuery, + brief: "Search query (Sentry issue search syntax)", + optional: true, + }, + period: { + kind: "parsed", + parse: parsePeriod, + brief: PERIOD_BRIEF, + default: DEFAULT_PERIOD, + }, + }, + aliases: { + ...PERIOD_ALIASES, + n: "limit", + q: "query", + }, + }, + async *func(this: SentryContext, flags: ListFlags, target?: string) { + const resolved = await resolveOrgOptionalProjectFromArg( + target, + this.cwd, + COMMAND_NAME + ); + const projectId = await projectIdFor(resolved); + const contextKey = buildPaginationContextKey( + "feedback", + formatScope(resolved.org, resolved.project), + { + status: flags.status, + q: flags.query, + period: serializeTimeRange(flags.period), + } + ); + const { cursor, direction } = resolveCursor( + flags.cursor, + PAGINATION_KEY, + contextKey + ); + + const { feedback, nextCursor } = await withProgress( + { + message: `Fetching feedback (up to ${flags.limit})...`, + json: flags.json, + }, + () => + listFeedback(resolved.org, resolved.project ?? "", { + limit: flags.limit, + status: flags.status, + query: flags.query, + cursor, + projectId, + ...timeRangeToApiParams(flags.period), + }).catch((error: unknown): never => { + throw toSearchQueryError(error, flags.query); + }) + ); + + advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); + const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); + const hasMore = Boolean(nextCursor); + const nav = paginationHint({ + hasPrev, + hasMore, + prevHint: pageHint("prev", resolved.org, resolved.project, flags), + nextHint: pageHint("next", resolved.org, resolved.project, flags), + }); + + let hint: string | undefined; + if (feedback.length === 0 && nav) { + hint = `No feedback on this page. ${nav}`; + } else if (feedback.length > 0) { + const count = `Showing ${feedback.length} feedback item${feedback.length === 1 ? "" : "s"}.`; + const first = feedback[0]; + const viewHint = first + ? `Use 'sentry feedback view ${resolved.org}/${first.shortId}' for details.` + : undefined; + hint = nav + ? `${count} ${nav}` + : [count, viewHint].filter(Boolean).join(" "); + } + + yield new CommandOutput({ + feedback, + hasMore, + hasPrev, + nextCursor, + org: resolved.org, + project: resolved.project, + }); + return { hint }; + }, +}); diff --git a/src/commands/feedback/utils.ts b/src/commands/feedback/utils.ts new file mode 100644 index 0000000000..4fa7d8f83d --- /dev/null +++ b/src/commands/feedback/utils.ts @@ -0,0 +1,68 @@ +/** + * Shared resolution utilities for modern User Feedback commands. + */ + +import { ApiError, ResolutionError } from "../../lib/errors.js"; +import type { SentryFeedback } from "../../types/index.js"; +import { buildCommandHint, resolveIssue } from "../issue/utils.js"; + +/** Positional parameter for a Feedback numeric ID, short ID, or scoped ID. */ +export const feedbackIdPositional = { + kind: "tuple", + parameters: [ + { + placeholder: "org/project/feedback-id", + brief: + "Feedback ID: numeric ID, short ID, /SHORT-ID, or //", + parse: String, + }, + ], +} as const; + +/** Result of resolving and category-checking a modern Feedback issue. */ +export type ResolvedFeedback = { + /** Resolved organization slug, when available. */ + org: string | undefined; + /** Category-constrained Feedback issue. */ + feedback: SentryFeedback; +}; + +/** + * Resolve an issue-style identifier and require the result to be User Feedback. + */ +export async function resolveFeedback( + feedbackArg: string, + cwd: string +): Promise { + let resolved: Awaited>; + try { + resolved = await resolveIssue({ + issueArg: feedbackArg, + cwd, + command: "view", + commandBase: "sentry feedback", + }); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + throw new ResolutionError( + `Feedback '${feedbackArg}'`, + "not found", + buildCommandHint("view", feedbackArg, "sentry feedback"), + ["List available Feedback: sentry feedback list"] + ); + } + throw error; + } + const { org, issue } = resolved; + + if (issue.issueCategory !== "feedback") { + throw new ResolutionError( + `Issue '${issue.shortId}'`, + "is not User Feedback", + `sentry issue view ${org ? `${org}/` : ""}${issue.shortId}`, + ["Use a Feedback ID from: sentry feedback list"] + ); + } + + return { org, feedback: issue as SentryFeedback }; +} diff --git a/src/commands/feedback/view.ts b/src/commands/feedback/view.ts new file mode 100644 index 0000000000..b769ff1dc7 --- /dev/null +++ b/src/commands/feedback/view.ts @@ -0,0 +1,156 @@ +/** + * sentry feedback view + * + * View a modern Feedback issue and its latest event context. + */ + +import type { EventAttachmentDetailsResponse } from "@sentry/api"; +import type { SentryContext } from "../../context.js"; +import { + getLatestEvent, + listEventAttachments, + listReplayIdsForIssue, +} from "../../lib/api-client.js"; +import { openInBrowser } from "../../lib/browser.js"; +import { buildCommand } from "../../lib/command.js"; +import { formatFeedbackView } from "../../lib/formatters/feedback.js"; +import { filterFields } from "../../lib/formatters/json.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; +import { logger } from "../../lib/logger.js"; +import { + collectReplayIds, + getReplayIdFromEvent, +} from "../../lib/replay-search.js"; +import type { FeedbackViewResult, SentryEvent } from "../../types/index.js"; +import { FeedbackViewOutputSchema } from "../../types/index.js"; +import { feedbackIdPositional, resolveFeedback } from "./utils.js"; + +const log = logger.withTag("feedback.view"); + +type ViewFlags = { + readonly json: boolean; + readonly web: boolean; + readonly fresh: boolean; + readonly fields?: string[]; +}; + +async function tryGetLatestEvent( + orgSlug: string, + feedbackId: string +): Promise { + try { + return await getLatestEvent(orgSlug, feedbackId); + } catch (error) { + log.debug("Failed to fetch latest event for feedback", error); + return; + } +} + +async function tryListReplayIds( + orgSlug: string, + feedbackId: string +): Promise { + try { + return await listReplayIdsForIssue(orgSlug, feedbackId); + } catch (error) { + log.debug("Failed to fetch replay IDs for feedback", error); + return []; + } +} + +async function tryListAttachments( + orgSlug: string, + projectSlug: string | undefined, + event: SentryEvent | undefined +): Promise { + if (!(projectSlug && event)) { + return []; + } + try { + return await listEventAttachments(orgSlug, projectSlug, event.eventID); + } catch (error) { + log.debug("Failed to fetch attachments for feedback", error); + return []; + } +} + +function jsonTransformFeedbackView( + data: FeedbackViewResult, + fields?: string[] +): unknown { + const result: Record = { + ...data.feedback, + org: data.org, + event: data.event, + replayIds: data.replayIds, + attachments: data.attachments, + }; + return fields?.length ? filterFields(result, fields) : result; +} + +export const viewCommand = buildCommand({ + docs: { + brief: "View a User Feedback item", + fullDescription: + "View modern User Feedback by numeric ID or short ID. The latest event, linked error, Session Replays, and attachment metadata are included when available.\n\n" + + "Feedback formats:\n" + + " Search accessible organizations\n" + + " Resolve by numeric issue ID\n" + + " / Explicit organization\n" + + " // Explicit organization and project\n\n" + + "The resolved issue must have issue.category:feedback. Use 'sentry issue view' for other issue categories.", + }, + output: { + human: formatFeedbackView, + jsonTransform: jsonTransformFeedbackView, + schema: FeedbackViewOutputSchema, + }, + parameters: { + positional: feedbackIdPositional, + flags: { + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + fresh: FRESH_FLAG, + }, + aliases: { ...FRESH_ALIASES, w: "web" }, + }, + async *func(this: SentryContext, flags: ViewFlags, feedbackArg: string) { + applyFreshFlag(flags); + const { org, feedback } = await resolveFeedback(feedbackArg, this.cwd); + + if (flags.web) { + await openInBrowser(feedback.permalink, "feedback"); + return; + } + + const [event, relatedReplayIds] = org + ? await Promise.all([ + tryGetLatestEvent(org, feedback.id), + tryListReplayIds(org, feedback.id), + ]) + : [undefined, []]; + const replayIds = collectReplayIds([ + event ? getReplayIdFromEvent(event) : undefined, + ...relatedReplayIds, + ]); + const attachments = org + ? await tryListAttachments(org, feedback.project?.slug, event) + : []; + + yield new CommandOutput({ + org: org ?? null, + feedback, + event: event ?? null, + replayIds, + attachments, + }); + }, +}); diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index 18d944f80b..c2bc1d0d8e 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -131,6 +131,16 @@ type StrictResolvedIssue = { issue: SentryIssue; }; +/** Command-specific recovery text shared by issue-style resolvers. */ +type IssueCommandContext = { + /** Primary recovery command for the original input. */ + commandHint: string; + /** Active issue-style subcommand. */ + command: string; + /** Command domain used for recovery suggestions. */ + commandBase: string; +}; + /** * Try to resolve via alias cache. * Returns null if the alias is not found in cache or fingerprint doesn't match. @@ -163,12 +173,17 @@ async function tryResolveFromAlias( * Fallback for when the fast shortid fan-out found no matches. * Uses findProjectsBySlug to retry on transient failures; when no project * slug matches, reports the issue short ID as not found (not the project). + * + * @param projectSlug - Project slug parsed from the short ID + * @param suffix - Issue short-ID suffix + * @param commandContext - Command-specific recovery text */ async function resolveProjectSearchFallback( projectSlug: string, suffix: string, - commandHint: string + commandContext: IssueCommandContext ): Promise { + const { command, commandBase, commandHint } = commandContext; const { projects } = await findProjectsBySlug(projectSlug.toLowerCase()); if (projects.length === 0) { @@ -180,7 +195,7 @@ async function resolveProjectSearchFallback( [ "No issue with this short ID found in any accessible organization", "Check the project prefix and suffix, or use a numeric issue ID", - `Specify the org: sentry issue ... /${fullShortId}`, + `Specify the org: ${commandBase} ${command} /${fullShortId}`, ] ); } @@ -193,7 +208,7 @@ async function resolveProjectSearchFallback( commandHint, [ `Found in: ${orgList}`, - `Specify the org: sentry issue ... /${projectSlug}-${suffix}`, + `Specify the org: ${commandBase} ${command} /${projectSlug}-${suffix}`, ] ); } @@ -230,14 +245,15 @@ async function resolveProjectSearchFallback( * @param projectSlug - Project slug to search for * @param suffix - Issue suffix (uppercase) * @param cwd - Current working directory - * @param commandHint - Hint for error messages + * @param commandContext - Command-specific recovery text */ async function resolveProjectSearch( projectSlug: string, suffix: string, cwd: string, - commandHint: string + commandContext: IssueCommandContext ): Promise { + const { command, commandBase, commandHint } = commandContext; // 1. Try alias cache first (fast, local lookup) const aliasResult = await tryResolveFromAlias( projectSlug.toLowerCase(), @@ -312,7 +328,7 @@ async function resolveProjectSearch( commandHint, [ `Found in: ${orgList}`, - `Specify the org: sentry issue ... /${projectSlug}-${suffix}`, + `Specify the org: ${commandBase} ${command} /${projectSlug}-${suffix}`, ] ); } @@ -334,7 +350,7 @@ async function resolveProjectSearch( // 4. Fall back to findProjectsBySlug for precise error messages // and retry the issue lookup (handles transient failures). - return resolveProjectSearchFallback(projectSlug, suffix, commandHint); + return resolveProjectSearchFallback(projectSlug, suffix, commandContext); } /** @@ -374,20 +390,21 @@ async function resolveSuffixOnly( * * @param org - Explicit organization slug * @param suffix - Issue suffix (uppercase) - * @param commandHint - Hint for error messages + * @param commandContext - Command-specific recovery text */ function resolveExplicitOrgSuffix( org: string, suffix: string, - commandHint: string + commandContext: IssueCommandContext ): never { + const { command, commandBase, commandHint } = commandContext; throw new ResolutionError( `Issue suffix '${suffix}'`, "could not be resolved without project context", commandHint, [ `The format '${org}/${suffix}' requires a project to build the full issue ID.`, - `Use: sentry issue ... ${org}/-${suffix}`, + `Use: ${commandBase} ${command} ${org}/-${suffix}`, ] ); } @@ -420,7 +437,7 @@ const SELECTOR_LABELS: Record = { * @param selector - The magic selector (e.g., `@latest`, `@most_frequent`) * @param explicitOrg - Optional explicit org slug from `org/@selector` format * @param cwd - Current working directory for context resolution - * @param commandHint - Hint for error messages + * @param commandContext - Command-specific recovery text * @returns The resolved issue with org context * @throws {ContextError} When organization cannot be resolved * @throws {ResolutionError} When no issues match the selector @@ -429,8 +446,9 @@ async function resolveSelector( selector: IssueSelector, explicitOrg: string | undefined, cwd: string, - commandHint: string + commandContext: IssueCommandContext ): Promise { + const { commandBase, commandHint } = commandContext; // Resolve org: explicit from `org/@latest` or auto-detected from DSN/defaults let orgSlug: string; if (explicitOrg) { @@ -460,7 +478,7 @@ async function resolveSelector( throw new ResolutionError( `Selector '${selector}'`, "no unresolved issues found", - `sentry issue list ${orgSlug}/ -q "is:resolved"`, + `${commandBase} list ${orgSlug}/ -q "is:resolved"`, [`The ${label} issue selector only matches unresolved issues.`] ); } @@ -718,8 +736,14 @@ export async function resolveIssue( options: ResolveIssueOptions ): Promise { const { issueArg, cwd, command, commandBase } = options; + const effectiveCommandBase = commandBase ?? "sentry issue"; const parsed = parseIssueArg(issueArg); - const commandHint = buildCommandHint(command, issueArg, commandBase); + const commandHint = buildCommandHint(command, issueArg, effectiveCommandBase); + const commandContext: IssueCommandContext = { + command, + commandBase: effectiveCommandBase, + commandHint, + }; let result: ResolvedIssueResult; @@ -755,7 +779,7 @@ export async function resolveIssue( commandHint, [ `No issue with numeric ID ${parsed.numericId} found in org '${org}' — you may not have access, or it may have been deleted.`, - `If this is a short ID suffix, try: sentry issue ${command} -${parsed.numericId}`, + `If this is a short ID suffix, try: ${effectiveCommandBase} ${command} -${parsed.numericId}`, ] ); } @@ -767,7 +791,7 @@ export async function resolveIssue( case "explicit-org-suffix": { // Org + suffix only - ambiguous without project, always errors const org = await resolveEffectiveOrg(parsed.org); - result = await resolveExplicitOrgSuffix(org, parsed.suffix, commandHint); + result = resolveExplicitOrgSuffix(org, parsed.suffix, commandContext); break; } @@ -777,7 +801,7 @@ export async function resolveIssue( parsed.projectSlug, parsed.suffix, cwd, - commandHint + commandContext ); break; @@ -792,7 +816,7 @@ export async function resolveIssue( parsed.selector, parsed.org, cwd, - commandHint + commandContext ); break; diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 6cf3504ef2..26e16fef58 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -52,10 +52,18 @@ export { findEventAcrossOrgs, getEvent, getLatestEvent, + listEventAttachments, listIssueEvents, type ResolvedEvent, resolveEventInOrg, } from "./api/events.js"; +export { + buildFeedbackQuery, + type FeedbackPage, + type FeedbackStatus, + type ListFeedbackOptions, + listFeedback, +} from "./api/feedback.js"; export { API_MAX_PER_PAGE, type ApiRequestOptions, diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts index 1026e81f8c..cab6b56b5e 100644 --- a/src/lib/api/events.ts +++ b/src/lib/api/events.ts @@ -5,9 +5,11 @@ */ import { + type EventAttachmentDetailsResponse, getOrganizationIssueEvent, getProjectEvent, listOrganizationIssueEvents, + listProjectEventAttachments, resolveOrganizationEventId as sdkResolveAnEventId, } from "@sentry/api"; import pLimit from "p-limit"; @@ -75,6 +77,31 @@ export async function getEvent( return unwrapResult(result, "Failed to get event"); } +/** + * List metadata for attachments on a project event. + * Uses the generated Sentry API client and region-aware organization routing. + */ +export async function listEventAttachments( + orgSlug: string, + projectSlug: string, + eventId: string +): Promise { + const config = await getOrgSdkConfig(orgSlug); + const result = await listProjectEventAttachments({ + ...config, + path: { + organization_id_or_slug: orgSlug, + project_id_or_slug: projectSlug, + event_id: eventId, + }, + }); + + return unwrapResult( + result, + "Failed to list event attachments" + ); +} + /** * Result of resolving an event ID to an org and project. * Includes the full event so the caller can avoid a second API call. diff --git a/src/lib/api/feedback.ts b/src/lib/api/feedback.ts new file mode 100644 index 0000000000..798acbf4cc --- /dev/null +++ b/src/lib/api/feedback.ts @@ -0,0 +1,103 @@ +/** + * User Feedback API functions. + * + * Modern Sentry User Feedback is represented by issue groups. This module + * owns the mandatory category filter so callers cannot accidentally query + * ordinary issues or the legacy User Reports endpoints. + */ + +import type { SentryFeedback } from "../../types/index.js"; +import { buildIssueListCollapse, listIssuesAllPages } from "./issues.js"; + +/** Status filters exposed by `sentry feedback list`. */ +export type FeedbackStatus = "unresolved" | "resolved" | "spam" | "all"; + +const FEEDBACK_CATEGORY_FILTER = "issue.category:feedback"; + +const STATUS_FILTERS: Record, string> = { + unresolved: "status:unresolved", + resolved: "status:resolved", + spam: "status:ignored", +}; + +/** Options for listing modern User Feedback. */ +export type ListFeedbackOptions = { + /** Maximum feedback items to return across auto-paginated API pages. */ + limit: number; + /** Feedback mailbox to query. */ + status: FeedbackStatus; + /** Optional user-provided Sentry issue-search expression. */ + query?: string; + /** Cursor from a previous page. */ + cursor?: string; + /** Relative time period such as `14d`. */ + statsPeriod?: string; + /** Inclusive absolute start time. */ + start?: string; + /** Inclusive absolute end time. */ + end?: string; + /** Numeric project ID used for direct project selection. */ + projectId?: number; +}; + +/** A page of feedback items plus the server cursor for the following page. */ +export type FeedbackPage = { + /** Feedback items returned by the issue index. */ + feedback: SentryFeedback[]; + /** Cursor for the next page, when more results are available. */ + nextCursor?: string; +}; + +/** + * Build the issue-index query for modern User Feedback. + * + * The category clause is always the first term. `spam` maps to Sentry's + * underlying `ignored` issue status, while `all` omits a generated status + * clause and keeps only the category plus any user query. + */ +export function buildFeedbackQuery( + status: FeedbackStatus, + query?: string +): string { + const statusFilter = status === "all" ? undefined : STATUS_FILTERS[status]; + const userQuery = query?.trim() || undefined; + return [FEEDBACK_CATEGORY_FILTER, statusFilter, userQuery] + .filter(Boolean) + .join(" "); +} + +/** + * List modern User Feedback from the organization issue index. + * + * Reuses the issue API's bounded auto-pagination and fixes sorting to newest + * activity first. The mandatory category filter is applied here rather than + * in the command so every caller stays inside the Feedback domain boundary. + */ +export async function listFeedback( + orgSlug: string, + projectSlug: string, + options: ListFeedbackOptions +): Promise { + const { issues, nextCursor } = await listIssuesAllPages( + orgSlug, + projectSlug, + { + query: buildFeedbackQuery(options.status, options.query), + limit: options.limit, + sort: "date", + statsPeriod: options.statsPeriod, + start: options.start, + end: options.end, + startCursor: options.cursor, + projectId: options.projectId, + collapse: buildIssueListCollapse({ shouldCollapseStats: false }), + } + ); + + return { + // The server query is category-constrained; specialize the SDK's generic + // issue response only after that boundary has been applied. + feedback: issues as SentryFeedback[], + nextCursor, + }; +} diff --git a/src/lib/api/issues.ts b/src/lib/api/issues.ts index ee5a87e0fb..9057444d11 100644 --- a/src/lib/api/issues.ts +++ b/src/lib/api/issues.ts @@ -190,8 +190,7 @@ export type IssuesPage = { issues: SentryIssue[]; /** * Cursor for the next page of results, if more exist beyond the returned - * issues. `undefined` when all matching issues have been returned OR when - * the last page was trimmed to fit `limit` (cursor would skip items). + * issues. `undefined` when exhausted or a safe page boundary is unavailable. */ nextCursor?: string; }; @@ -244,10 +243,9 @@ export async function listIssuesAllPages( const allResults: SentryIssue[] = []; let cursor: string | undefined = options.startCursor; - // Use the smaller of the requested limit and the API max as page size - const perPage = Math.min(options.limit, API_MAX_PER_PAGE); - for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const remaining = options.limit - allResults.length; + const perPage = Math.min(remaining, API_MAX_PER_PAGE); const response = await listIssuesPaginated(orgSlug, projectSlug, { query: options.query, cursor, @@ -264,10 +262,10 @@ export async function listIssuesAllPages( allResults.push(...response.data); options.onPage?.(Math.min(allResults.length, options.limit), options.limit); - // Stop if we've reached the requested limit or there are no more pages + // Request only the remaining number of items on each page. A conforming + // API therefore reaches the limit exactly and its cursor remains usable. + // Keep the defensive overshoot branch for servers that ignore `limit`. if (allResults.length >= options.limit || !response.nextCursor) { - // If we overshot the limit, trim and don't return a nextCursor — - // the cursor would point past the trimmed items, causing skips. if (allResults.length > options.limit) { return { issues: allResults.slice(0, options.limit) }; } diff --git a/src/lib/complete.ts b/src/lib/complete.ts index 7da59f0061..f334b30fed 100644 --- a/src/lib/complete.ts +++ b/src/lib/complete.ts @@ -97,6 +97,8 @@ export const ORG_PROJECT_COMMANDS = new Set([ "issue unresolve", "issue archive", "issue merge", + "feedback list", + "feedback view", "project list", "project view", "project delete", diff --git a/src/lib/formatters/feedback.ts b/src/lib/formatters/feedback.ts new file mode 100644 index 0000000000..fd9b5204d2 --- /dev/null +++ b/src/lib/formatters/feedback.ts @@ -0,0 +1,339 @@ +/** + * Human-readable formatting for modern Sentry User Feedback. + */ + +import type { EventAttachmentDetailsResponse } from "@sentry/api"; +import wrapAnsi from "wrap-ansi"; +import type { + FeedbackListResult, + FeedbackViewResult, + SentryEvent, + SentryFeedback, +} from "../../types/index.js"; +import { getReplayIdFromEvent } from "../replay-search.js"; +import { formatEventDetails } from "./human.js"; +import { + colorTag, + escapeMarkdownCell, + escapeMarkdownInline, + mdKvTable, + renderMarkdown, + safeCodeSpan, +} from "./markdown.js"; +import { formatBytes } from "./numbers.js"; +import { type Column, formatTable } from "./table.js"; +import { formatRelativeTime } from "./time-utils.js"; + +const MAX_REPLAY_IDS_SHOWN = 3; +const MAX_LIST_MESSAGE_LENGTH = 160; +const COMPACT_LIST_BREAKPOINT = 100; +const LINE_BREAK_RE = /\r?\n/; + +function feedbackSender(feedback: SentryFeedback): string { + const name = feedback.metadata.name?.trim(); + const email = feedback.metadata.contact_email?.trim(); + if (name && email && name !== email) { + return `${name} <${email}>`; + } + return name || email || "Anonymous User"; +} + +function feedbackMessage(feedback: SentryFeedback): string { + return ( + feedback.metadata.message || + feedback.metadata.value || + feedback.metadata.title || + feedback.title + ); +} + +function feedbackMessagePreview(feedback: SentryFeedback): string { + const message = feedbackMessage(feedback).replace(LINE_BREAK_RE, " "); + return message.length > MAX_LIST_MESSAGE_LENGTH + ? `${message.slice(0, MAX_LIST_MESSAGE_LENGTH - 1)}…` + : message; +} + +function feedbackStatus(feedback: SentryFeedback): string { + switch (feedback.status) { + case "ignored": + return colorTag("muted", "Spam"); + case "resolved": + return colorTag("green", "Resolved"); + case "unresolved": + return colorTag("yellow", "Unresolved"); + default: + return escapeMarkdownInline(feedback.status ?? "Unknown"); + } +} + +function feedbackReadState(feedback: SentryFeedback): string { + if (feedback.hasSeen === undefined) { + return colorTag("muted", "Unknown"); + } + return feedback.hasSeen + ? colorTag("muted", "Read") + : colorTag("blue", "Unread"); +} + +const FEEDBACK_COLUMNS: Column[] = [ + { + header: "ID", + value: (feedback) => + feedback.permalink + ? `[${feedback.shortId}](${feedback.permalink})` + : safeCodeSpan(feedback.shortId), + minWidth: 8, + shrinkable: false, + }, + { + header: "RECEIVED", + value: (feedback) => formatRelativeTime(feedback.firstSeen ?? undefined), + minWidth: 10, + }, + { + header: "FROM", + value: (feedback) => escapeMarkdownCell(feedbackSender(feedback)), + minWidth: 12, + truncate: true, + }, + { + header: "MESSAGE", + value: (feedback) => escapeMarkdownCell(feedbackMessage(feedback)), + minWidth: 16, + truncate: true, + }, + { + header: "STATUS", + value: feedbackStatus, + minWidth: 8, + shrinkable: false, + }, + { + header: "READ", + value: feedbackReadState, + minWidth: 6, + shrinkable: false, + }, + { + header: "PROJECT", + value: (feedback) => + escapeMarkdownCell(feedback.project?.slug ?? "unknown"), + minWidth: 7, + }, +]; + +function formatCompactFeedbackList( + result: FeedbackListResult, + scope: string, + width: number +): string { + const sections = result.feedback.map((feedback) => { + const id = feedback.permalink + ? `[${escapeMarkdownInline(feedback.shortId)}](${feedback.permalink})` + : safeCodeSpan(feedback.shortId); + return [ + `### ${id}`, + "", + `- **Received:** ${formatRelativeTime(feedback.firstSeen ?? undefined)}`, + `- **From:** ${escapeMarkdownInline(feedbackSender(feedback))}`, + `- **Message:** ${escapeMarkdownInline(feedbackMessagePreview(feedback))}`, + `- **Status:** ${feedbackStatus(feedback)}`, + `- **Read:** ${feedbackReadState(feedback)}`, + `- **Project:** ${escapeMarkdownInline(feedback.project?.slug ?? "unknown")}`, + ].join("\n"); + }); + const markdown = [ + `## Feedback in ${escapeMarkdownInline(scope)}`, + "", + ...sections, + ].join("\n\n"); + return wrapAnsi(renderMarkdown(markdown), width, { + hard: true, + trim: false, + wordWrap: true, + }); +} + +/** Format a page of Feedback as a terminal-width-aware table. */ +export function formatFeedbackList(result: FeedbackListResult): string { + if (result.feedback.length === 0) { + return result.hasMore ? "No feedback on this page." : "No feedback found."; + } + + const scope = result.project + ? `${result.org}/${result.project}` + : `${result.org} (all projects)`; + const terminalWidth = process.stdout.columns || 80; + if (terminalWidth < COMPACT_LIST_BREAKPOINT) { + return formatCompactFeedbackList(result, scope, terminalWidth); + } + return `Feedback in ${scope}:\n\n${formatTable(result.feedback, FEEDBACK_COLUMNS, { truncate: true })}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function feedbackContext( + event: SentryEvent | null +): Record | undefined { + const context = event?.contexts?.feedback; + return isRecord(context) ? context : undefined; +} + +function feedbackUrl(event: SentryEvent | null): string | undefined { + const contextUrl = feedbackContext(event)?.url; + if (typeof contextUrl === "string" && contextUrl) { + return contextUrl; + } + return event?.tags?.find((tag) => tag.key === "url")?.value; +} + +function linkedEventId(data: FeedbackViewResult): string | undefined { + const contextId = feedbackContext(data.event)?.associated_event_id; + if (typeof contextId === "string" && contextId) { + return contextId; + } + const metadataId = data.feedback.metadata.associated_event_id; + return typeof metadataId === "string" && metadataId ? metadataId : undefined; +} + +function formatFeedbackOverview(data: FeedbackViewResult): string { + const { feedback } = data; + const rows: [string, string][] = [ + ["Status", feedbackStatus(feedback)], + ["Read", feedbackReadState(feedback)], + ["From", escapeMarkdownInline(feedbackSender(feedback))], + ["Received", formatRelativeTime(feedback.firstSeen ?? undefined)], + ]; + + if (feedback.project) { + rows.push([ + "Project", + `${escapeMarkdownInline(feedback.project.name ?? feedback.project.slug)} (${safeCodeSpan(feedback.project.slug)})`, + ]); + } + if (feedback.assignedTo?.name) { + rows.push(["Assignee", escapeMarkdownInline(feedback.assignedTo.name)]); + } + if (feedback.metadata.source) { + rows.push(["Source", safeCodeSpan(feedback.metadata.source)]); + } + if (feedback.metadata.sdk?.name) { + rows.push([ + "SDK", + safeCodeSpan( + feedback.metadata.sdk.name_normalized ?? feedback.metadata.sdk.name + ), + ]); + } + + const url = feedbackUrl(data.event); + if (url) { + rows.push(["URL", safeCodeSpan(url)]); + } + + const associatedEventId = linkedEventId(data); + if (associatedEventId) { + const project = feedback.project?.slug; + const command = + data.org && project + ? `sentry event view ${data.org}/${project}/${associatedEventId}` + : `sentry event view ${associatedEventId}`; + rows.push(["Linked error", safeCodeSpan(command)]); + } + + if (feedback.permalink) { + rows.push(["Sentry", `[Open feedback](${feedback.permalink})`]); + } + + const lines = [ + `## ${escapeMarkdownInline(feedback.shortId)}: User Feedback`, + "", + mdKvTable(rows), + "", + "### Message", + "", + ...feedbackMessage(feedback) + .split(LINE_BREAK_RE) + .map((line) => `> ${escapeMarkdownInline(line)}`), + ]; + + if (feedback.metadata.summary) { + lines.push( + "", + "### Summary", + "", + escapeMarkdownInline(feedback.metadata.summary) + ); + } + + return renderMarkdown(lines.join("\n")); +} + +function formatRelatedReplays(data: FeedbackViewResult): string { + const eventReplayId = data.event + ? getReplayIdFromEvent(data.event) + : undefined; + const additionalIds = eventReplayId + ? data.replayIds.filter((replayId) => replayId !== eventReplayId) + : data.replayIds; + if (additionalIds.length === 0) { + return ""; + } + + const lines = ["### Additional Replays", ""]; + for (const replayId of additionalIds.slice(0, MAX_REPLAY_IDS_SHOWN)) { + const command = data.org + ? `sentry replay view ${data.org}/${replayId}` + : `sentry replay view ${replayId}`; + lines.push(`- ${safeCodeSpan(replayId)} (${safeCodeSpan(command)})`); + } + const remaining = additionalIds.length - MAX_REPLAY_IDS_SHOWN; + if (remaining > 0) { + lines.push(`- ${remaining} more`); + } + return renderMarkdown(lines.join("\n")); +} + +function formatAttachment(attachment: EventAttachmentDetailsResponse): string { + const details = [attachment.mimetype, formatBytes(attachment.size)] + .filter(Boolean) + .join(", "); + return `- ${escapeMarkdownInline(attachment.name)}${details ? ` (${escapeMarkdownInline(details)})` : ""} — ${safeCodeSpan(attachment.id)}`; +} + +function formatAttachments( + attachments: EventAttachmentDetailsResponse[] +): string { + if (attachments.length === 0) { + return ""; + } + return renderMarkdown( + ["### Attachments", "", ...attachments.map(formatAttachment)].join("\n") + ); +} + +/** Format a Feedback item plus its best-effort event enrichments. */ +export function formatFeedbackView(data: FeedbackViewResult): string { + const sections = [formatFeedbackOverview(data)]; + if (data.event) { + sections.push( + formatEventDetails( + data.event, + "Latest Feedback Event", + data.feedback.permalink + ) + ); + } + const replays = formatRelatedReplays(data); + if (replays) { + sections.push(replays); + } + const attachments = formatAttachments(data.attachments); + if (attachments) { + sections.push(attachments); + } + return sections.join("\n\n"); +} diff --git a/src/lib/formatters/index.ts b/src/lib/formatters/index.ts index 5eb67a20ae..51c2381bcf 100644 --- a/src/lib/formatters/index.ts +++ b/src/lib/formatters/index.ts @@ -6,6 +6,7 @@ */ export * from "./colors.js"; +export * from "./feedback.js"; export * from "./human.js"; export * from "./json.js"; export * from "./log.js"; diff --git a/src/types/feedback.ts b/src/types/feedback.ts new file mode 100644 index 0000000000..4dfcf7e499 --- /dev/null +++ b/src/types/feedback.ts @@ -0,0 +1,143 @@ +/** + * Modern Sentry User Feedback types and output schemas. + */ + +import type { EventAttachmentDetailsResponse } from "@sentry/api"; +import { zEventAttachmentDetailsResponse } from "@sentry/api/zod"; +import { z } from "zod"; +import type { SentryEvent, SentryIssue } from "./sentry.js"; +import { SentryIssueSchema } from "./sentry.js"; + +/** Metadata stored on a modern Feedback issue group. */ +export type FeedbackMetadata = { + /** Feedback message submitted by the end user. */ + message: string; + /** End-user email address, or null when omitted. */ + contact_email?: string | null; + /** End-user display name, or null when omitted. */ + name?: string | null; + /** Feedback title generated by Sentry. */ + title?: string; + /** Search/display value generated by Sentry. */ + value?: string; + /** Capture source such as `new_feedback_envelope`. */ + source?: string | null; + /** AI-generated feedback summary, when available. */ + summary?: string | null; + /** SDK that captured the feedback. */ + sdk?: { + /** Raw SDK name. */ + name: string; + /** Normalized SDK name used by Sentry. */ + name_normalized?: string; + }; + /** Associated error event ID included by list responses. */ + associated_event_id?: string; + /** Additional fields returned by newer Sentry versions. */ + [key: string]: unknown; +}; + +/** Modern User Feedback represented as a category-constrained Sentry issue. */ +export type SentryFeedback = Omit< + SentryIssue, + "issueCategory" | "issueType" | "metadata" +> & { + /** Discriminator for the issue category. */ + issueCategory: "feedback"; + /** Discriminator for the issue type. */ + issueType: "feedback"; + /** Feedback-specific issue metadata. */ + metadata: FeedbackMetadata; + /** Whether the current user has read the feedback. */ + hasSeen?: boolean; + /** Whether the latest event contains attachments. */ + latestEventHasAttachments?: boolean; +}; + +/** Data yielded by `feedback list` for human and JSON rendering. */ +export type FeedbackListResult = { + /** Feedback items on the current page. */ + feedback: SentryFeedback[]; + /** Whether a following page is available. */ + hasMore: boolean; + /** Whether a previous page is available. */ + hasPrev: boolean; + /** Server cursor for the following page. */ + nextCursor?: string | null; + /** Resolved organization slug. */ + org: string; + /** Resolved project slug, absent for organization-wide queries. */ + project?: string; +}; + +/** Data yielded by `feedback view` before JSON flattening. */ +export type FeedbackViewResult = { + /** Resolved organization slug, or null when unavailable. */ + org: string | null; + /** The modern Feedback issue. */ + feedback: SentryFeedback; + /** Latest event for the Feedback issue. */ + event: SentryEvent | null; + /** Related Session Replay IDs. */ + replayIds: string[]; + /** Attachments on the latest Feedback event. */ + attachments: EventAttachmentDetailsResponse[]; +}; + +/** Documentation schema for Feedback metadata. */ +export const FeedbackMetadataSchema = z + .object({ + message: z.string().describe("Feedback message"), + contact_email: z + .string() + .nullable() + .optional() + .describe("End-user email address"), + name: z.string().nullable().optional().describe("End-user display name"), + title: z.string().optional().describe("Feedback title"), + value: z.string().optional().describe("Feedback display value"), + source: z + .string() + .nullable() + .optional() + .describe("Feedback capture source"), + summary: z.string().nullable().optional().describe("AI-generated summary"), + sdk: z + .object({ + name: z.string().describe("SDK name"), + name_normalized: z.string().optional().describe("Normalized SDK name"), + }) + .optional() + .describe("SDK that captured the feedback"), + associated_event_id: z + .string() + .optional() + .describe("Associated error event ID"), + }) + .passthrough() + .describe("Feedback metadata"); + +/** Documentation schema for a modern Feedback issue. */ +export const SentryFeedbackSchema = SentryIssueSchema.extend({ + issueCategory: z.literal("feedback").describe("Issue category discriminator"), + issueType: z.literal("feedback").describe("Issue type discriminator"), + metadata: FeedbackMetadataSchema, + hasSeen: z + .boolean() + .optional() + .describe("Whether the feedback has been read"), + latestEventHasAttachments: z + .boolean() + .optional() + .describe("Whether the latest event has attachments"), +}).describe("Sentry User Feedback"); + +/** Documentation schema for flattened `feedback view` JSON output. */ +export const FeedbackViewOutputSchema = SentryFeedbackSchema.extend({ + org: z.string().nullable().describe("Organization slug"), + event: z.unknown().nullable().describe("Latest feedback event"), + replayIds: z.array(z.string()).describe("Related Session Replay IDs"), + attachments: z + .array(zEventAttachmentDetailsResponse) + .describe("Attachments on the latest feedback event"), +}).describe("Feedback view output"); diff --git a/src/types/index.ts b/src/types/index.ts index c3defe907a..ede3c2dd77 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -34,6 +34,18 @@ export { DashboardWidgetQuerySchema, DashboardWidgetSchema, } from "./dashboard.js"; +// Feedback types and schemas +export type { + FeedbackListResult, + FeedbackMetadata, + FeedbackViewResult, + SentryFeedback, +} from "./feedback.js"; +export { + FeedbackMetadataSchema, + FeedbackViewOutputSchema, + SentryFeedbackSchema, +} from "./feedback.js"; // OAuth types and schemas export type { DeviceCodeResponse, diff --git a/test/commands/feedback/list.test.ts b/test/commands/feedback/list.test.ts new file mode 100644 index 0000000000..82d760ad5a --- /dev/null +++ b/test/commands/feedback/list.test.ts @@ -0,0 +1,316 @@ +/** + * Feedback List Command Tests + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { listCommand } from "../../../src/commands/feedback/list.js"; +import { ApiError, ValidationError } from "../../../src/lib/errors.js"; +import { parsePeriod } from "../../../src/lib/time-range.js"; +import type { SentryFeedback } from "../../../src/types/index.js"; + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; + +vi.mock("../../../src/lib/db/pagination.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as paginationDb from "../../../src/lib/db/pagination.js"; + +vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +function sampleFeedback( + overrides: Partial = {} +): SentryFeedback { + return { + id: "5146636313", + shortId: "TEST-PROJECT-2SDJ", + title: "User Feedback", + issueCategory: "feedback", + issueType: "feedback", + status: "unresolved", + hasSeen: false, + firstSeen: "2026-07-16T12:00:00Z", + permalink: + "https://sentry.io/organizations/test-org/feedback/?feedbackSlug=test-project%3A5146636313", + project: { id: "42", slug: "test-project", name: "Test Project" }, + metadata: { + message: "The checkout button does not work", + name: "Ada", + contact_email: "ada@example.com", + }, + ...overrides, + }; +} + +function createMockContext() { + const stdoutWrite = vi.fn(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; +} + +describe("feedback list", () => { + let listFeedbackSpy: ReturnType; + let getProjectSpy: ReturnType; + let resolveTargetSpy: ReturnType; + let resolveCursorSpy: ReturnType; + let advancePaginationStateSpy: ReturnType; + let hasPreviousPageSpy: ReturnType; + + beforeEach(() => { + listFeedbackSpy = vi.spyOn(apiClient, "listFeedback"); + getProjectSpy = vi.spyOn(apiClient, "getProject").mockResolvedValue({ + id: "42", + slug: "test-project", + name: "Test Project", + }); + resolveTargetSpy = vi.spyOn( + resolveTarget, + "resolveOrgOptionalProjectFromArg" + ); + resolveCursorSpy = vi.spyOn(paginationDb, "resolveCursor").mockReturnValue({ + cursor: undefined, + direction: "next", + }); + advancePaginationStateSpy = vi + .spyOn(paginationDb, "advancePaginationState") + .mockReturnValue(undefined); + hasPreviousPageSpy = vi + .spyOn(paginationDb, "hasPreviousPage") + .mockReturnValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("renders the standard JSON envelope for a project", async () => { + resolveTargetSpy.mockResolvedValue({ + org: "test-org", + project: "test-project", + projectData: { id: "42", slug: "test-project", name: "Test Project" }, + }); + listFeedbackSpy.mockResolvedValue({ + feedback: [sampleFeedback()], + nextCursor: "0:25:0", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { + status: "unresolved", + limit: 25, + period: parsePeriod("14d"), + json: true, + fresh: false, + }, + "test-org/test-project" + ); + + expect(listFeedbackSpy).toHaveBeenCalledWith("test-org", "test-project", { + limit: 25, + status: "unresolved", + query: undefined, + cursor: undefined, + projectId: 42, + statsPeriod: "14d", + }); + expect(getProjectSpy).not.toHaveBeenCalled(); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(JSON.parse(output)).toMatchObject({ + data: [{ shortId: "TEST-PROJECT-2SDJ" }], + hasMore: true, + hasPrev: false, + nextCursor: "0:25:0", + }); + }); + + test("preserves status, query, period, and bidirectional pagination hints", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + resolveCursorSpy.mockReturnValue({ cursor: "previous", direction: "prev" }); + hasPreviousPageSpy.mockReturnValue(true); + listFeedbackSpy.mockResolvedValue({ + feedback: [sampleFeedback({ status: "ignored", hasSeen: true })], + nextCursor: "next", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { + status: "spam", + limit: 250, + query: "browser:Chrome", + period: parsePeriod("30d"), + cursor: "prev", + json: false, + fresh: false, + }, + "test-org/" + ); + + expect(listFeedbackSpy).toHaveBeenCalledWith("test-org", "", { + limit: 250, + status: "spam", + query: "browser:Chrome", + cursor: "previous", + projectId: undefined, + statsPeriod: "30d", + }); + expect(advancePaginationStateSpy).toHaveBeenCalledWith( + "feedback-list", + expect.any(String), + "prev", + "next" + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(output).toContain("Spam"); + expect(output).toContain("Read"); + expect(output).toContain( + 'sentry feedback list test-org/ -c prev --status spam -q "browser:Chrome" --period 30d' + ); + expect(output).toContain( + 'sentry feedback list test-org/ -c next --status spam -q "browser:Chrome" --period 30d' + ); + }); + + test("supports per-item field filtering in JSON", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + listFeedbackSpy.mockResolvedValue({ feedback: [sampleFeedback()] }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { + status: "all", + limit: 25, + period: parsePeriod("14d"), + json: true, + fields: ["shortId"], + fresh: false, + }); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(JSON.parse(output).data).toEqual([{ shortId: "TEST-PROJECT-2SDJ" }]); + expect(resolveTargetSpy).toHaveBeenCalledWith( + undefined, + "/tmp", + "feedback list" + ); + }); + + test("supports bare-project search results without refetching the project", async () => { + resolveTargetSpy.mockResolvedValue({ + org: "test-org", + project: "test-project", + projectData: { id: "42", slug: "test-project", name: "Test Project" }, + }); + listFeedbackSpy.mockResolvedValue({ feedback: [sampleFeedback()] }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { + status: "unresolved", + limit: 25, + period: parsePeriod("14d"), + json: true, + fresh: false, + }, + "test-project" + ); + + expect(resolveTargetSpy).toHaveBeenCalledWith( + "test-project", + "/tmp", + "feedback list" + ); + expect(listFeedbackSpy).toHaveBeenCalledWith( + "test-org", + "test-project", + expect.objectContaining({ projectId: 42 }) + ); + expect(getProjectSpy).not.toHaveBeenCalled(); + }); + + test("renders an empty result without a navigation hint", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + listFeedbackSpy.mockResolvedValue({ feedback: [] }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { + status: "unresolved", + limit: 25, + period: parsePeriod("14d"), + json: false, + fresh: false, + }); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(output).toContain("No feedback found"); + expect(output).not.toContain("Next:"); + }); + + test("converts user query 400 responses to ValidationError", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + listFeedbackSpy.mockRejectedValue( + new ApiError("bad query", 400, "Error parsing search query") + ); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await expect( + func.call(context, { + status: "unresolved", + limit: 25, + query: "bad:::query", + period: parsePeriod("14d"), + json: true, + fresh: false, + }) + ).rejects.toBeInstanceOf(ValidationError); + }); +}); diff --git a/test/commands/feedback/utils.test.ts b/test/commands/feedback/utils.test.ts new file mode 100644 index 0000000000..da5234d8fb --- /dev/null +++ b/test/commands/feedback/utils.test.ts @@ -0,0 +1,90 @@ +/** + * Feedback resolution tests. + */ + +import { afterEach, describe, expect, test, vi } from "vitest"; +import { resolveFeedback } from "../../../src/commands/feedback/utils.js"; +import { ApiError, type ResolutionError } from "../../../src/lib/errors.js"; +import type { SentryIssue } from "../../../src/types/index.js"; + +vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/commands/issue/utils.js") + >(); + return Object.fromEntries( + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as issueUtils from "../../../src/commands/issue/utils.js"; + +function issue(overrides: Partial = {}): SentryIssue { + return { + id: "123", + shortId: "TEST-PROJECT-1A", + title: "User Feedback", + ...overrides, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("resolveFeedback", () => { + test("uses issue resolution with feedback-specific command hints", async () => { + const resolveIssueSpy = vi + .spyOn(issueUtils, "resolveIssue") + .mockResolvedValue({ + org: "test-org", + issue: issue({ + issueCategory: "feedback", + issueType: "feedback", + metadata: { message: "Broken" }, + }), + }); + + const resolved = await resolveFeedback("TEST-PROJECT-1A", "/tmp"); + + expect(resolveIssueSpy).toHaveBeenCalledWith({ + issueArg: "TEST-PROJECT-1A", + cwd: "/tmp", + command: "view", + commandBase: "sentry feedback", + }); + expect(resolved.feedback.issueCategory).toBe("feedback"); + }); + + test("rejects ordinary issues with an issue-view recovery hint", async () => { + vi.spyOn(issueUtils, "resolveIssue").mockResolvedValue({ + org: "test-org", + issue: issue({ issueCategory: "error", issueType: "error" }), + }); + + await expect( + resolveFeedback("TEST-PROJECT-1A", "/tmp") + ).rejects.toMatchObject>({ + name: "ResolutionError", + hint: "sentry issue view test-org/TEST-PROJECT-1A", + }); + }); + + test("translates short-ID 404s to feedback-specific recovery", async () => { + vi.spyOn(issueUtils, "resolveIssue").mockRejectedValue( + new ApiError("Short ID not found", 404) + ); + + await expect( + resolveFeedback("TEST-PROJECT-404", "/tmp") + ).rejects.toMatchObject>({ + name: "ResolutionError", + hint: "sentry feedback view /TEST-PROJECT-404", + suggestions: ["List available Feedback: sentry feedback list"], + }); + }); +}); diff --git a/test/commands/feedback/view.test.ts b/test/commands/feedback/view.test.ts new file mode 100644 index 0000000000..fed40aaa85 --- /dev/null +++ b/test/commands/feedback/view.test.ts @@ -0,0 +1,266 @@ +/** + * Feedback View Command Tests + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { viewCommand } from "../../../src/commands/feedback/view.js"; +import type { SentryEvent, SentryFeedback } from "../../../src/types/index.js"; + +vi.mock("../../../src/commands/feedback/utils.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/commands/feedback/utils.js") + >(); + return Object.fromEntries( + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as feedbackUtils from "../../../src/commands/feedback/utils.js"; + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; + +vi.mock("../../../src/lib/browser.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as browser from "../../../src/lib/browser.js"; + +const REPLAY_ID = "346789a703f6454384f1de473b8b9fcc"; +const EXTRA_REPLAY_ID = "aaaaaaaa03f6454384f1de473b8b9fcc"; + +function feedback(): SentryFeedback { + return { + id: "5146636313", + shortId: "TEST-PROJECT-2SDJ", + title: "User Feedback", + issueCategory: "feedback", + issueType: "feedback", + status: "unresolved", + hasSeen: false, + firstSeen: "2026-07-16T12:00:00Z", + permalink: + "https://sentry.io/organizations/test-org/feedback/?feedbackSlug=test-project%3A5146636313", + project: { id: "42", slug: "test-project", name: "Test Project" }, + metadata: { + message: "Checkout is broken\nPlease help", + name: "Ada", + contact_email: "ada@example.com", + source: "new_feedback_envelope", + }, + }; +} + +function event(): SentryEvent { + return { + eventID: "abc123def456abc123def456abc12345", + title: "User Feedback", + dateReceived: "2026-07-16T12:00:00Z", + contexts: { + feedback: { + url: "https://example.com/checkout", + associated_event_id: "def456abc123def456abc123def456ab", + }, + }, + tags: [{ key: "replay.id", value: REPLAY_ID }], + }; +} + +function createMockContext() { + const stdoutWrite = vi.fn(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; +} + +describe("feedback view", () => { + let resolveFeedbackSpy: ReturnType; + let getLatestEventSpy: ReturnType; + let listReplayIdsSpy: ReturnType; + let listAttachmentsSpy: ReturnType; + let openInBrowserSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + resolveFeedbackSpy = vi + .spyOn(feedbackUtils, "resolveFeedback") + .mockResolvedValue({ org: "test-org", feedback: feedback() }); + getLatestEventSpy = vi + .spyOn(apiClient, "getLatestEvent") + .mockResolvedValue(event()); + listReplayIdsSpy = vi + .spyOn(apiClient, "listReplayIdsForIssue") + .mockResolvedValue([REPLAY_ID, EXTRA_REPLAY_ID]); + listAttachmentsSpy = vi + .spyOn(apiClient, "listEventAttachments") + .mockResolvedValue([ + { + id: "attachment-1", + event_id: "abc123def456abc123def456abc12345", + type: "event.attachment", + name: "screenshot.png", + mimetype: "image/png", + dateCreated: "2026-07-16T12:00:00Z", + size: 2048, + headers: {}, + sha1: null, + }, + ]); + openInBrowserSpy = vi.spyOn(browser, "openInBrowser").mockResolvedValue(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("flattens feedback and enrichment data in JSON", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { + json: true, + web: false, + fresh: false, + }, + "TEST-PROJECT-2SDJ" + ); + + expect(listAttachmentsSpy).toHaveBeenCalledWith( + "test-org", + "test-project", + "abc123def456abc123def456abc12345" + ); + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(JSON.parse(output)).toMatchObject({ + shortId: "TEST-PROJECT-2SDJ", + org: "test-org", + event: { eventID: "abc123def456abc123def456abc12345" }, + replayIds: [REPLAY_ID, EXTRA_REPLAY_ID], + attachments: [{ id: "attachment-1", name: "screenshot.png" }], + }); + }); + + test("renders message, linked error, replay, and attachment context", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { + json: false, + web: false, + fresh: false, + }, + "TEST-PROJECT-2SDJ" + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(output).toContain("Checkout is broken"); + expect(output).toContain("sentry event view"); + expect(output).toContain("def456abc123def456abc123def456ab"); + expect(output).toContain(EXTRA_REPLAY_ID); + expect(output).toContain("screenshot.png"); + expect(output).toContain("2.0 KB"); + }); + + test("keeps core feedback output when optional enrichments fail", async () => { + getLatestEventSpy.mockRejectedValue(new Error("event unavailable")); + listReplayIdsSpy.mockRejectedValue(new Error("replays unavailable")); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { + json: true, + web: false, + fresh: false, + }, + "5146636313" + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(JSON.parse(output)).toMatchObject({ + shortId: "TEST-PROJECT-2SDJ", + event: null, + replayIds: [], + attachments: [], + }); + expect(listAttachmentsSpy).not.toHaveBeenCalled(); + }); + + test("keeps event context when attachment enrichment fails", async () => { + listAttachmentsSpy.mockRejectedValue(new Error("attachments unavailable")); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { + json: true, + web: false, + fresh: false, + }, + "TEST-PROJECT-2SDJ" + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(JSON.parse(output)).toMatchObject({ + shortId: "TEST-PROJECT-2SDJ", + event: { eventID: "abc123def456abc123def456abc12345" }, + attachments: [], + }); + }); + + test("opens the feedback permalink without enrichment calls", async () => { + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { + json: false, + web: true, + fresh: false, + }, + "TEST-PROJECT-2SDJ" + ); + + expect(openInBrowserSpy).toHaveBeenCalledWith( + feedback().permalink, + "feedback" + ); + expect(getLatestEventSpy).not.toHaveBeenCalled(); + expect(listReplayIdsSpy).not.toHaveBeenCalled(); + expect(listAttachmentsSpy).not.toHaveBeenCalled(); + expect(resolveFeedbackSpy).toHaveBeenCalled(); + }); +}); diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index ffbe7874b6..13318fd98a 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -86,6 +86,12 @@ describe("buildCommandHint", () => { `sentry issue view ${issueUrl}` ); }); + + test("supports a custom command domain", () => { + expect(buildCommandHint("view", "PROJECT-ABC", "sentry feedback")).toBe( + "sentry feedback view /PROJECT-ABC" + ); + }); }); const getConfigDir = useTestConfigDir("test-issue-utils-", { @@ -115,6 +121,22 @@ afterEach(() => { }); describe("resolveOrgAndIssueId", () => { + test("uses a custom command domain in deep resolution suggestions", async () => { + const error = await resolveIssue({ + issueArg: "my-org/G", + cwd: getConfigDir(), + command: "view", + commandBase: "sentry feedback", + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ResolutionError); + expect((error as ResolutionError).hint).toContain("sentry feedback view"); + expect((error as ResolutionError).suggestions).toEqual([ + "The format 'my-org/G' requires a project to build the full issue ID.", + "Use: sentry feedback view my-org/-G", + ]); + }); + test("throws for numeric ID (org cannot be resolved)", async () => { // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { diff --git a/test/e2e/feedback.test.ts b/test/e2e/feedback.test.ts new file mode 100644 index 0000000000..b39f7d93c2 --- /dev/null +++ b/test/e2e/feedback.test.ts @@ -0,0 +1,204 @@ +/** + * Feedback command E2E tests. + * + * Exercises the real command router and generated API client against a mock + * Sentry API, including the mandatory issue-category and status filters. + */ + +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "vitest"; +import { EXIT } from "../../src/lib/errors.js"; +import { createE2EContext, type E2EContext } from "../fixture.js"; +import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; +import { + createSentryMockServer, + TEST_FEEDBACK_ID, + TEST_FEEDBACK_SHORT_ID, + TEST_ORG, + TEST_PROJECT, + TEST_TOKEN, +} from "../mocks/routes.js"; +import type { MockServer } from "../mocks/server.js"; + +let testConfigDir: string; +let mockServer: MockServer; +let ctx: E2EContext; + +beforeAll(async () => { + mockServer = createSentryMockServer(); + await mockServer.start(); +}); + +afterAll(() => { + mockServer.stop(); +}); + +beforeEach(async () => { + testConfigDir = await createTestConfigDir("e2e-feedback-"); + ctx = createE2EContext(testConfigDir, mockServer.url); +}); + +afterEach(async () => { + await cleanupTestDir(testConfigDir); +}); + +describe("sentry feedback routes", () => { + test( + "documents list, view, and the default view route", + { timeout: 30_000 }, + async () => { + const routeHelp = await ctx.run(["feedback", "--help"]); + const listHelp = await ctx.run(["feedback", "list", "--help"]); + const viewHelp = await ctx.run(["feedback", "view", "--help"]); + + expect(routeHelp.exitCode, routeHelp.stderr).toBe(0); + expect(routeHelp.stdout).toContain("list"); + expect(routeHelp.stdout).toContain("view"); + expect(listHelp.exitCode, listHelp.stderr).toBe(0); + expect(listHelp.stdout).toContain("--status"); + expect(listHelp.stdout).toContain("--period"); + expect(viewHelp.exitCode, viewHelp.stderr).toBe(0); + expect(viewHelp.stdout).toContain("--web"); + } + ); +}); + +describe("sentry feedback list", () => { + test("requires authentication", async () => { + const result = await ctx.run([ + "feedback", + "list", + `${TEST_ORG}/${TEST_PROJECT}`, + ]); + + expect(result.exitCode).toBe(EXIT.AUTH_NOT_AUTHENTICATED); + }); + + test("queries modern unresolved Feedback and returns the JSON envelope", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run([ + "feedback", + "list", + `${TEST_ORG}/${TEST_PROJECT}`, + "--query", + "e2e-status-check", + "--json", + ]); + + expect(result.exitCode, result.stderr + result.stdout).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + data: [ + { + id: TEST_FEEDBACK_ID, + shortId: TEST_FEEDBACK_SHORT_ID, + issueCategory: "feedback", + status: "unresolved", + }, + ], + hasMore: false, + hasPrev: false, + }); + }); +}); + +describe("sentry feedback view", () => { + test("returns flattened event, replay, and attachment data", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run([ + "feedback", + "view", + `${TEST_ORG}/${TEST_FEEDBACK_SHORT_ID}`, + "--json", + ]); + + expect(result.exitCode, result.stderr + result.stdout).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + id: TEST_FEEDBACK_ID, + org: TEST_ORG, + issueCategory: "feedback", + event: { groupID: TEST_FEEDBACK_ID }, + replayIds: [ + "346789a703f6454384f1de473b8b9fcc", + "aaaaaaaa03f6454384f1de473b8b9fcc", + ], + attachments: [{ name: "screenshot.png", size: 2048 }], + }); + }); + + test("supports the default route and show alias", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const defaultResult = await ctx.run([ + "feedback", + `${TEST_ORG}/${TEST_FEEDBACK_SHORT_ID}`, + "--json", + "--fields", + "id", + ]); + const aliasResult = await ctx.run([ + "feedback", + "show", + `${TEST_ORG}/${TEST_FEEDBACK_SHORT_ID}`, + "--json", + "--fields", + "id", + ]); + + expect( + defaultResult.exitCode, + defaultResult.stderr + defaultResult.stdout + ).toBe(0); + expect(aliasResult.exitCode, aliasResult.stderr + aliasResult.stdout).toBe( + 0 + ); + expect(JSON.parse(defaultResult.stdout)).toEqual({ id: TEST_FEEDBACK_ID }); + expect(JSON.parse(aliasResult.stdout)).toEqual({ id: TEST_FEEDBACK_ID }); + }); + + test("accepts numeric, bare short, and fully scoped IDs", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const inputs = [ + TEST_FEEDBACK_ID, + TEST_FEEDBACK_SHORT_ID, + `${TEST_ORG}/${TEST_PROJECT}/2SDJ`, + ]; + for (const input of inputs) { + const result = await ctx.run([ + "feedback", + "view", + input, + "--json", + "--fields", + "id", + ]); + expect(result.exitCode, result.stderr + result.stdout).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ id: TEST_FEEDBACK_ID }); + } + }); + + test("rejects ordinary issues with an issue-view hint", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run([ + "feedback", + "view", + `${TEST_ORG}/TEST-PROJECT-1A`, + ]); + + expect(result.exitCode, result.stderr + result.stdout).toBe( + EXIT.RESOLUTION + ); + expect(result.stderr + result.stdout).toContain("is not User Feedback"); + expect(result.stderr + result.stdout).toContain("sentry issue view"); + }); +}); diff --git a/test/fixtures/feedback-event.json b/test/fixtures/feedback-event.json new file mode 100644 index 0000000000..320d7d2b74 --- /dev/null +++ b/test/fixtures/feedback-event.json @@ -0,0 +1,33 @@ +{ + "id": "feed123def456abc123def456abc1234", + "eventID": "feed123def456abc123def456abc1234", + "groupID": "5146636313", + "projectID": "300001", + "context": {}, + "contexts": { + "feedback": { + "url": "https://example.com/checkout", + "associated_event_id": "def456abc123def456abc123def456ab" + } + }, + "culprit": "", + "dateCreated": "2026-07-16T12:00:00.000000Z", + "dateReceived": "2026-07-16T12:00:01.000000Z", + "entries": [], + "errors": [], + "fingerprints": ["{{ default }}"], + "location": "", + "message": "Checkout is broken", + "metadata": { + "message": "Checkout is broken" + }, + "platform": "javascript", + "tags": [ + { + "key": "replay.id", + "value": "346789a703f6454384f1de473b8b9fcc" + } + ], + "title": "User Feedback", + "type": "feedback" +} diff --git a/test/fixtures/feedback.json b/test/fixtures/feedback.json new file mode 100644 index 0000000000..872ae6f9d2 --- /dev/null +++ b/test/fixtures/feedback.json @@ -0,0 +1,48 @@ +{ + "id": "5146636313", + "shareId": null, + "shortId": "TEST-PROJECT-2SDJ", + "title": "User Feedback", + "culprit": "", + "permalink": "https://test-org.sentry.io/feedback/?feedbackSlug=test-project%3A5146636313", + "level": "info", + "status": "unresolved", + "statusDetails": {}, + "substatus": "new", + "isPublic": false, + "platform": "javascript", + "project": { + "id": "300001", + "name": "Test Project", + "slug": "test-project", + "platform": "javascript" + }, + "type": "feedback", + "metadata": { + "message": "Checkout is broken\nPlease help", + "name": "Ada Lovelace", + "contact_email": "ada@example.com", + "source": "new_feedback_envelope" + }, + "numComments": 0, + "assignedTo": null, + "isBookmarked": false, + "isSubscribed": true, + "hasSeen": false, + "annotations": [], + "issueType": "feedback", + "issueCategory": "feedback", + "priority": "medium", + "isUnhandled": false, + "count": "1", + "userCount": 1, + "firstSeen": "2026-07-16T12:00:00.000000Z", + "lastSeen": "2026-07-16T12:00:00.000000Z", + "activity": [], + "seenBy": [], + "pluginActions": [], + "pluginIssues": [], + "pluginContexts": [], + "userReportCount": 0, + "participants": [] +} diff --git a/test/fixtures/feedbacks.json b/test/fixtures/feedbacks.json new file mode 100644 index 0000000000..27bec3e7b9 --- /dev/null +++ b/test/fixtures/feedbacks.json @@ -0,0 +1,34 @@ +[ + { + "id": "5146636313", + "shortId": "TEST-PROJECT-2SDJ", + "title": "User Feedback", + "permalink": "https://test-org.sentry.io/feedback/?feedbackSlug=test-project%3A5146636313", + "level": "info", + "status": "unresolved", + "statusDetails": {}, + "substatus": "new", + "isPublic": false, + "platform": "javascript", + "project": { + "id": "300001", + "name": "Test Project", + "slug": "test-project", + "platform": "javascript" + }, + "type": "feedback", + "metadata": { + "message": "Checkout is broken\nPlease help", + "name": "Ada Lovelace", + "contact_email": "ada@example.com", + "source": "new_feedback_envelope" + }, + "hasSeen": false, + "issueType": "feedback", + "issueCategory": "feedback", + "count": "1", + "userCount": 1, + "firstSeen": "2026-07-16T12:00:00.000000Z", + "lastSeen": "2026-07-16T12:00:00.000000Z" + } +] diff --git a/test/lib/api-client.coverage.test.ts b/test/lib/api-client.coverage.test.ts index 369540d2eb..ccb6907d2f 100644 --- a/test/lib/api-client.coverage.test.ts +++ b/test/lib/api-client.coverage.test.ts @@ -431,6 +431,37 @@ describe("issues.ts", () => { expect(result.nextCursor).toBe("has-more"); }); + test("uses the remaining limit on the final page and preserves its cursor", async () => { + const requestedLimits: string[] = []; + let callCount = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + callCount += 1; + const req = new Request(input!, init); + const url = new URL(req.url); + requestedLimits.push(url.searchParams.get("limit") ?? ""); + const count = callCount === 3 ? 50 : 100; + const issues = Array.from({ length: count }, (_, index) => + mockIssue({ id: `${callCount}-${index}` }) + ); + return new Response(JSON.stringify(issues), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader(`cursor-${callCount + 1}`, true), + }, + }); + }); + + const result = await listIssuesAllPages("test-org", "test-project", { + limit: 250, + }); + + expect(result.issues).toHaveLength(250); + expect(requestedLimits).toEqual(["100", "100", "50"]); + expect(result.nextCursor).toBe("cursor-4"); + }); + test("accepts startCursor option", async () => { let capturedUrl = ""; diff --git a/test/lib/api/feedback.property.test.ts b/test/lib/api/feedback.property.test.ts new file mode 100644 index 0000000000..fbb393fb8c --- /dev/null +++ b/test/lib/api/feedback.property.test.ts @@ -0,0 +1,47 @@ +/** + * Property tests for the User Feedback API query boundary. + */ + +import fc from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + buildFeedbackQuery, + type FeedbackStatus, +} from "../../../src/lib/api/feedback.js"; + +const CATEGORY_FILTER = "issue.category:feedback"; + +describe("buildFeedbackQuery", () => { + test("always preserves the mandatory feedback category", () => { + fc.assert( + fc.property( + fc.constantFrom( + "unresolved", + "resolved", + "spam", + "all" + ), + fc.string(), + (status, query) => { + expect(buildFeedbackQuery(status, query)).toStartWith( + CATEGORY_FILTER + ); + } + ) + ); + }); + + test.each([ + ["unresolved", "status:unresolved"], + ["resolved", "status:resolved"], + ["spam", "status:ignored"], + ] as const)("maps %s to %s", (status, expected) => { + expect(buildFeedbackQuery(status)).toBe(`${CATEGORY_FILTER} ${expected}`); + }); + + test("omits the generated status filter for all", () => { + expect(buildFeedbackQuery("all", "browser:Chrome")).toBe( + `${CATEGORY_FILTER} browser:Chrome` + ); + }); +}); diff --git a/test/lib/completions.property.test.ts b/test/lib/completions.property.test.ts index 1e34785c82..3af18e99df 100644 --- a/test/lib/completions.property.test.ts +++ b/test/lib/completions.property.test.ts @@ -196,6 +196,7 @@ describe("proposeCompletions: Stricli integration", () => { "log", "local", "monitor", + "feedback", ]); test("subcommands match extractCommandTree for each group without defaultCommand", async () => { diff --git a/test/lib/formatters/feedback.test.ts b/test/lib/formatters/feedback.test.ts new file mode 100644 index 0000000000..bd50e9aab2 --- /dev/null +++ b/test/lib/formatters/feedback.test.ts @@ -0,0 +1,146 @@ +/** + * Feedback formatter tests. + */ + +import stringWidth from "string-width"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + formatFeedbackList, + formatFeedbackView, +} from "../../../src/lib/formatters/feedback.js"; +import type { SentryFeedback } from "../../../src/types/index.js"; + +const originalPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; + +function feedback(overrides: Partial = {}): SentryFeedback { + return { + id: "1", + shortId: "WEB-1", + title: "User Feedback", + issueCategory: "feedback", + issueType: "feedback", + status: "unresolved", + hasSeen: false, + firstSeen: "2026-07-16T12:00:00Z", + project: { id: "1", slug: "web", name: "Web" }, + metadata: { message: "Button | failed\nwith **markdown**" }, + ...overrides, + }; +} + +beforeAll(() => { + process.env.SENTRY_PLAIN_OUTPUT = "1"; +}); + +afterAll(() => { + if (originalPlainOutput === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = originalPlainOutput; + } +}); + +describe("formatFeedbackList", () => { + test("renders anonymous, unread feedback without breaking the table", () => { + const output = formatFeedbackList({ + feedback: [feedback()], + hasMore: false, + hasPrev: false, + org: "test-org", + project: "web", + }); + + expect(output).toContain("Anonymous"); + expect(output).toContain("Unread"); + expect(output).toContain("Button"); + }); + + test("maps ignored feedback to Spam and combines name with email", () => { + const output = formatFeedbackList({ + feedback: [ + feedback({ + status: "ignored", + hasSeen: true, + metadata: { + message: "Spam", + name: "Ada", + contact_email: "ada@example.com", + }, + }), + ], + hasMore: false, + hasPrev: false, + org: "test-org", + }); + + expect(output).toContain("Ada"); + expect(output).toContain("Spam"); + expect(output).toContain("Read"); + }); + + test("does not label an absent read state as unread", () => { + const output = formatFeedbackList({ + feedback: [feedback({ hasSeen: undefined })], + hasMore: false, + hasPrev: false, + org: "test-org", + }); + + expect(output).toContain("Unknown"); + expect(output).not.toContain("Unread"); + }); +}); + +describe("formatFeedbackView", () => { + test("escapes message markdown while preserving multiple lines", () => { + const output = formatFeedbackView({ + org: "test-org", + feedback: feedback(), + event: null, + replayIds: [], + attachments: [], + }); + + expect(output).toContain("Button | failed"); + expect(output).toContain("with **markdown**"); + }); + + test("truncates long list messages but preserves the complete detail message", () => { + const savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + const savedColumns = process.stdout.columns; + const longMessage = `${"Checkout failed ".repeat(20)}\nTAIL **marker**`; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + process.stdout.columns = 60; + + try { + const item = feedback({ metadata: { message: longMessage } }); + const listOutput = formatFeedbackList({ + feedback: [item], + hasMore: false, + hasPrev: false, + org: "test-org", + project: "web", + }); + const viewOutput = formatFeedbackView({ + org: "test-org", + feedback: item, + event: null, + replayIds: [], + attachments: [], + }); + + expect(listOutput).toContain("…"); + expect( + Math.max(...listOutput.split("\n").map((line) => stringWidth(line))) + ).toBeLessThanOrEqual(60); + expect(viewOutput).toContain("TAIL **marker**"); + } finally { + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + process.stdout.columns = savedColumns; + } + }); +}); diff --git a/test/mocks/routes.ts b/test/mocks/routes.ts index 7e02f96f39..4df6470885 100644 --- a/test/mocks/routes.ts +++ b/test/mocks/routes.ts @@ -8,6 +8,9 @@ import methodNotAllowedFixture from "../fixtures/errors/method-not-allowed.json"; import notFoundFixture from "../fixtures/errors/not-found.json"; import eventFixture from "../fixtures/event.json"; +import feedbackFixture from "../fixtures/feedback.json"; +import feedbackEventFixture from "../fixtures/feedback-event.json"; +import feedbacksFixture from "../fixtures/feedbacks.json"; import issueFixture from "../fixtures/issue.json"; import issuesFixture from "../fixtures/issues.json"; import logDetailFixture from "../fixtures/log-detail.json"; @@ -28,6 +31,9 @@ export const TEST_TOKEN = "test-auth-token-12345"; export const TEST_ISSUE_ID = "400001"; export const TEST_ISSUE_SHORT_ID = "TEST-PROJECT-1A"; export const TEST_EVENT_ID = "abc123def456abc123def456abc12345"; +export const TEST_FEEDBACK_ID = "5146636313"; +export const TEST_FEEDBACK_SHORT_ID = "TEST-PROJECT-2SDJ"; +export const TEST_FEEDBACK_EVENT_ID = "feed123def456abc123def456abc1234"; export const TEST_DSN = "https://abc123@o123.ingest.sentry.io/456789"; export const TEST_LOG_ID = "a0a1a2a3a4a5a6a7a8a9b0b1b2b3b4b5"; export const TEST_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; @@ -142,8 +148,21 @@ export const apiRoutes: MockRoute[] = [ { method: "GET", path: "/api/0/organizations/:orgSlug/issues/", - response: (_req, params) => { + response: (req, params) => { if (params.orgSlug === TEST_ORG) { + const query = new URL(req.url).searchParams.get("query") ?? ""; + if (query.includes("issue.category:feedback")) { + if ( + query.includes("e2e-status-check") && + !query.includes("status:unresolved") + ) { + return { + status: 400, + body: { detail: "Missing default Feedback status filter" }, + }; + } + return { body: feedbacksFixture }; + } return { body: issuesFixture }; } return { status: 404, body: notFoundFixture }; @@ -164,6 +183,9 @@ export const apiRoutes: MockRoute[] = [ method: "GET", path: "/api/0/issues/:issueId/", response: (_req, params) => { + if (params.issueId === TEST_FEEDBACK_ID) { + return { body: feedbackFixture }; + } if (params.issueId === TEST_ISSUE_ID) { return { body: issueFixture }; } @@ -174,6 +196,15 @@ export const apiRoutes: MockRoute[] = [ method: "GET", path: "/api/0/organizations/:orgSlug/issues/:shortId/", response: (_req, params) => { + if (params.orgSlug === TEST_ORG && params.shortId === TEST_FEEDBACK_ID) { + return { body: feedbackFixture }; + } + if ( + params.orgSlug === TEST_ORG && + params.shortId.toUpperCase() === TEST_FEEDBACK_SHORT_ID + ) { + return { body: feedbackFixture }; + } if ( params.orgSlug === TEST_ORG && params.shortId.toUpperCase() === TEST_ISSUE_SHORT_ID @@ -183,10 +214,32 @@ export const apiRoutes: MockRoute[] = [ return { status: 404, body: notFoundFixture }; }, }, + { + method: "GET", + path: "/api/0/organizations/:orgSlug/shortids/:shortId/", + response: (_req, params) => { + if ( + params.orgSlug === TEST_ORG && + params.shortId.toUpperCase() === TEST_FEEDBACK_SHORT_ID + ) { + return { body: { group: feedbackFixture } }; + } + if ( + params.orgSlug === TEST_ORG && + params.shortId.toUpperCase() === TEST_ISSUE_SHORT_ID + ) { + return { body: { group: issueFixture } }; + } + return { status: 404, body: notFoundFixture }; + }, + }, { method: "GET", path: "/api/0/organizations/:orgSlug/issues/:issueId/events/latest/", response: (_req, params) => { + if (params.orgSlug === TEST_ORG && params.issueId === TEST_FEEDBACK_ID) { + return { body: feedbackEventFixture }; + } if (params.orgSlug === TEST_ORG && params.issueId === TEST_ISSUE_ID) { return { body: eventFixture }; } @@ -209,6 +262,51 @@ export const apiRoutes: MockRoute[] = [ return { status: 404, body: notFoundFixture }; }, }, + { + method: "GET", + path: "/api/0/projects/:orgSlug/:projectSlug/events/:eventId/attachments/", + response: (_req, params) => { + if ( + params.orgSlug === TEST_ORG && + params.projectSlug === TEST_PROJECT && + params.eventId === TEST_FEEDBACK_EVENT_ID + ) { + return { + body: [ + { + id: "attachment-1", + event_id: TEST_FEEDBACK_EVENT_ID, + type: "event.attachment", + name: "screenshot.png", + mimetype: "image/png", + dateCreated: "2026-07-16T12:00:00Z", + size: 2048, + headers: {}, + sha1: null, + }, + ], + }; + } + return { status: 404, body: notFoundFixture }; + }, + }, + { + method: "GET", + path: "/api/0/organizations/:orgSlug/replay-count/", + response: (_req, params) => { + if (params.orgSlug === TEST_ORG) { + return { + body: { + [TEST_FEEDBACK_ID]: [ + "346789a703f6454384f1de473b8b9fcc", + "aaaaaaaa03f6454384f1de473b8b9fcc", + ], + }, + }; + } + return { status: 404, body: notFoundFixture }; + }, + }, // Logs & Transactions (Events API - dispatches on dataset param) { From e9d52220f715744353517e7f9efe8e2796321fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 21 Jul 2026 16:30:27 +0200 Subject: [PATCH 02/10] fix(feedback): harden output and pagination Sanitize untrusted feedback and event data before terminal rendering, preserve limit-scoped cursor navigation, and fetch attachment metadata across bounded API pages. --- src/commands/feedback/list.ts | 8 +- src/lib/api/events.ts | 31 ++++--- src/lib/formatters/feedback.ts | 126 ++++++++++++++++++++------- test/commands/feedback/list.test.ts | 7 +- test/e2e/feedback.test.ts | 37 ++++++++ test/lib/api-client.coverage.test.ts | 50 +++++++++++ test/lib/formatters/feedback.test.ts | 74 ++++++++++++++++ test/mocks/routes.ts | 43 ++++++++- 8 files changed, 324 insertions(+), 52 deletions(-) diff --git a/src/commands/feedback/list.ts b/src/commands/feedback/list.ts index 3dd537aaa9..d4d7e14ca9 100644 --- a/src/commands/feedback/list.ts +++ b/src/commands/feedback/list.ts @@ -88,12 +88,15 @@ async function projectIdFor( function appendFeedbackFlags( base: string, - flags: Pick + flags: Pick ): string { const parts: string[] = []; if (flags.status !== DEFAULT_STATUS) { parts.push(`--status ${flags.status}`); } + if (flags.limit !== LIST_DEFAULT_LIMIT) { + parts.push(`--limit ${flags.limit}`); + } appendQueryHint(parts, flags.query); appendPeriodHint(parts, flags.period, DEFAULT_PERIOD); return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; @@ -103,7 +106,7 @@ function pageHint( direction: "next" | "prev", org: string, project: string | undefined, - flags: Pick + flags: Pick ): string { return appendFeedbackFlags( `sentry feedback list ${formatScope(org, project)} -c ${direction}`, @@ -207,6 +210,7 @@ export const listCommand = buildListCommand("feedback", { formatScope(resolved.org, resolved.project), { status: flags.status, + limit: String(flags.limit), q: flags.query, period: serializeTimeRange(flags.period), } diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts index cab6b56b5e..35b3c16e34 100644 --- a/src/lib/api/events.ts +++ b/src/lib/api/events.ts @@ -20,6 +20,7 @@ import { ApiError, AuthError } from "../errors.js"; import { API_MAX_PER_PAGE, + autoPaginate, getOrgSdkConfig, MAX_PAGINATION_PAGES, ORG_FANOUT_CONCURRENCY, @@ -80,6 +81,8 @@ export async function getEvent( /** * List metadata for attachments on a project event. * Uses the generated Sentry API client and region-aware organization routing. + * The extra result sentinel lets the shared page bound emit its incomplete-data + * warning instead of stopping silently at the exact theoretical capacity. */ export async function listEventAttachments( orgSlug: string, @@ -87,19 +90,25 @@ export async function listEventAttachments( eventId: string ): Promise { const config = await getOrgSdkConfig(orgSlug); - const result = await listProjectEventAttachments({ - ...config, - path: { - organization_id_or_slug: orgSlug, - project_id_or_slug: projectSlug, - event_id: eventId, + const { data } = await autoPaginate( + async (cursor) => { + const result = await listProjectEventAttachments({ + ...config, + path: { + organization_id_or_slug: orgSlug, + project_id_or_slug: projectSlug, + event_id: eventId, + }, + ...(cursor ? { query: { cursor } } : {}), + }); + return unwrapPaginatedResult( + result, + "Failed to list event attachments" + ); }, - }); - - return unwrapResult( - result, - "Failed to list event attachments" + API_MAX_PER_PAGE * MAX_PAGINATION_PAGES + 1 ); + return data; } /** diff --git a/src/lib/formatters/feedback.ts b/src/lib/formatters/feedback.ts index fd9b5204d2..559e65e79a 100644 --- a/src/lib/formatters/feedback.ts +++ b/src/lib/formatters/feedback.ts @@ -1,5 +1,8 @@ /** * Human-readable formatting for modern Sentry User Feedback. + * + * Feedback and enriched event strings are untrusted. Sanitize them before + * passing them to Markdown, table, or shared event renderers. */ import type { EventAttachmentDetailsResponse } from "@sentry/api"; @@ -21,17 +24,74 @@ import { safeCodeSpan, } from "./markdown.js"; import { formatBytes } from "./numbers.js"; +import { stripAnsi } from "./plain-detect.js"; import { type Column, formatTable } from "./table.js"; import { formatRelativeTime } from "./time-utils.js"; const MAX_REPLAY_IDS_SHOWN = 3; const MAX_LIST_MESSAGE_LENGTH = 160; const COMPACT_LIST_BREAKPOINT = 100; -const LINE_BREAK_RE = /\r?\n/; +const LINE_BREAK_RE = /\n/; +const C1_CSI_RE = /\u009b[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g; +const FEEDBACK_UNSAFE_TERMINAL_RE = + // biome-ignore lint/suspicious/noControlCharactersInRegex: terminal sanitization requires matching control characters + /[\x00-\x09\x0b\x0c\x0e-\x1f\x7f-\x9f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; + +/** Remove terminal controls while preserving intentional message line breaks. */ +function sanitizeFeedbackText(value: string): string { + return stripAnsi(value) + .replace(C1_CSI_RE, "") + .replace(/\r\n?/g, "\n") + .replace(FEEDBACK_UNSAFE_TERMINAL_RE, ""); +} + +function sanitizeFeedbackInline(value: string): string { + return sanitizeFeedbackText(value).replace(/\n+/g, " "); +} + +function escapeFeedbackInline(value: string): string { + return escapeMarkdownInline(sanitizeFeedbackInline(value)); +} + +function escapeFeedbackCell(value: string): string { + return escapeMarkdownCell(sanitizeFeedbackInline(value)); +} + +function feedbackCodeSpan(value: string): string { + return safeCodeSpan(sanitizeFeedbackInline(value)); +} + +/** Recursively sanitize event strings and keys before human rendering. */ +function sanitizeFeedbackEventValue(value: unknown): unknown { + if (typeof value === "string") { + return sanitizeFeedbackText(value); + } + if (Array.isArray(value)) { + return value.map(sanitizeFeedbackEventValue); + } + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + sanitizeFeedbackInline(key), + sanitizeFeedbackEventValue(nestedValue), + ]) + ); + } + return value; +} + +/** Build a human-only event copy; the raw event used for JSON remains unchanged. */ +function sanitizeFeedbackEvent(event: SentryEvent): SentryEvent { + return sanitizeFeedbackEventValue(event) as SentryEvent; +} function feedbackSender(feedback: SentryFeedback): string { - const name = feedback.metadata.name?.trim(); - const email = feedback.metadata.contact_email?.trim(); + const name = feedback.metadata.name + ? sanitizeFeedbackInline(feedback.metadata.name).trim() + : undefined; + const email = feedback.metadata.contact_email + ? sanitizeFeedbackInline(feedback.metadata.contact_email).trim() + : undefined; if (name && email && name !== email) { return `${name} <${email}>`; } @@ -39,11 +99,11 @@ function feedbackSender(feedback: SentryFeedback): string { } function feedbackMessage(feedback: SentryFeedback): string { - return ( + return sanitizeFeedbackText( feedback.metadata.message || - feedback.metadata.value || - feedback.metadata.title || - feedback.title + feedback.metadata.value || + feedback.metadata.title || + feedback.title ); } @@ -63,7 +123,7 @@ function feedbackStatus(feedback: SentryFeedback): string { case "unresolved": return colorTag("yellow", "Unresolved"); default: - return escapeMarkdownInline(feedback.status ?? "Unknown"); + return escapeFeedbackInline(feedback.status ?? "Unknown"); } } @@ -81,8 +141,8 @@ const FEEDBACK_COLUMNS: Column[] = [ header: "ID", value: (feedback) => feedback.permalink - ? `[${feedback.shortId}](${feedback.permalink})` - : safeCodeSpan(feedback.shortId), + ? `[${escapeFeedbackInline(feedback.shortId)}](${feedback.permalink})` + : feedbackCodeSpan(feedback.shortId), minWidth: 8, shrinkable: false, }, @@ -93,13 +153,13 @@ const FEEDBACK_COLUMNS: Column[] = [ }, { header: "FROM", - value: (feedback) => escapeMarkdownCell(feedbackSender(feedback)), + value: (feedback) => escapeFeedbackCell(feedbackSender(feedback)), minWidth: 12, truncate: true, }, { header: "MESSAGE", - value: (feedback) => escapeMarkdownCell(feedbackMessage(feedback)), + value: (feedback) => escapeFeedbackCell(feedbackMessage(feedback)), minWidth: 16, truncate: true, }, @@ -118,7 +178,7 @@ const FEEDBACK_COLUMNS: Column[] = [ { header: "PROJECT", value: (feedback) => - escapeMarkdownCell(feedback.project?.slug ?? "unknown"), + escapeFeedbackCell(feedback.project?.slug ?? "unknown"), minWidth: 7, }, ]; @@ -130,21 +190,21 @@ function formatCompactFeedbackList( ): string { const sections = result.feedback.map((feedback) => { const id = feedback.permalink - ? `[${escapeMarkdownInline(feedback.shortId)}](${feedback.permalink})` - : safeCodeSpan(feedback.shortId); + ? `[${escapeFeedbackInline(feedback.shortId)}](${feedback.permalink})` + : feedbackCodeSpan(feedback.shortId); return [ `### ${id}`, "", `- **Received:** ${formatRelativeTime(feedback.firstSeen ?? undefined)}`, - `- **From:** ${escapeMarkdownInline(feedbackSender(feedback))}`, - `- **Message:** ${escapeMarkdownInline(feedbackMessagePreview(feedback))}`, + `- **From:** ${escapeFeedbackInline(feedbackSender(feedback))}`, + `- **Message:** ${escapeFeedbackInline(feedbackMessagePreview(feedback))}`, `- **Status:** ${feedbackStatus(feedback)}`, `- **Read:** ${feedbackReadState(feedback)}`, - `- **Project:** ${escapeMarkdownInline(feedback.project?.slug ?? "unknown")}`, + `- **Project:** ${escapeFeedbackInline(feedback.project?.slug ?? "unknown")}`, ].join("\n"); }); const markdown = [ - `## Feedback in ${escapeMarkdownInline(scope)}`, + `## Feedback in ${escapeFeedbackInline(scope)}`, "", ...sections, ].join("\n\n"); @@ -204,26 +264,26 @@ function formatFeedbackOverview(data: FeedbackViewResult): string { const rows: [string, string][] = [ ["Status", feedbackStatus(feedback)], ["Read", feedbackReadState(feedback)], - ["From", escapeMarkdownInline(feedbackSender(feedback))], + ["From", escapeFeedbackInline(feedbackSender(feedback))], ["Received", formatRelativeTime(feedback.firstSeen ?? undefined)], ]; if (feedback.project) { rows.push([ "Project", - `${escapeMarkdownInline(feedback.project.name ?? feedback.project.slug)} (${safeCodeSpan(feedback.project.slug)})`, + `${escapeFeedbackInline(feedback.project.name ?? feedback.project.slug)} (${feedbackCodeSpan(feedback.project.slug)})`, ]); } if (feedback.assignedTo?.name) { - rows.push(["Assignee", escapeMarkdownInline(feedback.assignedTo.name)]); + rows.push(["Assignee", escapeFeedbackInline(feedback.assignedTo.name)]); } if (feedback.metadata.source) { - rows.push(["Source", safeCodeSpan(feedback.metadata.source)]); + rows.push(["Source", feedbackCodeSpan(feedback.metadata.source)]); } if (feedback.metadata.sdk?.name) { rows.push([ "SDK", - safeCodeSpan( + feedbackCodeSpan( feedback.metadata.sdk.name_normalized ?? feedback.metadata.sdk.name ), ]); @@ -231,7 +291,7 @@ function formatFeedbackOverview(data: FeedbackViewResult): string { const url = feedbackUrl(data.event); if (url) { - rows.push(["URL", safeCodeSpan(url)]); + rows.push(["URL", feedbackCodeSpan(url)]); } const associatedEventId = linkedEventId(data); @@ -241,7 +301,7 @@ function formatFeedbackOverview(data: FeedbackViewResult): string { data.org && project ? `sentry event view ${data.org}/${project}/${associatedEventId}` : `sentry event view ${associatedEventId}`; - rows.push(["Linked error", safeCodeSpan(command)]); + rows.push(["Linked error", feedbackCodeSpan(command)]); } if (feedback.permalink) { @@ -249,7 +309,7 @@ function formatFeedbackOverview(data: FeedbackViewResult): string { } const lines = [ - `## ${escapeMarkdownInline(feedback.shortId)}: User Feedback`, + `## ${escapeFeedbackInline(feedback.shortId)}: User Feedback`, "", mdKvTable(rows), "", @@ -257,7 +317,7 @@ function formatFeedbackOverview(data: FeedbackViewResult): string { "", ...feedbackMessage(feedback) .split(LINE_BREAK_RE) - .map((line) => `> ${escapeMarkdownInline(line)}`), + .map((line) => `> ${escapeFeedbackInline(line)}`), ]; if (feedback.metadata.summary) { @@ -265,7 +325,7 @@ function formatFeedbackOverview(data: FeedbackViewResult): string { "", "### Summary", "", - escapeMarkdownInline(feedback.metadata.summary) + escapeFeedbackInline(feedback.metadata.summary) ); } @@ -288,7 +348,9 @@ function formatRelatedReplays(data: FeedbackViewResult): string { const command = data.org ? `sentry replay view ${data.org}/${replayId}` : `sentry replay view ${replayId}`; - lines.push(`- ${safeCodeSpan(replayId)} (${safeCodeSpan(command)})`); + lines.push( + `- ${feedbackCodeSpan(replayId)} (${feedbackCodeSpan(command)})` + ); } const remaining = additionalIds.length - MAX_REPLAY_IDS_SHOWN; if (remaining > 0) { @@ -301,7 +363,7 @@ function formatAttachment(attachment: EventAttachmentDetailsResponse): string { const details = [attachment.mimetype, formatBytes(attachment.size)] .filter(Boolean) .join(", "); - return `- ${escapeMarkdownInline(attachment.name)}${details ? ` (${escapeMarkdownInline(details)})` : ""} — ${safeCodeSpan(attachment.id)}`; + return `- ${escapeFeedbackInline(attachment.name)}${details ? ` (${escapeFeedbackInline(details)})` : ""} — ${feedbackCodeSpan(attachment.id)}`; } function formatAttachments( @@ -321,7 +383,7 @@ export function formatFeedbackView(data: FeedbackViewResult): string { if (data.event) { sections.push( formatEventDetails( - data.event, + sanitizeFeedbackEvent(data.event), "Latest Feedback Event", data.feedback.permalink ) diff --git a/test/commands/feedback/list.test.ts b/test/commands/feedback/list.test.ts index 82d760ad5a..e1929c0a5c 100644 --- a/test/commands/feedback/list.test.ts +++ b/test/commands/feedback/list.test.ts @@ -165,7 +165,7 @@ describe("feedback list", () => { }); }); - test("preserves status, query, period, and bidirectional pagination hints", async () => { + test("preserves filters, limit, and bidirectional pagination hints", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org" }); resolveCursorSpy.mockReturnValue({ cursor: "previous", direction: "prev" }); hasPreviousPageSpy.mockReturnValue(true); @@ -204,15 +204,14 @@ describe("feedback list", () => { "prev", "next" ); - const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); expect(output).toContain("Spam"); expect(output).toContain("Read"); expect(output).toContain( - 'sentry feedback list test-org/ -c prev --status spam -q "browser:Chrome" --period 30d' + 'sentry feedback list test-org/ -c prev --status spam --limit 250 -q "browser:Chrome" --period 30d' ); expect(output).toContain( - 'sentry feedback list test-org/ -c next --status spam -q "browser:Chrome" --period 30d' + 'sentry feedback list test-org/ -c next --status spam --limit 250 -q "browser:Chrome" --period 30d' ); }); diff --git a/test/e2e/feedback.test.ts b/test/e2e/feedback.test.ts index b39f7d93c2..db8baac9da 100644 --- a/test/e2e/feedback.test.ts +++ b/test/e2e/feedback.test.ts @@ -107,6 +107,43 @@ describe("sentry feedback list", () => { hasPrev: false, }); }); + + test("isolates stored cursors for different limits", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + for (const limit of [25, 50]) { + const firstPage = await ctx.run([ + "feedback", + "list", + `${TEST_ORG}/${TEST_PROJECT}`, + "--query", + "e2e-limit-context", + "--limit", + String(limit), + "--json", + ]); + expect(firstPage.exitCode, firstPage.stderr + firstPage.stdout).toBe(0); + expect(JSON.parse(firstPage.stdout).nextCursor).toBe( + `feedback-limit-${limit}-next` + ); + } + + for (const limit of [25, 50]) { + const nextPage = await ctx.run([ + "feedback", + "list", + `${TEST_ORG}/${TEST_PROJECT}`, + "--query", + "e2e-limit-context", + "--limit", + String(limit), + "--cursor", + "next", + "--json", + ]); + expect(nextPage.exitCode, nextPage.stderr + nextPage.stdout).toBe(0); + } + }); }); describe("sentry feedback view", () => { diff --git a/test/lib/api-client.coverage.test.ts b/test/lib/api-client.coverage.test.ts index ccb6907d2f..61cd7897f8 100644 --- a/test/lib/api-client.coverage.test.ts +++ b/test/lib/api-client.coverage.test.ts @@ -25,6 +25,7 @@ import { getLatestEvent, getLogs, getProjectKeys, + listEventAttachments, listIssuesAllPages, listLogs, listProjects, @@ -984,6 +985,55 @@ describe("events.ts", () => { }); }); + describe("listEventAttachments", () => { + test("returns attachment metadata from every API page", async () => { + let requestCount = 0; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + const url = new URL(req.url); + expect(url.pathname).toContain( + "/projects/test-org/test-project/events/evt-abc/attachments/" + ); + + requestCount += 1; + const firstPage = url.searchParams.get("cursor") === null; + if (firstPage) { + return new Response( + JSON.stringify([{ id: "attachment-1", name: "first.png" }]), + { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("attachments-next", true), + }, + } + ); + } + + expect(url.searchParams.get("cursor")).toBe("attachments-next"); + return new Response( + JSON.stringify([{ id: "attachment-2", name: "second.png" }]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }); + + const result = await listEventAttachments( + "test-org", + "test-project", + "evt-abc" + ); + + expect(result.map((attachment) => attachment.id)).toEqual([ + "attachment-1", + "attachment-2", + ]); + expect(requestCount).toBe(2); + }); + }); + describe("resolveEventInOrg", () => { test("returns resolved event on success", async () => { const resolved = { diff --git a/test/lib/formatters/feedback.test.ts b/test/lib/formatters/feedback.test.ts index bd50e9aab2..e1c7ccad50 100644 --- a/test/lib/formatters/feedback.test.ts +++ b/test/lib/formatters/feedback.test.ts @@ -143,4 +143,78 @@ describe("formatFeedbackView", () => { process.stdout.columns = savedColumns; } }); + + test("removes terminal control sequences from untrusted fields", () => { + const savedPlain = process.env.SENTRY_PLAIN_OUTPUT; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + const maliciousControlSequences = [ + "\u001b[2J", + "\u001b]52;c;dGVzdA==\u0007", + "\u009b2J", + "\u202e", + ]; + const item = feedback({ + metadata: { + message: `Checkout ${maliciousControlSequences.join("")}failed`, + name: `Ada${maliciousControlSequences.join("")}`, + summary: `Summary${maliciousControlSequences.join("")}`, + }, + }); + + try { + const listOutput = formatFeedbackList({ + feedback: [item], + hasMore: false, + hasPrev: false, + org: "test-org", + project: "web", + }); + const viewOutput = formatFeedbackView({ + org: "test-org", + feedback: item, + event: { + eventID: "abc123def456abc123def456abc12345", + title: "User Feedback", + user: { + name: `Mallory${maliciousControlSequences.join("")}`, + }, + tags: [ + { + key: "unsafe", + value: `tag${maliciousControlSequences.join("")}`, + }, + ], + }, + replayIds: [], + attachments: [ + { + id: "attachment-1", + event_id: "event-1", + type: "event.attachment", + name: `screen${maliciousControlSequences.join("")}.png`, + mimetype: "image/png", + dateCreated: "2026-07-16T12:00:00Z", + size: 10, + headers: {}, + sha1: null, + }, + ], + }); + + for (const sequence of maliciousControlSequences) { + expect(listOutput).not.toContain(sequence); + expect(viewOutput).not.toContain(sequence); + } + expect(listOutput).toContain("Ada"); + expect(viewOutput).toContain("Checkout"); + expect(viewOutput).toContain("Mallory"); + expect(viewOutput).toContain("screen.png"); + } finally { + if (savedPlain === undefined) { + delete process.env.SENTRY_PLAIN_OUTPUT; + } else { + process.env.SENTRY_PLAIN_OUTPUT = savedPlain; + } + } + }); }); diff --git a/test/mocks/routes.ts b/test/mocks/routes.ts index 4df6470885..bf834fd9ec 100644 --- a/test/mocks/routes.ts +++ b/test/mocks/routes.ts @@ -22,7 +22,7 @@ import projectsFixture from "../fixtures/projects.json"; import traceSpansFixture from "../fixtures/trace-spans.json"; import transactionsFixture from "../fixtures/transactions.json"; import userFixture from "../fixtures/user.json"; -import type { MockRoute, MockServer } from "./server.js"; +import type { MockResponse, MockRoute, MockServer } from "./server.js"; import { createMockServer } from "./server.js"; export const TEST_ORG = "test-org"; @@ -51,6 +51,35 @@ const projectKeysFixture = [ }, ]; +function feedbackLimitContextResponse( + url: URL, + serverUrl: string +): MockResponse | undefined { + const query = url.searchParams.get("query") ?? ""; + if (!query.includes("e2e-limit-context")) { + return; + } + + const limit = url.searchParams.get("limit") ?? "unknown"; + const expectedCursor = `feedback-limit-${limit}-next`; + const cursor = url.searchParams.get("cursor"); + if (cursor && cursor !== expectedCursor) { + return { + status: 400, + body: { detail: `Unexpected cursor ${cursor}` }, + }; + } + return { + body: Array.from( + { length: Number.parseInt(limit, 10) }, + () => feedbacksFixture[0] + ), + headers: { + Link: `<${serverUrl}/next>; rel="next"; results="true"; cursor="${expectedCursor}"`, + }, + }; +} + export const apiRoutes: MockRoute[] = [ // User Regions (multi-region support) // Returns the mock server itself as the only region @@ -148,10 +177,18 @@ export const apiRoutes: MockRoute[] = [ { method: "GET", path: "/api/0/organizations/:orgSlug/issues/", - response: (req, params) => { + response: (req, params, serverUrl) => { if (params.orgSlug === TEST_ORG) { - const query = new URL(req.url).searchParams.get("query") ?? ""; + const url = new URL(req.url); + const query = url.searchParams.get("query") ?? ""; if (query.includes("issue.category:feedback")) { + const paginationResponse = feedbackLimitContextResponse( + url, + serverUrl + ); + if (paginationResponse) { + return paginationResponse; + } if ( query.includes("e2e-status-check") && !query.includes("status:unresolved") From 3ae587590718e1629df1a24a1c4d6160cf93a523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 21 Jul 2026 16:54:54 +0200 Subject: [PATCH 03/10] feat(feedback): support @latest selector --- docs/src/fragments/commands/feedback.md | 6 ++ plugins/sentry-cli/skills/sentry-cli/SKILL.md | 2 +- .../skills/sentry-cli/references/feedback.md | 6 +- src/commands/feedback/utils.ts | 70 ++++++++++++- src/commands/feedback/view.ts | 4 +- test/e2e/feedback.test.ts | 41 ++++++++ test/mocks/routes.ts | 97 +++++++++++++------ 7 files changed, 191 insertions(+), 35 deletions(-) diff --git a/docs/src/fragments/commands/feedback.md b/docs/src/fragments/commands/feedback.md index 919861fc37..b98ffb417e 100644 --- a/docs/src/fragments/commands/feedback.md +++ b/docs/src/fragments/commands/feedback.md @@ -36,6 +36,10 @@ direction with `--cursor next` and `--cursor prev`. ### View User Feedback ```bash +# Most recent unresolved Feedback, with detected or explicit organization +sentry feedback view @latest +sentry feedback view my-org/@latest + # Short ID or numeric ID sentry feedback view FRONTEND-2SDJ sentry feedback view 5146636313 @@ -51,6 +55,8 @@ sentry feedback show my-org/FRONTEND-2SDJ sentry feedback view my-org/FRONTEND-2SDJ --web ``` +`@latest` selects the most recently active unresolved Feedback. + The detail view includes the complete message and, when available, its latest event, linked error, Session Replays, and attachment metadata. If the supplied ID belongs to another issue category, use `sentry issue view` instead. diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index edb52f1bb1..131acbd1f0 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -517,7 +517,7 @@ Query aggregate event data (Explore) Search and inspect User Feedback - `sentry feedback list ` — List and search User Feedback -- `sentry feedback view ` — View a User Feedback item +- `sentry feedback view ` — View a User Feedback item → Full flags and examples: `references/feedback.md` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/feedback.md b/plugins/sentry-cli/skills/sentry-cli/references/feedback.md index e4b68bb95a..f0735d8e5e 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/feedback.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/feedback.md @@ -72,7 +72,7 @@ sentry feedback list my-org/frontend --status all --period 90d sentry feedback list my-org/frontend --query "message:*checkout*" ``` -### `sentry feedback view ` +### `sentry feedback view ` View a User Feedback item @@ -115,6 +115,10 @@ View a User Feedback item **Examples:** ```bash +# Most recent unresolved Feedback, with detected or explicit organization +sentry feedback view @latest +sentry feedback view my-org/@latest + # Short ID or numeric ID sentry feedback view FRONTEND-2SDJ sentry feedback view 5146636313 diff --git a/src/commands/feedback/utils.ts b/src/commands/feedback/utils.ts index 4fa7d8f83d..92b6cb2561 100644 --- a/src/commands/feedback/utils.ts +++ b/src/commands/feedback/utils.ts @@ -2,18 +2,28 @@ * Shared resolution utilities for modern User Feedback commands. */ -import { ApiError, ResolutionError } from "../../lib/errors.js"; +import { listFeedback } from "../../lib/api-client.js"; +import { type IssueSelector, parseIssueArg } from "../../lib/arg-parsing.js"; +import { + ApiError, + ContextError, + ResolutionError, + ValidationError, +} from "../../lib/errors.js"; +import { resolveEffectiveOrg } from "../../lib/region.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { setOrgProjectContext } from "../../lib/telemetry.js"; import type { SentryFeedback } from "../../types/index.js"; import { buildCommandHint, resolveIssue } from "../issue/utils.js"; -/** Positional parameter for a Feedback numeric ID, short ID, or scoped ID. */ +/** Positional parameter for a Feedback selector or issue-style identifier. */ export const feedbackIdPositional = { kind: "tuple", parameters: [ { - placeholder: "org/project/feedback-id", + placeholder: "feedback", brief: - "Feedback ID: numeric ID, short ID, /SHORT-ID, or //", + "Feedback: @latest, numeric ID, short ID, /SHORT-ID, or //", parse: String, }, ], @@ -27,13 +37,63 @@ export type ResolvedFeedback = { feedback: SentryFeedback; }; +/** Resolve a supported Feedback selector inside the mandatory Feedback category. */ +async function resolveFeedbackSelector( + selector: IssueSelector, + explicitOrg: string | undefined, + cwd: string +): Promise { + if (selector !== "@latest") { + throw new ValidationError( + "Feedback only supports @latest; @most_frequent is not meaningful for Feedback.", + "feedback selector" + ); + } + + const org = explicitOrg + ? await resolveEffectiveOrg(explicitOrg) + : (await resolveOrg({ cwd }))?.org; + if (!org) { + throw new ContextError( + "Organization", + "sentry feedback view /@latest" + ); + } + + const { feedback } = await listFeedback(org, "", { + limit: 1, + status: "unresolved", + }); + const latest = feedback[0]; + if (!latest) { + throw new ResolutionError( + "Selector '@latest'", + "found no unresolved User Feedback", + `sentry feedback list ${org}/ --status all`, + ["The @latest selector only matches unresolved Feedback."] + ); + } + + setOrgProjectContext( + [org], + latest.project?.slug ? [latest.project.slug] : [] + ); + return { org, feedback: latest }; +} + /** - * Resolve an issue-style identifier and require the result to be User Feedback. + * Resolve an issue-style identifier or the newest unresolved Feedback via + * `@latest`, and require the result to remain inside the Feedback category. */ export async function resolveFeedback( feedbackArg: string, cwd: string ): Promise { + const parsed = parseIssueArg(feedbackArg); + if (parsed.type === "selector") { + return resolveFeedbackSelector(parsed.selector, parsed.org, cwd); + } + let resolved: Awaited>; try { resolved = await resolveIssue({ diff --git a/src/commands/feedback/view.ts b/src/commands/feedback/view.ts index b769ff1dc7..6d9b74b4c9 100644 --- a/src/commands/feedback/view.ts +++ b/src/commands/feedback/view.ts @@ -97,8 +97,10 @@ export const viewCommand = buildCommand({ docs: { brief: "View a User Feedback item", fullDescription: - "View modern User Feedback by numeric ID or short ID. The latest event, linked error, Session Replays, and attachment metadata are included when available.\n\n" + + "View modern User Feedback by ID or select the most recent unresolved Feedback with @latest. The latest event, linked error, Session Replays, and attachment metadata are included when available.\n\n" + "Feedback formats:\n" + + " @latest Most recent unresolved Feedback\n" + + " /@latest Most recent unresolved Feedback in an organization\n" + " Search accessible organizations\n" + " Resolve by numeric issue ID\n" + " / Explicit organization\n" + diff --git a/test/e2e/feedback.test.ts b/test/e2e/feedback.test.ts index db8baac9da..a3208b6866 100644 --- a/test/e2e/feedback.test.ts +++ b/test/e2e/feedback.test.ts @@ -20,6 +20,7 @@ import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; import { createSentryMockServer, TEST_FEEDBACK_ID, + TEST_FEEDBACK_LATEST_ORG, TEST_FEEDBACK_SHORT_ID, TEST_ORG, TEST_PROJECT, @@ -147,6 +148,46 @@ describe("sentry feedback list", () => { }); describe("sentry feedback view", () => { + test("resolves @latest with explicit and detected organization context", async () => { + await ctx.setAuthToken(TEST_TOKEN); + const defaultsResult = await ctx.run([ + "cli", + "defaults", + "org", + TEST_FEEDBACK_LATEST_ORG, + ]); + expect( + defaultsResult.exitCode, + defaultsResult.stderr + defaultsResult.stdout + ).toBe(0); + + for (const input of ["@latest", `${TEST_FEEDBACK_LATEST_ORG}/@latest`]) { + const result = await ctx.run([ + "feedback", + "view", + input, + "--json", + "--fields", + "id", + ]); + expect(result.exitCode, result.stderr + result.stdout).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ id: TEST_FEEDBACK_ID }); + } + }); + + test("rejects @most_frequent with a Feedback-specific error", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run([ + "feedback", + "view", + `${TEST_ORG}/@most_frequent`, + ]); + + expect(result.exitCode).toBe(EXIT.VALIDATION); + expect(result.stderr + result.stdout).toContain("only supports @latest"); + }); + test("returns flattened event, replay, and attachment data", async () => { await ctx.setAuthToken(TEST_TOKEN); diff --git a/test/mocks/routes.ts b/test/mocks/routes.ts index bf834fd9ec..bf4cc56f8f 100644 --- a/test/mocks/routes.ts +++ b/test/mocks/routes.ts @@ -34,6 +34,7 @@ export const TEST_EVENT_ID = "abc123def456abc123def456abc12345"; export const TEST_FEEDBACK_ID = "5146636313"; export const TEST_FEEDBACK_SHORT_ID = "TEST-PROJECT-2SDJ"; export const TEST_FEEDBACK_EVENT_ID = "feed123def456abc123def456abc1234"; +export const TEST_FEEDBACK_LATEST_ORG = "test-feedback-latest-org"; export const TEST_DSN = "https://abc123@o123.ingest.sentry.io/456789"; export const TEST_LOG_ID = "a0a1a2a3a4a5a6a7a8a9b0b1b2b3b4b5"; export const TEST_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; @@ -80,6 +81,74 @@ function feedbackLimitContextResponse( }; } +/** Validate the org-wide issue-index request made by `feedback view @latest`. */ +function feedbackLatestValidationResponse(url: URL): MockResponse | undefined { + const query = url.searchParams.get("query") ?? ""; + if ( + url.searchParams.get("limit") === "1" && + query.includes("status:unresolved") && + url.searchParams.get("sort") === "date" + ) { + return; + } + + return { + status: 400, + body: { detail: "Invalid @latest Feedback query" }, + }; +} + +/** Build the Feedback-specific response for the shared issue-index route. */ +function feedbackIssueIndexResponse( + url: URL, + orgSlug: string, + serverUrl: string +): MockResponse { + if (orgSlug === TEST_FEEDBACK_LATEST_ORG) { + const latestValidationResponse = feedbackLatestValidationResponse(url); + if (latestValidationResponse) { + return latestValidationResponse; + } + } + + const paginationResponse = feedbackLimitContextResponse(url, serverUrl); + if (paginationResponse) { + return paginationResponse; + } + + const query = url.searchParams.get("query") ?? ""; + if ( + query.includes("e2e-status-check") && + !query.includes("status:unresolved") + ) { + return { + status: 400, + body: { detail: "Missing default Feedback status filter" }, + }; + } + + return { body: feedbacksFixture }; +} + +/** Build the org issue-index response for issue and Feedback E2E tests. */ +function issueIndexResponse( + req: Request, + params: Record, + serverUrl: string +): MockResponse { + const supportedOrgs = [TEST_ORG, TEST_FEEDBACK_LATEST_ORG]; + if (!supportedOrgs.includes(params.orgSlug)) { + return { status: 404, body: notFoundFixture }; + } + + const url = new URL(req.url); + const query = url.searchParams.get("query") ?? ""; + if (query.includes("issue.category:feedback")) { + return feedbackIssueIndexResponse(url, params.orgSlug, serverUrl); + } + return { body: issuesFixture }; +} + export const apiRoutes: MockRoute[] = [ // User Regions (multi-region support) // Returns the mock server itself as the only region @@ -177,33 +246,7 @@ export const apiRoutes: MockRoute[] = [ { method: "GET", path: "/api/0/organizations/:orgSlug/issues/", - response: (req, params, serverUrl) => { - if (params.orgSlug === TEST_ORG) { - const url = new URL(req.url); - const query = url.searchParams.get("query") ?? ""; - if (query.includes("issue.category:feedback")) { - const paginationResponse = feedbackLimitContextResponse( - url, - serverUrl - ); - if (paginationResponse) { - return paginationResponse; - } - if ( - query.includes("e2e-status-check") && - !query.includes("status:unresolved") - ) { - return { - status: 400, - body: { detail: "Missing default Feedback status filter" }, - }; - } - return { body: feedbacksFixture }; - } - return { body: issuesFixture }; - } - return { status: 404, body: notFoundFixture }; - }, + response: issueIndexResponse, }, // Issues (legacy project-scoped endpoint) { From 8ec1feef31f4ec4ed5fd35b4e45ca867a5bdcf2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 21 Jul 2026 17:02:54 +0200 Subject: [PATCH 04/10] fix(feedback): handle numeric IDs and multiline previews --- src/lib/api/issues.ts | 2 ++ src/lib/formatters/feedback.ts | 2 +- src/lib/sentry-url-parser.ts | 5 +++++ test/e2e/feedback.test.ts | 19 +++++++++++++++++++ test/lib/formatters/feedback.test.ts | 20 ++++++++++++++++++++ test/lib/sentry-url-parser.test.ts | 10 ++++++++++ 6 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/lib/api/issues.ts b/src/lib/api/issues.ts index 9057444d11..ec2ac970a8 100644 --- a/src/lib/api/issues.ts +++ b/src/lib/api/issues.ts @@ -267,6 +267,8 @@ export async function listIssuesAllPages( // Keep the defensive overshoot branch for servers that ignore `limit`. if (allResults.length >= options.limit || !response.nextCursor) { if (allResults.length > options.limit) { + // The cursor resumes after the oversized page; exposing it would skip + // the trimmed items that the caller has not received. return { issues: allResults.slice(0, options.limit) }; } return { issues: allResults, nextCursor: response.nextCursor }; diff --git a/src/lib/formatters/feedback.ts b/src/lib/formatters/feedback.ts index 559e65e79a..3d72427660 100644 --- a/src/lib/formatters/feedback.ts +++ b/src/lib/formatters/feedback.ts @@ -31,7 +31,7 @@ import { formatRelativeTime } from "./time-utils.js"; const MAX_REPLAY_IDS_SHOWN = 3; const MAX_LIST_MESSAGE_LENGTH = 160; const COMPACT_LIST_BREAKPOINT = 100; -const LINE_BREAK_RE = /\n/; +const LINE_BREAK_RE = /\n/g; const C1_CSI_RE = /\u009b[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g; const FEEDBACK_UNSAFE_TERMINAL_RE = // biome-ignore lint/suspicious/noControlCharactersInRegex: terminal sanitization requires matching control characters diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index e6dc259c8c..122f44a6c6 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -158,6 +158,10 @@ function matchSubdomainTailPath( if (segments[0] === "share" && segments[1] === "issue" && segments[2]) { return { shareId: segments[2] }; } + // /feedback/?feedbackSlug={project}:{id} + if (segments[0] === "feedback" && segments.length === 1) { + return {}; + } // Bare org subdomain URL (no path segments) if (segments.length === 0) { return {}; @@ -271,6 +275,7 @@ function matchSharePath( * - `https://{org}.sentry.io/issues/{id}/events/{eventId}/` * - `https://{org}.sentry.io/dashboard/{id}/` * - `https://{org}.sentry.io/share/issue/{shareId}/` + * - `https://{org}.sentry.io/feedback/?feedbackSlug={project}:{id}` * * @param input - Raw string that may or may not be a URL * @returns Parsed components, or null if input is not a recognized Sentry URL diff --git a/test/e2e/feedback.test.ts b/test/e2e/feedback.test.ts index a3208b6866..23ed5df6b6 100644 --- a/test/e2e/feedback.test.ts +++ b/test/e2e/feedback.test.ts @@ -264,6 +264,25 @@ describe("sentry feedback view", () => { } }); + test("recovers organization context from a bare numeric Feedback ID", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run([ + "feedback", + "view", + TEST_FEEDBACK_ID, + "--json", + ]); + + expect(result.exitCode, result.stderr + result.stdout).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + id: TEST_FEEDBACK_ID, + org: TEST_ORG, + event: { groupID: TEST_FEEDBACK_ID }, + attachments: [{ name: "screenshot.png" }], + }); + }); + test("rejects ordinary issues with an issue-view hint", async () => { await ctx.setAuthToken(TEST_TOKEN); diff --git a/test/lib/formatters/feedback.test.ts b/test/lib/formatters/feedback.test.ts index e1c7ccad50..e3f0fb1d76 100644 --- a/test/lib/formatters/feedback.test.ts +++ b/test/lib/formatters/feedback.test.ts @@ -89,6 +89,26 @@ describe("formatFeedbackList", () => { expect(output).toContain("Unknown"); expect(output).not.toContain("Unread"); }); + + test("flattens every message line break in compact output", () => { + const savedColumns = process.stdout.columns; + process.stdout.columns = 60; + + try { + const output = formatFeedbackList({ + feedback: [feedback({ metadata: { message: "First\nSecond\nThird" } })], + hasMore: false, + hasPrev: false, + org: "test-org", + }); + + expect(output).toContain("First Second Third"); + expect(output).not.toContain("\nSecond"); + expect(output).not.toContain("\nThird"); + } finally { + process.stdout.columns = savedColumns; + } + }); }); describe("formatFeedbackView", () => { diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts index c2d8b1139e..bcf1a36c2d 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -420,6 +420,16 @@ describe("parseSentryUrl", () => { }); }); + test("modern Feedback permalink returns its org", () => { + const result = parseSentryUrl( + "https://my-org.sentry.io/feedback/?feedbackSlug=my-project%3A5146636313" + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + }); + }); + test("hyphenated org slug", () => { const result = parseSentryUrl( "https://acme-corp.sentry.io/issues/12345/" From a1becebd8b0a1964065a45d2c8b231b9503cea95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 21 Jul 2026 17:05:42 +0200 Subject: [PATCH 05/10] fix(issue): keep selector recovery in issue domain --- src/commands/issue/utils.ts | 4 ++-- test/commands/issue/utils.test.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index c2bc1d0d8e..aedaca441c 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -448,7 +448,7 @@ async function resolveSelector( cwd: string, commandContext: IssueCommandContext ): Promise { - const { commandBase, commandHint } = commandContext; + const { commandHint } = commandContext; // Resolve org: explicit from `org/@latest` or auto-detected from DSN/defaults let orgSlug: string; if (explicitOrg) { @@ -478,7 +478,7 @@ async function resolveSelector( throw new ResolutionError( `Selector '${selector}'`, "no unresolved issues found", - `${commandBase} list ${orgSlug}/ -q "is:resolved"`, + `sentry issue list ${orgSlug}/ -q "is:resolved"`, [`The ${label} issue selector only matches unresolved issues.`] ); } diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index 13318fd98a..2a2b946959 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -2015,6 +2015,17 @@ describe("resolveOrgAndIssueId: magic @ selectors", () => { expect(String(err)).toContain("most recent"); expect(String(err)).toContain("sentry issue list"); expect(String(err)).toContain('-q "is:resolved"'); + + const eventError = await resolveIssue({ + issueArg: "@latest", + cwd: getConfigDir(), + command: "list", + commandBase: "sentry event", + }).catch((error) => error); + expect(eventError).toBeInstanceOf(ResolutionError); + expect(eventError.hint).toBe( + 'sentry issue list test-org/ -q "is:resolved"' + ); }); test("throws ContextError when org cannot be resolved for bare @selector", async () => { From 03de549dd23791336d715b86af1d3599c8af9e50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 21 Jul 2026 17:13:36 +0200 Subject: [PATCH 06/10] fix(feedback): parse permalink identifiers --- src/lib/sentry-url-parser.ts | 40 +++++++++++++++++++++++------- test/e2e/feedback.test.ts | 3 ++- test/lib/sentry-url-parser.test.ts | 16 ++++++++++++ 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index 122f44a6c6..fb77172d72 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -15,6 +15,8 @@ import { tryNormalizeHexId } from "./hex-id.js"; import { isSaaSTrustOrigin } from "./sentry-urls.js"; import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; +const FEEDBACK_SLUG_RE = /^([a-z0-9][a-z0-9_-]*):(\d+)$/; + /** * Components extracted from a Sentry web URL. * @@ -117,7 +119,8 @@ function matchSettingsPath( * or null if no pattern matches. */ function matchSubdomainPath( - segments: string[] + segments: string[], + searchParams: URLSearchParams ): Omit | null { // /issues/{id}/ (optionally with /events/{eventId}/) if (segments[0] === "issues" && segments[1]) { @@ -140,11 +143,12 @@ function matchSubdomainPath( if (replayPath.status === "list") { return {}; } - return matchSubdomainTailPath(segments); + return matchSubdomainTailPath(segments, searchParams); } function matchSubdomainTailPath( - segments: string[] + segments: string[], + searchParams: URLSearchParams ): Omit | null { // /settings/projects/{project}/ (org-scoped subdomain settings URL) if (segments[0] === "settings" && segments[1] === "projects" && segments[2]) { @@ -158,9 +162,9 @@ function matchSubdomainTailPath( if (segments[0] === "share" && segments[1] === "issue" && segments[2]) { return { shareId: segments[2] }; } - // /feedback/?feedbackSlug={project}:{id} - if (segments[0] === "feedback" && segments.length === 1) { - return {}; + const feedbackPath = matchFeedbackSubdomainPath(segments, searchParams); + if (feedbackPath) { + return feedbackPath; } // Bare org subdomain URL (no path segments) if (segments.length === 0) { @@ -169,6 +173,23 @@ function matchSubdomainTailPath( return null; } +/** Match `/feedback/?feedbackSlug={project}:{id}` on an org subdomain. */ +function matchFeedbackSubdomainPath( + segments: string[], + searchParams: URLSearchParams +): Omit | null { + if (segments[0] !== "feedback" || segments.length !== 1) { + return null; + } + + const feedbackSlug = searchParams.get("feedbackSlug"); + const match = feedbackSlug?.match(FEEDBACK_SLUG_RE); + if (!match) { + return {}; + } + return { project: match[1], issueId: match[2] }; +} + function matchReplayPath( segments: string[], startIndex: number @@ -212,7 +233,8 @@ function matchReplayPath( function matchSubdomainOrg( baseUrl: string, hostname: string, - segments: string[] + segments: string[], + searchParams: URLSearchParams ): ParsedSentryUrl | null { // Must be a subdomain of sentry.io (e.g., "my-org.sentry.io") if (!hostname.endsWith(`.${DEFAULT_SENTRY_HOST}`)) { @@ -227,7 +249,7 @@ function matchSubdomainOrg( return null; } - const pathResult = matchSubdomainPath(segments); + const pathResult = matchSubdomainPath(segments, searchParams); if (!pathResult) { return null; } @@ -299,7 +321,7 @@ export function parseSentryUrl(input: string): ParsedSentryUrl | null { return ( matchOrganizationsPath(baseUrl, segments) ?? matchSettingsPath(baseUrl, segments) ?? - matchSubdomainOrg(baseUrl, url.hostname, segments) ?? + matchSubdomainOrg(baseUrl, url.hostname, segments, url.searchParams) ?? matchSharePath(baseUrl, segments) ); } diff --git a/test/e2e/feedback.test.ts b/test/e2e/feedback.test.ts index 23ed5df6b6..f922c5d6a3 100644 --- a/test/e2e/feedback.test.ts +++ b/test/e2e/feedback.test.ts @@ -242,13 +242,14 @@ describe("sentry feedback view", () => { expect(JSON.parse(aliasResult.stdout)).toEqual({ id: TEST_FEEDBACK_ID }); }); - test("accepts numeric, bare short, and fully scoped IDs", async () => { + test("accepts issue-style identifiers and modern Feedback URLs", async () => { await ctx.setAuthToken(TEST_TOKEN); const inputs = [ TEST_FEEDBACK_ID, TEST_FEEDBACK_SHORT_ID, `${TEST_ORG}/${TEST_PROJECT}/2SDJ`, + `https://${TEST_ORG}.sentry.io/feedback/?feedbackSlug=${TEST_PROJECT}%3A${TEST_FEEDBACK_ID}`, ]; for (const input of inputs) { const result = await ctx.run([ diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts index bcf1a36c2d..639dc9bf62 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -424,6 +424,22 @@ describe("parseSentryUrl", () => { const result = parseSentryUrl( "https://my-org.sentry.io/feedback/?feedbackSlug=my-project%3A5146636313" ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + project: "my-project", + issueId: "5146636313", + }); + }); + + test.each([ + "my-project%3Aa%3A5146636313", + "my-project%3Aabc%2Fdef", + "my-project%250A%3A5146636313", + ])("does not extract malformed Feedback slug %s", (feedbackSlug) => { + const result = parseSentryUrl( + `https://my-org.sentry.io/feedback/?feedbackSlug=${feedbackSlug}` + ); expect(result).toEqual({ baseUrl: "https://my-org.sentry.io", org: "my-org", From fcbf50f2e2804f404c289573535b6f9391df412e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 21 Jul 2026 17:28:24 +0200 Subject: [PATCH 07/10] refactor(feedback): reuse list JSON transform --- src/commands/feedback/list.ts | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/commands/feedback/list.ts b/src/commands/feedback/list.ts index d4d7e14ca9..78271e04f2 100644 --- a/src/commands/feedback/list.ts +++ b/src/commands/feedback/list.ts @@ -19,7 +19,6 @@ import { } from "../../lib/db/pagination.js"; import { toSearchQueryError } from "../../lib/errors.js"; import { formatFeedbackList } from "../../lib/formatters/feedback.js"; -import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { appendQueryHint, @@ -31,6 +30,7 @@ import { paginationHint, targetPatternExplanation, } from "../../lib/list-command.js"; +import { jsonTransformListResult } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { type ResolvedOrgOptionalProject, @@ -118,18 +118,15 @@ function jsonTransformFeedbackList( result: FeedbackListResult, fields?: string[] ): unknown { - const items = fields?.length - ? result.feedback.map((item) => filterFields(item, fields)) - : result.feedback; - const envelope: Record = { - data: items, - hasMore: result.hasMore, - hasPrev: result.hasPrev, - }; - if (result.nextCursor) { - envelope.nextCursor = result.nextCursor; - } - return envelope; + return jsonTransformListResult( + { + items: result.feedback, + hasMore: result.hasMore, + hasPrev: result.hasPrev, + nextCursor: result.nextCursor, + }, + fields + ); } export const listCommand = buildListCommand("feedback", { From 7901a551eb3d200bf9be7766efb644f6960d7f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 21 Jul 2026 17:40:21 +0200 Subject: [PATCH 08/10] fix(feedback): parse self-hosted permalinks --- src/lib/sentry-url-parser.ts | 25 ++++++++++++++++++------- test/lib/sentry-url-parser.test.ts | 12 ++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index fb77172d72..44942c7632 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -15,7 +15,7 @@ import { tryNormalizeHexId } from "./hex-id.js"; import { isSaaSTrustOrigin } from "./sentry-urls.js"; import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; -const FEEDBACK_SLUG_RE = /^([a-z0-9][a-z0-9_-]*):(\d+)$/; +const FEEDBACK_SLUG_RE = /^([a-z0-9][a-z0-9_-]*):(\d+)$/i; /** * Components extracted from a Sentry web URL. @@ -53,7 +53,8 @@ export type ParsedSentryUrl = { */ function matchOrganizationsPath( baseUrl: string, - segments: string[] + segments: string[], + searchParams: URLSearchParams ): ParsedSentryUrl | null { if (segments[0] !== "organizations" || !segments[1]) { return null; @@ -81,6 +82,11 @@ function matchOrganizationsPath( return null; } + const feedbackPath = matchFeedbackPath(segments, 2, searchParams); + if (feedbackPath) { + return { baseUrl, org, ...feedbackPath }; + } + // /organizations/{org}/dashboard/{id}/ if (segments[2] === "dashboard" && segments[3]) { return { baseUrl, org, dashboardId: segments[3] }; @@ -162,7 +168,7 @@ function matchSubdomainTailPath( if (segments[0] === "share" && segments[1] === "issue" && segments[2]) { return { shareId: segments[2] }; } - const feedbackPath = matchFeedbackSubdomainPath(segments, searchParams); + const feedbackPath = matchFeedbackPath(segments, 0, searchParams); if (feedbackPath) { return feedbackPath; } @@ -173,12 +179,16 @@ function matchSubdomainTailPath( return null; } -/** Match `/feedback/?feedbackSlug={project}:{id}` on an org subdomain. */ -function matchFeedbackSubdomainPath( +/** Match a Feedback detail path and extract its project slug and group ID. */ +function matchFeedbackPath( segments: string[], + startIndex: number, searchParams: URLSearchParams ): Omit | null { - if (segments[0] !== "feedback" || segments.length !== 1) { + if ( + segments[startIndex] !== "feedback" || + segments.length !== startIndex + 1 + ) { return null; } @@ -286,6 +296,7 @@ function matchSharePath( * - `/organizations/{org}/explore/replays/{replayId}/` * - `/organizations/{org}/replays/{replayId}/` * - `/organizations/{org}/dashboard/{id}/` + * - `/organizations/{org}/feedback/?feedbackSlug={project}:{id}` * - `/organizations/{org}/` * - `/share/issue/{shareId}/` * @@ -319,7 +330,7 @@ export function parseSentryUrl(input: string): ParsedSentryUrl | null { const segments = url.pathname.split("/").filter(Boolean); return ( - matchOrganizationsPath(baseUrl, segments) ?? + matchOrganizationsPath(baseUrl, segments, url.searchParams) ?? matchSettingsPath(baseUrl, segments) ?? matchSubdomainOrg(baseUrl, url.hostname, segments, url.searchParams) ?? matchSharePath(baseUrl, segments) diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts index 639dc9bf62..d70f92e2ee 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -58,6 +58,18 @@ describe("parseSentryUrl", () => { }); }); + test("self-hosted Feedback permalink supports legacy mixed-case project slugs", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/acme-corp/feedback/?feedbackSlug=Legacy_Project%3A5146636313" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme-corp", + project: "Legacy_Project", + issueId: "5146636313", + }); + }); + test("strips trailing path segments after org", () => { // /organizations/{org}/ with no further recognized segments → org only const result = parseSentryUrl("https://sentry.io/organizations/my-org/"); From 16c5d6c7983083f35fcbf4944c04d7ffdc437525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 21 Jul 2026 17:46:54 +0200 Subject: [PATCH 09/10] fix(feedback): harden pagination edge cases --- src/lib/api/events.ts | 9 ++++++++- src/lib/formatters/feedback.ts | 4 +++- test/lib/api-client.coverage.test.ts | 1 + test/lib/formatters/feedback.test.ts | 11 +++++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts index 35b3c16e34..ba2297ed0a 100644 --- a/src/lib/api/events.ts +++ b/src/lib/api/events.ts @@ -92,6 +92,13 @@ export async function listEventAttachments( const config = await getOrgSdkConfig(orgSlug); const { data } = await autoPaginate( async (cursor) => { + // The endpoint accepts `per_page`, but its generated SDK type omits it. + // Keeping the query in a variable permits the server-supported parameter + // without weakening the generated client type. + const query = { + per_page: API_MAX_PER_PAGE, + ...(cursor ? { cursor } : {}), + }; const result = await listProjectEventAttachments({ ...config, path: { @@ -99,7 +106,7 @@ export async function listEventAttachments( project_id_or_slug: projectSlug, event_id: eventId, }, - ...(cursor ? { query: { cursor } } : {}), + query, }); return unwrapPaginatedResult( result, diff --git a/src/lib/formatters/feedback.ts b/src/lib/formatters/feedback.ts index 3d72427660..acbe0922d9 100644 --- a/src/lib/formatters/feedback.ts +++ b/src/lib/formatters/feedback.ts @@ -218,7 +218,9 @@ function formatCompactFeedbackList( /** Format a page of Feedback as a terminal-width-aware table. */ export function formatFeedbackList(result: FeedbackListResult): string { if (result.feedback.length === 0) { - return result.hasMore ? "No feedback on this page." : "No feedback found."; + return result.hasMore || result.hasPrev + ? "No feedback on this page." + : "No feedback found."; } const scope = result.project diff --git a/test/lib/api-client.coverage.test.ts b/test/lib/api-client.coverage.test.ts index 61cd7897f8..7266cedb3d 100644 --- a/test/lib/api-client.coverage.test.ts +++ b/test/lib/api-client.coverage.test.ts @@ -994,6 +994,7 @@ describe("events.ts", () => { expect(url.pathname).toContain( "/projects/test-org/test-project/events/evt-abc/attachments/" ); + expect(url.searchParams.get("per_page")).toBe("100"); requestCount += 1; const firstPage = url.searchParams.get("cursor") === null; diff --git a/test/lib/formatters/feedback.test.ts b/test/lib/formatters/feedback.test.ts index e3f0fb1d76..ee6df8df47 100644 --- a/test/lib/formatters/feedback.test.ts +++ b/test/lib/formatters/feedback.test.ts @@ -90,6 +90,17 @@ describe("formatFeedbackList", () => { expect(output).not.toContain("Unread"); }); + test("describes an empty previous page as a page, not an empty result", () => { + const output = formatFeedbackList({ + feedback: [], + hasMore: false, + hasPrev: true, + org: "test-org", + }); + + expect(output).toBe("No feedback on this page."); + }); + test("flattens every message line break in compact output", () => { const savedColumns = process.stdout.columns; process.stdout.columns = 60; From a0f77cc61d5389cb6dd64e88c8d03b467eb0479f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 21 Jul 2026 21:08:17 +0200 Subject: [PATCH 10/10] fix(feedback): document initial priority --- src/types/feedback.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/types/feedback.ts b/src/types/feedback.ts index 4dfcf7e499..49551af010 100644 --- a/src/types/feedback.ts +++ b/src/types/feedback.ts @@ -20,6 +20,8 @@ export type FeedbackMetadata = { title?: string; /** Search/display value generated by Sentry. */ value?: string; + /** Initial numeric priority assigned when the Feedback issue was created. */ + initial_priority?: number; /** Capture source such as `new_feedback_envelope`. */ source?: string | null; /** AI-generated feedback summary, when available. */ @@ -96,6 +98,10 @@ export const FeedbackMetadataSchema = z name: z.string().nullable().optional().describe("End-user display name"), title: z.string().optional().describe("Feedback title"), value: z.string().optional().describe("Feedback display value"), + initial_priority: z + .number() + .optional() + .describe("Initial priority assigned when the feedback was created"), source: z .string() .nullable()