-
-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathissues.ts
More file actions
738 lines (694 loc) · 26.6 KB
/
issues.ts
File metadata and controls
738 lines (694 loc) · 26.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
/**
* Issue API functions
*
* Functions for listing, retrieving, and updating Sentry issues.
*/
import type { ListAnOrganizationSissuesData } from "@sentry/api";
import { listAnOrganization_sIssues } from "@sentry/api";
import type { SentryIssue } from "../../types/index.js";
import type { IssueSubstatus } from "../../types/sentry.js";
import {
buildTlsErrorDetail,
customFetch,
isTlsCertError,
warnIfSaasWithEnvCa,
} from "../custom-ca.js";
import { applyCustomHeaders } from "../custom-headers.js";
import { ApiError, ValidationError } from "../errors.js";
import { resolveOrgRegion } from "../region.js";
import { invalidateCachedResponsesMatching } from "../response-cache.js";
import { getApiBaseUrl } from "../sentry-client.js";
import {
API_MAX_PER_PAGE,
apiRequest,
apiRequestToRegion,
getOrgSdkConfig,
MAX_PAGINATION_PAGES,
type PaginatedResponse,
unwrapPaginatedResult,
} from "./infrastructure.js";
const TRAILING_SLASH_RE = /\/$/;
/**
* Sort options for issue listing, derived from the @sentry/api SDK types.
* Uses the SDK type directly for compile-time safety against parameter drift.
*/
export type IssueSort = NonNullable<
NonNullable<ListAnOrganizationSissuesData["query"]>["sort"]
>;
/**
* Collapse options for issue listing, derived from the @sentry/api SDK types.
* Each value tells the server to skip computing that data field, avoiding
* expensive Snuba/ClickHouse queries on the backend.
*
* - `'stats'` — time-series event counts (sparkline data)
* - `'lifetime'` — lifetime aggregate counts (count, userCount, firstSeen)
* - `'filtered'` — filtered aggregate counts
* - `'unhandled'` — unhandled event flag computation
* - `'base'` — base group fields (rarely useful to collapse)
*/
export type IssueCollapseField = NonNullable<
NonNullable<ListAnOrganizationSissuesData["query"]>["collapse"]
>[number];
/**
* Build the `collapse` parameter for issue list API calls.
*
* Always collapses fields the CLI never consumes in issue list:
* `filtered`, `lifetime`, `unhandled`. Conditionally collapses `stats`
* when sparklines won't be rendered (narrow terminal, non-TTY, or JSON).
*
* Matches the Sentry web UI's optimization: the initial page load sends
* `collapse=stats,unhandled` to skip expensive Snuba queries, fetching
* stats in a follow-up request only when needed.
*
* @param options - Context for determining what to collapse
* @param options.shouldCollapseStats - Whether stats data can be skipped
* (true when sparklines won't be shown: narrow terminal, non-TTY, --json)
* @returns Array of fields to collapse
*/
export function buildIssueListCollapse(options: {
shouldCollapseStats: boolean;
}): IssueCollapseField[] {
const collapse: IssueCollapseField[] = ["filtered", "lifetime", "unhandled"];
if (options.shouldCollapseStats) {
collapse.push("stats");
}
return collapse;
}
/**
* Collapse fields for single-issue detail endpoints.
*
* The CLI never displays stats (sparkline time-series), lifetime (aggregate
* sub-object), filtered (filtered counts), or unhandled (unhandled sub-object)
* in detail views (`issue view`, `issue explain`, `issue plan`).
* Collapsing these skips expensive Snuba queries, saving 100-300ms per request.
*
* Note: `count`, `userCount`, `firstSeen`, `lastSeen` are top-level fields
* and remain unaffected by collapsing.
*/
export const ISSUE_DETAIL_COLLAPSE: IssueCollapseField[] = [
"stats",
"lifetime",
"filtered",
"unhandled",
];
/**
* List issues for a project with pagination control.
*
* Uses the @sentry/api SDK's `listAnOrganization_sIssues` for type-safe
* query parameters, and extracts pagination from the response Link header.
*
* @param orgSlug - Organization slug
* @param projectSlug - Project slug (empty string for org-wide listing)
* @param options - Query and pagination options
* @returns Single page of issues with cursor metadata
*/
export async function listIssuesPaginated(
orgSlug: string,
projectSlug: string,
options: {
query?: string;
cursor?: string;
perPage?: number;
sort?: IssueSort;
statsPeriod?: string;
/** Numeric project ID. When provided, uses the `project` query param
* instead of `project:<slug>` search syntax, avoiding "not actively
* selected" errors. */
projectId?: number;
/** Controls the time resolution of inline stats data. "auto" adapts to statsPeriod. */
groupStatsPeriod?: "" | "14d" | "24h" | "auto";
/** Fields to collapse (omit) from the response for performance.
* @see {@link buildIssueListCollapse} */
collapse?: IssueCollapseField[];
/** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */
start?: string;
/** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */
end?: string;
} = {}
): Promise<PaginatedResponse<SentryIssue[]>> {
// When we have a numeric project ID, use the `project` query param (Array<number>)
// instead of `project:<slug>` in the search query. The API's `project` param
// selects the project directly, bypassing the "actively selected" requirement.
let projectFilter = "";
if (!options.projectId && projectSlug) {
projectFilter = `project:${projectSlug}`;
}
const fullQuery = [projectFilter, options.query].filter(Boolean).join(" ");
const config = await getOrgSdkConfig(orgSlug);
const result = await listAnOrganization_sIssues({
...config,
path: { organization_id_or_slug: orgSlug },
query: {
project: options.projectId ? [options.projectId] : undefined,
// Convert empty string to undefined so the SDK omits the param entirely;
// sending `query=` causes the Sentry API to behave differently than
// omitting the parameter.
query: fullQuery || undefined,
cursor: options.cursor,
limit: options.perPage ?? 25,
sort: options.sort,
statsPeriod: options.statsPeriod,
start: options.start,
end: options.end,
groupStatsPeriod: options.groupStatsPeriod,
collapse: options.collapse,
},
});
return unwrapPaginatedResult<SentryIssue[]>(
result as
| { data: SentryIssue[]; error: undefined }
| { data: undefined; error: unknown },
"Failed to list issues"
);
}
/** Result from {@link listIssuesAllPages}. */
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).
*/
nextCursor?: string;
};
/**
* Auto-paginate through issues up to the requested limit.
*
* The Sentry API caps `per_page` at {@link API_MAX_PER_PAGE} server-side. When the caller
* requests more than that, this function transparently fetches multiple
* pages using cursor-based pagination and returns the combined result.
*
* Safety-bounded by {@link MAX_PAGINATION_PAGES} to prevent runaway requests.
*
* @param orgSlug - Organization slug
* @param projectSlug - Project slug (empty string for org-wide)
* @param options - Query, sort, and limit options
* @returns Issues (up to `limit` items) and a cursor for the next page if available
*/
export async function listIssuesAllPages(
orgSlug: string,
projectSlug: string,
options: {
query?: string;
limit: number;
sort?: IssueSort;
statsPeriod?: string;
/** Numeric project ID for direct project selection via query param. */
projectId?: number;
/** Controls the time resolution of inline stats data. "auto" adapts to statsPeriod. */
groupStatsPeriod?: "" | "14d" | "24h" | "auto";
/** Resume pagination from this cursor instead of starting from the beginning. */
startCursor?: string;
/** Called after each page is fetched. Useful for progress indicators. */
onPage?: (fetched: number, limit: number) => void;
/** Fields to collapse (omit) from the response for performance.
* @see {@link buildIssueListCollapse} */
collapse?: IssueCollapseField[];
/** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */
start?: string;
/** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */
end?: string;
}
): Promise<IssuesPage> {
if (options.limit < 1) {
throw new Error(
`listIssuesAllPages: limit must be at least 1, got ${options.limit}`
);
}
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 response = await listIssuesPaginated(orgSlug, projectSlug, {
query: options.query,
cursor,
perPage,
sort: options.sort,
statsPeriod: options.statsPeriod,
start: options.start,
end: options.end,
projectId: options.projectId,
groupStatsPeriod: options.groupStatsPeriod,
collapse: options.collapse,
});
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
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) };
}
return { issues: allResults, nextCursor: response.nextCursor };
}
cursor = response.nextCursor;
}
// Safety limit reached — return what we have, no nextCursor
return { issues: allResults.slice(0, options.limit) };
}
/**
* Get a specific issue by numeric ID.
*
* Uses the legacy unscoped endpoint — no org context or region routing.
* Prefer {@link getIssueInOrg} when the org slug is known.
*
* @param issueId - Numeric issue ID
* @param options - Optional collapse fields to skip expensive backend queries
*/
export function getIssue(
issueId: string,
options?: { collapse?: IssueCollapseField[] }
): Promise<SentryIssue> {
return apiRequest<SentryIssue>(`/issues/${issueId}/`, {
params: options?.collapse ? { collapse: options.collapse } : undefined,
});
}
/**
* Get a specific issue by numeric ID, scoped to an organization.
*
* Uses the org-scoped endpoint with region-aware routing.
* Preferred over {@link getIssue} when the org slug is available.
*
* Uses raw `apiRequestToRegion` instead of the SDK's `retrieveAnIssue`
* because the SDK types declare `query?: never`, blocking `collapse`
* and other query parameters. See: https://github.com/getsentry/sentry-api-schema/issues/63
*
* @param orgSlug - Organization slug (used for region routing)
* @param issueId - Numeric issue ID
* @param options - Optional collapse fields to skip expensive backend queries
*/
export async function getIssueInOrg(
orgSlug: string,
issueId: string,
options?: { collapse?: IssueCollapseField[] }
): Promise<SentryIssue> {
const regionUrl = await resolveOrgRegion(orgSlug);
const { data } = await apiRequestToRegion<SentryIssue>(
regionUrl,
`/organizations/${orgSlug}/issues/${issueId}/`,
{
params: options?.collapse ? { collapse: options.collapse } : undefined,
}
);
return data;
}
/**
* Get an issue by short ID (e.g., SPOTLIGHT-ELECTRON-4D).
* Requires organization context to resolve the short ID.
* Uses region-aware routing for multi-region support.
*
* Uses raw `apiRequestToRegion` instead of the SDK's `resolveAShortId`
* because the SDK types declare `query?: never`, blocking `collapse`
* and other query parameters. See: https://github.com/getsentry/sentry-api-schema/
*
* @param orgSlug - Organization slug
* @param shortId - Short ID (e.g., "CLI-G5", "SPOTLIGHT-ELECTRON-4D")
* @param options - Optional collapse fields to skip expensive backend queries
*/
export async function getIssueByShortId(
orgSlug: string,
shortId: string,
options?: { collapse?: IssueCollapseField[] }
): Promise<SentryIssue> {
const normalizedShortId = shortId.toUpperCase();
const regionUrl = await resolveOrgRegion(orgSlug);
let data: { group?: SentryIssue };
try {
const result = await apiRequestToRegion<{ group?: SentryIssue }>(
regionUrl,
`/organizations/${orgSlug}/shortids/${normalizedShortId}/`,
{
params: options?.collapse ? { collapse: options.collapse } : undefined,
}
);
data = result.data;
} catch (error) {
// Enrich 404 errors with actionable context. The generic
// "Failed to resolve short ID: 404 Not Found" is the most common
// issue view error (CLI-A1, 27 users). Callers like
// tryGetIssueByShortId still catch ApiError by status code.
if (error instanceof ApiError && error.status === 404) {
throw new ApiError(
`Short ID '${normalizedShortId}' not found in organization '${orgSlug}'`,
404,
[
"The issue may have been deleted or merged",
`Verify the short ID and org: sentry issue view ${orgSlug}/${normalizedShortId}`,
`List issues in this org: sentry issue list ${orgSlug}/`,
].join("\n ")
);
}
throw error;
}
if (!data.group) {
throw new ApiError(
`Short ID ${normalizedShortId} resolved but no issue group returned`,
404,
"Issue not found"
);
}
return data.group;
}
/**
* Try to get an issue by short ID, returning null on 404.
*
* Same as {@link getIssueByShortId} but returns null instead of throwing
* when the short ID is not found. Useful for parallel fan-out across orgs
* where most will 404.
*
* @param orgSlug - Organization slug
* @param shortId - Full short ID (e.g., "CONSUMER-MOBILE-1QNEK")
* @returns The resolved issue, or null if not found in this org
*/
export async function tryGetIssueByShortId(
orgSlug: string,
shortId: string,
options?: { collapse?: IssueCollapseField[] }
): Promise<SentryIssue | null> {
try {
return await getIssueByShortId(orgSlug, shortId, options);
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return null;
}
throw error;
}
}
/**
* Resolution-release tracking for {@link updateIssueStatus}. Maps to the
* `statusDetails` shape expected by Sentry's bulk mutate endpoint:
*
* - `inRelease` — resolve in a specific named release (e.g. `"0.26.1"`).
* Future events seen on releases **after** this one will regression-flag.
* - `inNextRelease: true` — resolve in the next release after the current
* commit. Commonly used when the fix is merged but not yet tagged.
* - `inCommit` — resolve tied to a commit in a Sentry-registered repo.
* Sentry resolves when a release containing the commit is created. Both
* `commit` (SHA) and `repository` (Sentry-registered repo name) are
* required by the API's InCommitValidator.
*/
export type ResolveStatusDetails =
| { inRelease: string }
| { inNextRelease: true }
| { inCommit: { commit: string; repository: string } };
/**
* Sentinel string meaning "resolve in the next release (tied to HEAD)".
* Chosen to never clash with a real version string — `@` is not a valid
* character in semver or Sentry release slugs.
*/
export const RESOLVE_NEXT_RELEASE_SENTINEL = "@next";
/**
* Bare sentinel meaning "resolve at the current git HEAD, auto-detecting
* the Sentry-registered repo from the git origin URL". The command layer
* resolves this into a concrete `{inCommit: {...}}` before calling the API.
*/
export const RESOLVE_COMMIT_SENTINEL = "@commit";
/**
* Prefix for the explicit commit form: `@commit:<repo>@<sha>`.
* Kept unambiguous against monorepo release strings like `pkg@1.2.3` by
* requiring the full `@commit:` anchor at the start.
*/
export const RESOLVE_COMMIT_EXPLICIT_PREFIX = "@commit:";
/**
* Parsed representation of the `@commit` spec family — either an
* "auto-detect from HEAD" request or an explicit repo + SHA pair.
*
* Auto-detection needs git + an API call, so the CLI layer converts this
* into `ResolveStatusDetails` asynchronously via a separate resolver.
*/
export type ResolveCommitSpec =
| { kind: "auto" }
| { kind: "explicit"; repository: string; commit: string };
/**
* Parsed representation of the fully-static portion of the `--in` grammar.
* Static specs ({@link ResolveStatusDetails}) are ready to ship to the API;
* commit specs ({@link ResolveCommitSpec}) need further resolution through
* git and the Sentry repo list before they become `{inCommit: ...}`.
*/
export type ParsedResolveSpec =
| { kind: "static"; details: ResolveStatusDetails }
| { kind: "commit"; spec: ResolveCommitSpec };
/**
* Parse an `--in` resolution-spec string into its structured form.
*
* Grammar (see command docs):
*
* - `@next` → `{kind: "static", details: {inNextRelease: true}}`
* - `@commit` → `{kind: "commit", spec: {kind: "auto"}}`
* - `@commit:<repo>@<sha>` → `{kind: "commit", spec: {kind: "explicit", ...}}`
* - anything else → `{kind: "static", details: {inRelease: <value>}}`
*
* The explicit commit form requires a `<repo>@<sha>` payload after the
* `@commit:` anchor. Monorepo release strings like `pkg@1.2.3` are
* unambiguously treated as releases because they lack the `@commit:` prefix.
*
* Empty/whitespace-only input returns `null` (treated as "no spec" by the
* caller, which resolves immediately without release tracking).
*
* @throws {ValidationError} When `@commit:` prefix is given without a
* well-formed `<repo>@<sha>` payload.
*/
export function parseResolveSpec(
spec: string | undefined
): ParsedResolveSpec | null {
if (!spec) {
return null;
}
const trimmed = spec.trim();
if (!trimmed) {
return null;
}
// Sentinel matches are case-insensitive (`@NEXT`, `@Commit`, etc.) so
// typos and mixed-case variants don't silently fall through to
// inRelease. But the payload after `@commit:` keeps its original case,
// since repository names and SHAs are case-sensitive.
const lower = trimmed.toLowerCase();
if (lower === RESOLVE_NEXT_RELEASE_SENTINEL) {
return { kind: "static", details: { inNextRelease: true } };
}
if (lower === RESOLVE_COMMIT_SENTINEL) {
return { kind: "commit", spec: { kind: "auto" } };
}
if (lower.startsWith(RESOLVE_COMMIT_EXPLICIT_PREFIX)) {
const payload = trimmed.slice(RESOLVE_COMMIT_EXPLICIT_PREFIX.length);
// Split on the LAST `@` so repo names containing `@` (rare but legal
// for scoped npm-style names like `@acme/web`) resolve correctly.
const splitIdx = payload.lastIndexOf("@");
if (splitIdx <= 0 || splitIdx === payload.length - 1) {
throw new ValidationError(
`Invalid --in spec: '${spec}' — expected '${RESOLVE_COMMIT_EXPLICIT_PREFIX}<repo>@<sha>'.`,
"in"
);
}
const repository = payload.slice(0, splitIdx).trim();
const commit = payload.slice(splitIdx + 1).trim();
if (!(repository && commit)) {
throw new ValidationError(
`Invalid --in spec: '${spec}' — repo and SHA must both be non-empty.`,
"in"
);
}
return { kind: "commit", spec: { kind: "explicit", repository, commit } };
}
// Anything else starting with `@` is almost certainly a mistyped
// sentinel — reject with a clear message instead of silently creating
// a release named (e.g.) "@netx" that the user didn't intend.
if (trimmed.startsWith("@")) {
throw new ValidationError(
`Invalid --in spec: '${spec}' is not a recognized sentinel.\n\n` +
`Expected '${RESOLVE_NEXT_RELEASE_SENTINEL}', '${RESOLVE_COMMIT_SENTINEL}', or '${RESOLVE_COMMIT_EXPLICIT_PREFIX}<repo>@<sha>'.\n` +
"If you meant a literal release name, it cannot start with '@'.",
"in"
);
}
return { kind: "static", details: { inRelease: trimmed } };
}
/**
* Ignore/archive conditions for {@link updateIssueStatus}.
*
* - `ignoreDuration` — ignore for N minutes
* - `ignoreCount` / `ignoreWindow` — ignore until N events in M minutes
* - `ignoreUserCount` / `ignoreUserWindow` — ignore until N users in M minutes
*/
export type IgnoreStatusDetails = {
ignoreDuration?: number;
ignoreCount?: number;
ignoreWindow?: number;
ignoreUserCount?: number;
ignoreUserWindow?: number;
};
/**
* Update an issue's status.
*
* - `"resolved"` — optional `statusDetails` can pin the fix to a release
* or commit (see {@link ResolveStatusDetails}).
* - `"ignored"` — optional `substatus` controls archive granularity;
* optional `statusDetails` sets conditions (see {@link IgnoreStatusDetails}).
* - `"unresolved"` — reopens the issue; no additional options needed.
*
* When `options.orgSlug` is provided, the request is routed to that org's
* region via the org-scoped endpoint. Without it, falls back to the legacy
* global `/issues/{id}/` endpoint (works but not region-aware).
*/
export async function updateIssueStatus(
issueId: string,
status: "resolved" | "unresolved" | "ignored",
options?: {
statusDetails?: ResolveStatusDetails | IgnoreStatusDetails;
/** Substatus for archive granularity. */
substatus?: IssueSubstatus;
orgSlug?: string;
}
): Promise<SentryIssue> {
const body: Record<string, unknown> = { status };
if (options?.statusDetails) {
body.statusDetails = options.statusDetails;
}
if (options?.substatus) {
body.substatus = options.substatus;
}
if (options?.orgSlug) {
// Region-aware org-scoped endpoint — preferred when org is known.
const regionUrl = await resolveOrgRegion(options.orgSlug);
const { data } = await apiRequestToRegion<SentryIssue>(
regionUrl,
`/organizations/${encodeURIComponent(options.orgSlug)}/issues/${encodeURIComponent(issueId)}/`,
{ method: "PUT", body }
);
return data;
}
// Legacy global endpoint — works without org but not region-aware.
return apiRequest<SentryIssue>(`/issues/${encodeURIComponent(issueId)}/`, {
method: "PUT",
body,
});
}
/** Result of a successful issue-merge operation. */
export type MergeIssuesResult = {
/** Numeric group ID that the merged issues were consolidated into. */
parent: string;
/** Numeric group IDs that were merged into the parent (excludes parent). */
children: string[];
};
/**
* Merge multiple issues into a single canonical group.
*
* Sentry auto-picks the canonical parent (typically the largest by event
* count). Future events with fingerprints previously matching any of the
* children will flow into the parent group.
*
* @param orgSlug - Organization slug (required for the bulk mutate endpoint)
* @param groupIds - At least 2 numeric group IDs to merge
* @throws {ApiError} When fewer than 2 IDs are provided, or the API rejects
* (e.g. `"Only error issues can be merged."` for non-error issue types)
*/
export async function mergeIssues(
orgSlug: string,
groupIds: readonly string[]
): Promise<MergeIssuesResult> {
if (groupIds.length < 2) {
throw new ValidationError(
`Need at least 2 issues to merge (got ${groupIds.length}).`
);
}
// The bulk mutate endpoint accepts repeated `?id=X` query params plus a
// `{merge: 1}` body. The SDK wraps this but its typed `query` shape
// doesn't expose the array semantics cleanly, so use raw request.
const regionUrl = await resolveOrgRegion(orgSlug);
const query = groupIds.map((id) => `id=${encodeURIComponent(id)}`).join("&");
const path = `/organizations/${encodeURIComponent(orgSlug)}/issues/?${query}`;
type MergeResponse = { merge: MergeIssuesResult };
try {
const { data } = await apiRequestToRegion<MergeResponse>(regionUrl, path, {
method: "PUT",
body: { merge: 1 },
});
// HTTP-layer invalidation covers the region-scoped caches via the
// prefix sweep on `/organizations/{org}/issues/`, but it can't see
// the affected IDs (they're in query params, stripped from the
// URL the hook sees). Manually clear each affected issue's legacy
// cross-origin cache so subsequent `getIssue(id)` doesn't serve
// stale data.
const apiBase = getApiBaseUrl().replace(TRAILING_SLASH_RE, "");
const affectedIds = data.merge.children.toSpliced(0, 0, data.merge.parent);
await Promise.all(
affectedIds.map((id) =>
invalidateCachedResponsesMatching(
`${apiBase}/api/0/issues/${encodeURIComponent(id)}/`
)
)
);
return data.merge;
} catch (error) {
// The bulk-mutate endpoint returns 204 when no matching issues are
// found — e.g. IDs out of scope, or issues deleted between resolution
// and the merge call. Catch the generic "no body" ApiError and re-throw
// with a friendlier user-facing message.
if (error instanceof ApiError && error.status === 204) {
throw new ApiError(
`No matching issues found for merge in '${orgSlug}'.`,
204,
"All provided issue IDs are out of scope or no longer exist.",
path
);
}
throw error;
}
}
/**
* Resolve a share ID to basic issue data via the public share endpoint.
*
* This endpoint does not require authentication and is not org-scoped.
* The response includes the numeric `groupID` needed to fetch full issue
* details via the authenticated API.
*
* @param baseUrl - The Sentry instance base URL (from the share URL)
* @param shareId - The share ID extracted from the share URL
* @returns Object containing the numeric groupID
* @throws {ApiError} When the share link is expired, disabled, or invalid
*/
export async function getSharedIssue(
baseUrl: string,
shareId: string
): Promise<{ groupID: string }> {
const url = `${baseUrl}/api/0/shared/issues/${encodeURIComponent(shareId)}/`;
const headers = new Headers({ "Content-Type": "application/json" });
// URL-scoped: headers only attach when `url`'s origin matches the trusted
// host, so IAP tokens etc. can't leak to an attacker-controlled share URL.
applyCustomHeaders(headers, url);
warnIfSaasWithEnvCa(url);
let response: Response;
try {
response = await customFetch(url, { headers });
} catch (error) {
if (error instanceof Error && isTlsCertError(error)) {
throw new ApiError(
"TLS certificate error",
0,
buildTlsErrorDetail(error),
`shared/issues/${shareId}`
);
}
throw error;
}
if (!response.ok) {
if (response.status === 404) {
throw new ApiError(
"Share link not found or expired",
404,
"The share link may have been disabled by the issue owner.\n" +
" Ask them to re-enable sharing, or use the issue ID directly.",
`shared/issues/${shareId}`
);
}
throw new ApiError(
"Failed to resolve share link",
response.status,
undefined,
`shared/issues/${shareId}`
);
}
return (await response.json()) as { groupID: string };
}