Skip to content

Commit f210c1e

Browse files
betegoncodex
andauthored
feat(cli): add feedback resolve command (#1366)
Adds `sentry feedback resolve <feedback>` to mark modern User Feedback as resolved. It accepts the same IDs, URLs, and `@latest` selector as `feedback view`, verifies the Feedback category before changing status, and returns the updated issue with standard `--json`/`--fields` support. Reuses the existing issue resolution and status API helpers, matching the Feedback UI's immediate resolution behavior. Reads bypass the response cache so `@latest` cannot select an outdated cached item. Includes command docs, generated agent references, and regression coverage for category rejection, API failures, request payloads, and fresh selection. Typecheck, lint, CLI policy checks, native CLI build, docs build, and Feedback E2E pass, including E2E against the built binary. The CLI suite passes 10,184 tests with one existing macOS failure: the unchanged musl installer test assumes Linux. The remaining MCP suites pass. No live Feedback was modified during validation. Refs getsentry/cli#1262. This PR adds only `resolve`; the remaining commands stay separate. Co-authored-by: Codex CLI Agent <noreply@openai.com>
1 parent 56ae6b9 commit f210c1e

13 files changed

Lines changed: 442 additions & 17 deletions

File tree

‎apps/cli-docs/src/content/docs/contributing.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ toolkit/
6666
│ │ │ ├── debug-files/ # bundle-jvm, bundle-sources, check, find, print-sources, upload
6767
│ │ │ ├── docs/ # list, query
6868
│ │ │ ├── event/ # list, send, view
69-
│ │ │ ├── feedback/ # list, view
69+
│ │ │ ├── feedback/ # list, resolve, view
7070
│ │ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view
7171
│ │ │ ├── local/ # run, serve
7272
│ │ │ ├── log/ # list, view

‎apps/cli-docs/src/fragments/commands/feedback.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,20 @@ sentry feedback view my-org/FRONTEND-2SDJ --web
6060
The detail view includes the complete message and, when available, its latest
6161
event, linked error, Session Replays, and attachment metadata. If the supplied
6262
ID belongs to another issue category, use `sentry issue view` instead.
63+
64+
### Resolve User Feedback
65+
66+
```bash
67+
# Resolve a Feedback item immediately
68+
sentry feedback resolve my-org/FRONTEND-2SDJ
69+
70+
# Resolve the most recently active unresolved Feedback
71+
sentry feedback resolve my-org/@latest
72+
73+
# Return the updated Feedback, or select specific fields
74+
sentry feedback resolve my-org/FRONTEND-2SDJ --json --fields id,shortId,status
75+
```
76+
77+
`resolve` accepts the same IDs and URLs as `view` and reads the current state
78+
before selecting a Feedback item. It rejects other issue categories before
79+
making changes. JSON output contains the updated Feedback issue from Sentry.

‎packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,10 +620,11 @@ Query aggregate event data (Explore)
620620

621621
### Feedback
622622

623-
Search and inspect User Feedback
623+
Manage User Feedback
624624

625625
- `sentry feedback list <org/project>` — List and search User Feedback
626626
- `sentry feedback view <feedback>` — View a User Feedback item
627+
- `sentry feedback resolve <feedback>` — Mark User Feedback as resolved
627628

628629
→ Full flags and examples: `references/feedback.md`
629630

‎packages/cli/plugins/sentry-cli/skills/sentry-cli/references/feedback.md‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
---
22
name: sentry-cli-feedback
33
version: 0.47.0-dev.0
4-
description: Search and inspect User Feedback
4+
description: Manage User Feedback
55
requires:
66
bins: ["sentry"]
77
auth: true
88
---
99

1010
# Feedback Commands
1111

12-
Search and inspect User Feedback
12+
Manage User Feedback
1313

1414
### `sentry feedback list <org/project>`
1515

@@ -134,4 +134,49 @@ sentry feedback show my-org/FRONTEND-2SDJ
134134
sentry feedback view my-org/FRONTEND-2SDJ --web
135135
```
136136

137+
### `sentry feedback resolve <feedback>`
138+
139+
Mark User Feedback as resolved
140+
141+
**JSON Fields** (use `--json --fields` to select specific fields):
142+
143+
| Field | Type | Description |
144+
|-------|------|-------------|
145+
| `id` | string | Numeric issue ID |
146+
| `shortId` | string | Human-readable short ID (e.g. PROJ-ABC) |
147+
| `title` | string | Issue title |
148+
| `culprit` | string \| null | Culprit string |
149+
| `count` | string | Total event count |
150+
| `userCount` | number | Number of affected users |
151+
| `firstSeen` | string \| null | First occurrence (ISO 8601) |
152+
| `lastSeen` | string \| null | Most recent occurrence (ISO 8601) |
153+
| `level` | string | Severity level |
154+
| `status` | string | Issue status |
155+
| `permalink` | string | URL to the issue in Sentry |
156+
| `project` | object | Project info |
157+
| `metadata` | object | Feedback metadata |
158+
| `assignedTo` | object \| null | Assigned user or team |
159+
| `priority` | string | Triage priority |
160+
| `platform` | string | Platform |
161+
| `substatus` | string \| null | Issue substatus |
162+
| `isUnhandled` | boolean | Whether the issue is unhandled |
163+
| `seerFixabilityScore` | number \| null | Seer AI fixability score (0-1) |
164+
| `issueCategory` | string | Issue category discriminator |
165+
| `issueType` | string | Issue type discriminator |
166+
| `hasSeen` | boolean | Whether the feedback has been read |
167+
| `latestEventHasAttachments` | boolean | Whether the latest event has attachments |
168+
169+
**Examples:**
170+
171+
```bash
172+
# Resolve a Feedback item immediately
173+
sentry feedback resolve my-org/FRONTEND-2SDJ
174+
175+
# Resolve the most recently active unresolved Feedback
176+
sentry feedback resolve my-org/@latest
177+
178+
# Return the updated Feedback, or select specific fields
179+
sentry feedback resolve my-org/FRONTEND-2SDJ --json --fields id,shortId,status
180+
```
181+
137182
All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags.
Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,29 @@
11
/**
22
* sentry feedback
33
*
4-
* Search and inspect modern Sentry User Feedback.
4+
* Search, inspect, and manage modern Sentry User Feedback.
55
*/
66

77
import { buildRouteMap } from "../../lib/route-map.js";
88
import { listCommand } from "./list.js";
9+
import { resolveCommand } from "./resolve.js";
910
import { viewCommand } from "./view.js";
1011

1112
export const feedbackRoute = buildRouteMap({
1213
routes: {
1314
list: listCommand,
1415
view: viewCommand,
16+
resolve: resolveCommand,
1517
},
1618
defaultCommand: "view",
1719
docs: {
18-
brief: "Search and inspect User Feedback",
20+
brief: "Manage User Feedback",
1921
fullDescription:
20-
"Search and inspect modern User Feedback from your Sentry organization.\n\n" +
22+
"Search, inspect, and manage modern User Feedback from your Sentry organization.\n\n" +
2123
"Commands:\n" +
22-
" list List and search feedback\n" +
23-
" view View feedback with its latest event context",
24+
" list List and search feedback\n" +
25+
" view View feedback with its latest event context\n" +
26+
" resolve Mark feedback as resolved",
2427
hideRoute: {},
2528
},
2629
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Resolve modern User Feedback through the Issues API after checking its category.
3+
*/
4+
5+
import type { SentryContext } from "../../context.js";
6+
import { updateIssueStatus } from "../../lib/api-client.js";
7+
import { buildCommand } from "../../lib/command.js";
8+
import { formatResolvedFeedback } from "../../lib/formatters/feedback.js";
9+
import { CommandOutput } from "../../lib/formatters/output.js";
10+
import { disableResponseCache } from "../../lib/response-cache.js";
11+
import { SentryFeedbackSchema } from "../../types/index.js";
12+
import { feedbackIdPositional, resolveFeedback } from "./utils.js";
13+
14+
export const resolveCommand = buildCommand({
15+
docs: {
16+
brief: "Mark User Feedback as resolved",
17+
fullDescription:
18+
"Resolve a User Feedback item immediately.\n\n" +
19+
"Accepts the same IDs and URLs as sentry feedback view. Use @latest to resolve the most recently active unresolved Feedback.\n\n" +
20+
"The resolved issue must have issue.category:feedback. Other issue categories are rejected before any changes are made.\n\n" +
21+
"Examples:\n" +
22+
" sentry feedback resolve FRONTEND-2SDJ\n" +
23+
" sentry feedback resolve my-org/FRONTEND-2SDJ\n" +
24+
" sentry feedback resolve my-org/@latest",
25+
},
26+
output: {
27+
human: formatResolvedFeedback,
28+
schema: SentryFeedbackSchema,
29+
},
30+
parameters: {
31+
positional: feedbackIdPositional,
32+
},
33+
async *func(this: SentryContext, _flags, feedbackArg: string) {
34+
// A cached inbox could select Feedback that has already been resolved elsewhere.
35+
disableResponseCache();
36+
const { org, feedback } = await resolveFeedback(
37+
feedbackArg,
38+
this.cwd,
39+
"resolve"
40+
);
41+
const updated = await updateIssueStatus(feedback.id, "resolved", {
42+
orgSlug: org,
43+
});
44+
45+
yield new CommandOutput(updated);
46+
},
47+
});

‎packages/cli/src/commands/feedback/utils.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export type ResolvedFeedback = {
4141
async function resolveFeedbackSelector(
4242
selector: IssueSelector,
4343
explicitOrg: string | undefined,
44-
cwd: string
44+
cwd: string,
45+
command: string
4546
): Promise<ResolvedFeedback> {
4647
if (selector !== "@latest") {
4748
throw new ValidationError(
@@ -56,7 +57,7 @@ async function resolveFeedbackSelector(
5657
if (!org) {
5758
throw new ContextError(
5859
"Organization",
59-
"sentry feedback view <org>/@latest"
60+
`sentry feedback ${command} <org>/@latest`
6061
);
6162
}
6263

@@ -84,30 +85,32 @@ async function resolveFeedbackSelector(
8485
/**
8586
* Resolve an issue-style identifier or the newest unresolved Feedback via
8687
* `@latest`, and require the result to remain inside the Feedback category.
88+
* The command name keeps recovery hints aligned with the caller's operation.
8789
*/
8890
export async function resolveFeedback(
8991
feedbackArg: string,
90-
cwd: string
92+
cwd: string,
93+
command = "view"
9194
): Promise<ResolvedFeedback> {
9295
const parsed = parseIssueArg(feedbackArg);
9396
if (parsed.type === "selector") {
94-
return resolveFeedbackSelector(parsed.selector, parsed.org, cwd);
97+
return resolveFeedbackSelector(parsed.selector, parsed.org, cwd, command);
9598
}
9699

97100
let resolved: Awaited<ReturnType<typeof resolveIssue>>;
98101
try {
99102
resolved = await resolveIssue({
100103
issueArg: feedbackArg,
101104
cwd,
102-
command: "view",
105+
command,
103106
commandBase: "sentry feedback",
104107
});
105108
} catch (error) {
106109
if (error instanceof ApiError && error.status === 404) {
107110
throw new ResolutionError(
108111
`Feedback '${feedbackArg}'`,
109112
"not found",
110-
buildCommandHint("view", feedbackArg, "sentry feedback"),
113+
buildCommandHint(command, feedbackArg, "sentry feedback"),
111114
["List available Feedback: sentry feedback list"]
112115
);
113116
}

‎packages/cli/src/lib/formatters/feedback.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
FeedbackViewResult,
1313
SentryEvent,
1414
SentryFeedback,
15+
SentryIssue,
1516
} from "../../types/index.js";
1617
import { getReplayIdFromEvent } from "../replay-search.js";
1718
import { formatEventDetails } from "./human.js";
@@ -37,6 +38,13 @@ const FEEDBACK_UNSAFE_TERMINAL_RE =
3738
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal sanitization requires matching control characters
3839
/[\x00-\x09\x0b\x0c\x0e-\x1f\x7f-\x9f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
3940

41+
/** Confirm resolution using the updated Feedback's terminal-safe identifier. */
42+
export function formatResolvedFeedback(feedback: SentryIssue): string {
43+
return renderMarkdown(
44+
`Resolved feedback ${feedbackCodeSpan(feedback.shortId)}.`
45+
);
46+
}
47+
4048
/** Remove terminal controls while preserving intentional message line breaks. */
4149
function sanitizeFeedbackText(value: string): string {
4250
return stripAnsi(value)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/** Tests the Feedback category boundary and resolution output contract. */
2+
3+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4+
import { resolveCommand } from "../../../src/commands/feedback/resolve.js";
5+
import { ApiError } from "../../../src/lib/errors.js";
6+
import { resetCacheState } from "../../../src/lib/response-cache.js";
7+
import type { SentryFeedback } from "../../../src/types/index.js";
8+
9+
vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => {
10+
const actual =
11+
await importOriginal<
12+
typeof import("../../../src/commands/issue/utils.js")
13+
>();
14+
return { ...actual, resolveIssue: vi.fn() };
15+
});
16+
17+
vi.mock("../../../src/lib/api-client.js", async (importOriginal) => {
18+
const actual =
19+
await importOriginal<typeof import("../../../src/lib/api-client.js")>();
20+
return { ...actual, updateIssueStatus: vi.fn() };
21+
});
22+
23+
import { resolveIssue } from "../../../src/commands/issue/utils.js";
24+
import { updateIssueStatus } from "../../../src/lib/api-client.js";
25+
26+
function feedback(): SentryFeedback {
27+
return {
28+
id: "123",
29+
shortId: "TEST-PROJECT-1A",
30+
title: "User Feedback",
31+
issueCategory: "feedback",
32+
issueType: "feedback",
33+
status: "unresolved",
34+
metadata: { message: "Checkout is broken" },
35+
};
36+
}
37+
38+
function createMockContext() {
39+
const stdoutWrite = vi.fn(() => true);
40+
return {
41+
context: {
42+
stdout: { write: stdoutWrite },
43+
stderr: { write: vi.fn(() => true) },
44+
cwd: "/tmp",
45+
},
46+
output: () => stdoutWrite.mock.calls.map((call) => call[0]).join(""),
47+
};
48+
}
49+
50+
describe("feedback resolve", () => {
51+
beforeEach(() => {
52+
vi.mocked(resolveIssue).mockResolvedValue({
53+
org: "test-org",
54+
issue: feedback(),
55+
});
56+
vi.mocked(updateIssueStatus).mockResolvedValue({
57+
...feedback(),
58+
status: "resolved",
59+
metadata: { message: "Updated by the server" },
60+
});
61+
});
62+
63+
afterEach(() => {
64+
vi.resetAllMocks();
65+
resetCacheState();
66+
});
67+
68+
test("resolves the checked Feedback and emits the server response as JSON", async () => {
69+
const { context, output } = createMockContext();
70+
const func = await resolveCommand.loader();
71+
await func.call(context, { json: true }, "TEST-PROJECT-1A");
72+
73+
expect(resolveIssue).toHaveBeenCalledWith({
74+
issueArg: "TEST-PROJECT-1A",
75+
cwd: "/tmp",
76+
command: "resolve",
77+
commandBase: "sentry feedback",
78+
});
79+
expect(updateIssueStatus).toHaveBeenCalledExactlyOnceWith(
80+
"123",
81+
"resolved",
82+
{ orgSlug: "test-org" }
83+
);
84+
expect(JSON.parse(output())).toEqual({
85+
...feedback(),
86+
status: "resolved",
87+
metadata: { message: "Updated by the server" },
88+
});
89+
});
90+
91+
test("rejects an ordinary issue before any status mutation", async () => {
92+
vi.mocked(resolveIssue).mockResolvedValue({
93+
org: "test-org",
94+
issue: { ...feedback(), issueCategory: "error", issueType: "error" },
95+
});
96+
const { context, output } = createMockContext();
97+
const func = await resolveCommand.loader();
98+
99+
await expect(
100+
func.call(context, { json: false }, "TEST-PROJECT-1A")
101+
).rejects.toMatchObject({
102+
name: "ResolutionError",
103+
hint: "sentry issue view test-org/TEST-PROJECT-1A",
104+
});
105+
expect(updateIssueStatus).not.toHaveBeenCalled();
106+
expect(output()).toBe("");
107+
});
108+
109+
test("does not report success when the update fails", async () => {
110+
const error = new ApiError("Permission denied", 403);
111+
vi.mocked(updateIssueStatus).mockRejectedValue(error);
112+
const { context, output } = createMockContext();
113+
const func = await resolveCommand.loader();
114+
115+
await expect(
116+
func.call(context, { json: false }, "TEST-PROJECT-1A")
117+
).rejects.toBe(error);
118+
expect(output()).toBe("");
119+
});
120+
});

0 commit comments

Comments
 (0)