@goliapkg/sentori-cli 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+
3
+ import { formatIssueLine, issueList, issuePatch } from '../issue.js'
4
+
5
+ const cfg = {
6
+ apiUrl: 'https://api.example.com/',
7
+ projectId: 'proj-1',
8
+ token: 'sk_test',
9
+ }
10
+
11
+ const sample = {
12
+ errorType: 'TypeError',
13
+ eventCount: 17,
14
+ id: 'issue-1',
15
+ lastSeen: '2026-05-13T00:00:00Z',
16
+ messageSample: 'undefined is not an object',
17
+ status: 'active' as const,
18
+ }
19
+
20
+ const origFetch = globalThis.fetch
21
+ let calls: { body: string | undefined; headers: Headers; method: string; url: string }[]
22
+ beforeEach(() => {
23
+ calls = []
24
+ })
25
+ afterEach(() => {
26
+ globalThis.fetch = origFetch
27
+ })
28
+
29
+ function mockFetch(respBody: string, init: ResponseInit = { status: 200 }): void {
30
+ globalThis.fetch = (async (url: Request | string | URL, opts?: RequestInit) => {
31
+ calls.push({
32
+ body: typeof opts?.body === 'string' ? opts.body : undefined,
33
+ headers: new Headers(opts?.headers),
34
+ method: opts?.method ?? 'GET',
35
+ url: String(url),
36
+ })
37
+ return new Response(respBody, {
38
+ headers: { 'content-type': 'application/json' },
39
+ ...init,
40
+ })
41
+ }) as typeof fetch
42
+ }
43
+
44
+ describe('issueList', () => {
45
+ test('GETs /admin/api/projects/<id>/issues with query params + Bearer', async () => {
46
+ mockFetch(JSON.stringify([sample]))
47
+ const rows = await issueList({ config: cfg, limit: 20, status: 'active' })
48
+ expect(calls).toHaveLength(1)
49
+ expect(calls[0]?.method).toBe('GET')
50
+ expect(calls[0]?.url).toBe(
51
+ 'https://api.example.com/admin/api/projects/proj-1/issues?status=active&limit=20',
52
+ )
53
+ expect(calls[0]?.headers.get('authorization')).toBe('Bearer sk_test')
54
+ expect(rows).toHaveLength(1)
55
+ expect(rows[0]?.id).toBe('issue-1')
56
+ })
57
+
58
+ test('omits empty query params', async () => {
59
+ mockFetch(JSON.stringify([]))
60
+ await issueList({ config: cfg })
61
+ expect(calls[0]?.url).toBe('https://api.example.com/admin/api/projects/proj-1/issues')
62
+ })
63
+
64
+ test('throws with server detail on non-2xx', async () => {
65
+ mockFetch('forbidden', { status: 403, statusText: 'Forbidden' })
66
+ await expect(issueList({ config: cfg })).rejects.toThrow('403 Forbidden — forbidden')
67
+ })
68
+ })
69
+
70
+ describe('issuePatch', () => {
71
+ test('PATCHes /issues/<id> with the body', async () => {
72
+ mockFetch(JSON.stringify({ ...sample, status: 'resolved' }))
73
+ const updated = await issuePatch(cfg, 'issue-1', {
74
+ resolvedInRelease: 'app@1.2.4+457',
75
+ status: 'resolved',
76
+ })
77
+ expect(calls[0]?.method).toBe('PATCH')
78
+ expect(calls[0]?.url).toBe('https://api.example.com/admin/api/projects/proj-1/issues/issue-1')
79
+ expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({
80
+ resolvedInRelease: 'app@1.2.4+457',
81
+ status: 'resolved',
82
+ })
83
+ expect(updated.status).toBe('resolved')
84
+ })
85
+ })
86
+
87
+ describe('formatIssueLine', () => {
88
+ test('renders id + status + title + event count on one line', () => {
89
+ const line = formatIssueLine(sample)
90
+ expect(line).toContain('issue-1')
91
+ expect(line).toContain('active')
92
+ expect(line).toContain('TypeError: undefined is not an object')
93
+ expect(line).toContain('17×')
94
+ })
95
+ })
@@ -0,0 +1,37 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
6
+
7
+ import { composeSourceMaps, resolveComposeScript } from '../react-native.js'
8
+
9
+ let dir = ''
10
+ beforeEach(async () => {
11
+ dir = await mkdtemp(join(tmpdir(), 'sentori-cli-rn-'))
12
+ })
13
+ afterEach(async () => {
14
+ await rm(dir, { force: true, recursive: true })
15
+ })
16
+
17
+ describe('react-native helpers', () => {
18
+ test('resolveComposeScript returns null when react-native is not installed', () => {
19
+ // This package doesn't depend on react-native, so from sdk/cli's
20
+ // own node_modules there's nothing to find.
21
+ expect(resolveComposeScript()).toBeNull()
22
+ })
23
+
24
+ test('composeSourceMaps throws "no such file" for a missing input', () => {
25
+ expect(() => composeSourceMaps(join(dir, 'nope.map'), join(dir, 'nope2.map'))).toThrow(
26
+ 'no such file',
27
+ )
28
+ })
29
+
30
+ test('composeSourceMaps throws a helpful message when react-native is absent', async () => {
31
+ await writeFile(join(dir, 'a.packager.map'), '{"version":3}')
32
+ await writeFile(join(dir, 'a.hbc.map'), '{"version":3}')
33
+ expect(() =>
34
+ composeSourceMaps(join(dir, 'a.packager.map'), join(dir, 'a.hbc.map')),
35
+ ).toThrow(/compose-source-maps\.js/)
36
+ })
37
+ })
package/src/index.ts CHANGED
@@ -1,60 +1,147 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util'
3
3
 
4
+ import { formatIssueLine, issueList, issuePatch } from './issue.js'
5
+ import { reactNativeUpload } from './react-native.js'
4
6
  import { uploadSourcemaps } from './upload.js'
5
7
 
6
- const HELP = `sentori-cli — upload release artifacts to Sentori
8
+ const HELP = `sentori-cli — Sentori command-line interface
7
9
 
8
- Usage:
10
+ Source-map upload:
9
11
  sentori-cli upload sourcemap [options] <path...>
12
+ Upload one or more files or directories. A directory is scanned
13
+ (one level) for *.map / *.js / *.jsbundle / *.bundle / *.hbc;
14
+ a file given explicitly is uploaded as-is. Use this for web
15
+ bundlers (point at the build dir) or for already-composed RN maps.
10
16
 
11
- <path> one or more files or directories. A directory is scanned
12
- (one level) for *.map and *.js / *.bundle / *.hbc files.
13
- A file given explicitly is uploaded as-is.
17
+ sentori-cli react-native upload [options]
18
+ Compose a Metro packager map + a Hermes map into one source map
19
+ and upload it (plus the bundle). Use this for a Hermes release
20
+ build. Requires --metro-map and --hermes-map.
14
21
 
15
- Options:
22
+ CI triage:
23
+ sentori-cli issue list --project <uuid> [--status active|silenced|resolved|closed] [--limit N] [--error-type <t>]
24
+ sentori-cli issue resolve <issue-uuid> --project <uuid> [--in-release <r>]
25
+ sentori-cli issue silence <issue-uuid> --project <uuid>
26
+
27
+ Options (upload commands):
16
28
  --release <r> release identifier — MUST equal the value the SDK
17
- reports via init({ release }). Required. A mismatch
18
- means the dashboard silently can't symbolicate.
29
+ reports via init({ release }). Required.
19
30
  --token <t> Sentori token (or set $SENTORI_TOKEN).
20
31
  --api-url <url> Sentori API base (default https://api.sentori.golia.jp,
21
32
  or $SENTORI_API_URL). For a self-hosted instance, your
22
33
  host. (Accepts --ingest-url as an alias.)
23
- --dry-run list what would be uploaded; don't upload.
34
+ --dry-run describe what would be uploaded; don't upload.
24
35
  -h, --help show this help.
25
36
 
26
- React Native / Hermes: in a release build the *minified* map (Metro)
27
- and the *bytecode* map (Hermes) must be composed first —
28
- npx react-native bundle --platform ios --dev false \\
29
- --entry-file index.js --bundle-output main.jsbundle \\
30
- --sourcemap-output main.jsbundle.packager.map
31
- node node_modules/react-native/scripts/compose-source-maps.js \\
32
- main.jsbundle.packager.map main.jsbundle.hbc.map -o main.jsbundle.map
33
- then \`sentori-cli upload sourcemap --release "<app>@<version>+<build>" main.jsbundle.map main.jsbundle\`.
34
- (Web bundlers emit a usable .map directly just point at the build dir.)
37
+ Options (react-native upload):
38
+ --metro-map <p> the *.packager.map Metro emits (--sourcemap-output).
39
+ --hermes-map <p> the *.hbc.map the Hermes compiler emits.
40
+ --bundle <p> optional: also upload the bundle (.jsbundle / .bundle).
41
+
42
+ Options (issue commands):
43
+ --project <uuid> project id (or set $SENTORI_PROJECT_ID).
44
+ --token <t> admin token, sk_… prefix (or $SENTORI_ADMIN_TOKEN /
45
+ $SENTORI_TOKEN). The ingest st_pk_ token may also work
46
+ on a self-hosted instance.
47
+ --api-url <url> Sentori API base (same as above).
48
+ --in-release <r> (resolve only) mark this release as where the fix
49
+ landed; the regression detector flips the issue back
50
+ to "regressed" if a matching event lands later.
51
+
52
+ Hermes release build, by hand:
53
+ npx react-native bundle --platform ios --dev false --entry-file index.js \\
54
+ --bundle-output main.jsbundle --sourcemap-output main.jsbundle.packager.map
55
+ # (the iOS/Android build compiles to Hermes and writes main.jsbundle.hbc.map)
56
+ npx @goliapkg/sentori-cli react-native upload \\
57
+ --release "<app>@<version>+<build>" --token "$SENTORI_TOKEN" \\
58
+ --metro-map main.jsbundle.packager.map --hermes-map main.jsbundle.hbc.map \\
59
+ --bundle main.jsbundle
35
60
  `
36
61
 
37
- async function main(argv: string[]): Promise<number> {
38
- if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
62
+ type Common = { apiUrl: string; dryRun: boolean; release: string; token: string }
63
+
64
+ /** Parse the shared options, or print an error + return null. */
65
+ function parseCommon(values: Record<string, unknown>): Common | null {
66
+ const release = typeof values.release === 'string' ? values.release : undefined
67
+ if (!release) {
68
+ console.error('error: --release is required (must match the SDK’s init({ release }))')
69
+ return null
70
+ }
71
+ const dryRun = values['dry-run'] === true
72
+ const token =
73
+ (typeof values.token === 'string' ? values.token : undefined) ?? process.env.SENTORI_TOKEN
74
+ if (!token && !dryRun) {
75
+ console.error('error: --token (or $SENTORI_TOKEN) is required')
76
+ return null
77
+ }
78
+ const apiUrl =
79
+ (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
80
+ (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
81
+ process.env.SENTORI_API_URL ??
82
+ 'https://api.sentori.golia.jp'
83
+ return { apiUrl, dryRun, release, token: token ?? '' }
84
+ }
85
+
86
+ async function cmdUploadSourcemap(argv: string[]): Promise<number> {
87
+ let parsed
88
+ try {
89
+ parsed = parseArgs({
90
+ allowPositionals: true,
91
+ args: argv,
92
+ options: {
93
+ 'api-url': { type: 'string' },
94
+ 'dry-run': { type: 'boolean' },
95
+ help: { short: 'h', type: 'boolean' },
96
+ 'ingest-url': { type: 'string' },
97
+ release: { type: 'string' },
98
+ token: { type: 'string' },
99
+ },
100
+ })
101
+ } catch (e) {
102
+ console.error(`error: ${(e as Error).message}\n`)
103
+ console.error(HELP)
104
+ return 2
105
+ }
106
+ if (parsed.values.help) {
39
107
  console.log(HELP)
40
108
  return 0
41
109
  }
42
- if (argv[0] !== 'upload' || argv[1] !== 'sourcemap') {
43
- console.error(`unknown command: ${argv.slice(0, 2).join(' ') || '(none)'}\n`)
44
- console.error(HELP)
110
+ const c = parseCommon(parsed.values)
111
+ if (!c) return 2
112
+ if (parsed.positionals.length === 0) {
113
+ console.error('error: at least one path (file or directory) is required')
45
114
  return 2
46
115
  }
116
+ try {
117
+ const result = await uploadSourcemaps({
118
+ apiUrl: c.apiUrl,
119
+ dryRun: c.dryRun,
120
+ paths: parsed.positionals,
121
+ release: c.release,
122
+ token: c.token,
123
+ })
124
+ reportUpload(result, c)
125
+ return 0
126
+ } catch (e) {
127
+ console.error(`upload failed: ${(e as Error).message}`)
128
+ return 1
129
+ }
130
+ }
47
131
 
132
+ async function cmdReactNativeUpload(argv: string[]): Promise<number> {
48
133
  let parsed
49
134
  try {
50
135
  parsed = parseArgs({
51
- allowPositionals: true,
52
- args: argv.slice(2),
136
+ args: argv,
53
137
  options: {
54
138
  'api-url': { type: 'string' },
139
+ bundle: { type: 'string' },
55
140
  'dry-run': { type: 'boolean' },
56
141
  help: { short: 'h', type: 'boolean' },
57
- 'ingest-url': { type: 'string' }, // alias, kept for older docs
142
+ 'hermes-map': { type: 'string' },
143
+ 'ingest-url': { type: 'string' },
144
+ 'metro-map': { type: 'string' },
58
145
  release: { type: 'string' },
59
146
  token: { type: 'string' },
60
147
  },
@@ -64,58 +151,200 @@ async function main(argv: string[]): Promise<number> {
64
151
  console.error(HELP)
65
152
  return 2
66
153
  }
67
- const { positionals, values } = parsed
68
-
69
- if (values.help) {
154
+ if (parsed.values.help) {
70
155
  console.log(HELP)
71
156
  return 0
72
157
  }
73
- const release = values.release
74
- if (!release) {
75
- console.error('error: --release is required (must match the SDK’s init({ release }))')
158
+ const c = parseCommon(parsed.values)
159
+ if (!c) return 2
160
+ const metroMap = parsed.values['metro-map']
161
+ const hermesMap = parsed.values['hermes-map']
162
+ if (typeof metroMap !== 'string' || typeof hermesMap !== 'string') {
163
+ console.error('error: --metro-map and --hermes-map are both required')
76
164
  return 2
77
165
  }
78
- const dryRun = !!values['dry-run']
79
- const token = values.token ?? process.env.SENTORI_TOKEN
80
- if (!token && !dryRun) {
81
- console.error('error: --token (or $SENTORI_TOKEN) is required')
82
- return 2
166
+ try {
167
+ const result = await reactNativeUpload({
168
+ apiUrl: c.apiUrl,
169
+ bundle: typeof parsed.values.bundle === 'string' ? parsed.values.bundle : undefined,
170
+ dryRun: c.dryRun,
171
+ hermesMap,
172
+ metroMap,
173
+ release: c.release,
174
+ token: c.token,
175
+ })
176
+ reportUpload(result, c)
177
+ return 0
178
+ } catch (e) {
179
+ console.error(`react-native upload failed: ${(e as Error).message}`)
180
+ return 1
181
+ }
182
+ }
183
+
184
+ function reportUpload(
185
+ result: { files: string[]; uploaded?: number },
186
+ c: Common,
187
+ ): void {
188
+ if (c.dryRun) {
189
+ console.log(
190
+ `would upload ${result.files.length} file(s) to ${c.apiUrl.replace(/\/+$/, '')}/admin/api/releases/${encodeURIComponent(c.release)}/sourcemaps:`,
191
+ )
192
+ for (const f of result.files) console.log(` ${f}`)
193
+ } else {
194
+ console.log(
195
+ `uploaded ${result.uploaded ?? result.files.length} file(s) for release "${c.release}" — minified stacks on this release will now resolve to source.`,
196
+ )
197
+ }
198
+ }
199
+
200
+ // ── issue commands ────────────────────────────────────────────────
201
+
202
+ type AdminCfg = { apiUrl: string; projectId: string; token: string }
203
+
204
+ function parseAdminCfg(values: Record<string, unknown>): AdminCfg | null {
205
+ const projectId =
206
+ (typeof values.project === 'string' ? values.project : undefined) ??
207
+ process.env.SENTORI_PROJECT_ID
208
+ if (!projectId) {
209
+ console.error('error: --project <uuid> (or $SENTORI_PROJECT_ID) is required')
210
+ return null
211
+ }
212
+ const token =
213
+ (typeof values.token === 'string' ? values.token : undefined) ??
214
+ process.env.SENTORI_ADMIN_TOKEN ??
215
+ process.env.SENTORI_TOKEN
216
+ if (!token) {
217
+ console.error(
218
+ 'error: --token (or $SENTORI_ADMIN_TOKEN / $SENTORI_TOKEN) is required for issue commands',
219
+ )
220
+ return null
83
221
  }
84
222
  const apiUrl =
85
- values['api-url'] ??
86
- values['ingest-url'] ??
223
+ (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
224
+ (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
87
225
  process.env.SENTORI_API_URL ??
88
226
  'https://api.sentori.golia.jp'
89
- if (positionals.length === 0) {
90
- console.error('error: at least one path (file or directory) is required')
227
+ return { apiUrl, projectId, token }
228
+ }
229
+
230
+ async function cmdIssueList(argv: string[]): Promise<number> {
231
+ let parsed
232
+ try {
233
+ parsed = parseArgs({
234
+ args: argv,
235
+ options: {
236
+ 'api-url': { type: 'string' },
237
+ 'error-type': { type: 'string' },
238
+ help: { short: 'h', type: 'boolean' },
239
+ 'ingest-url': { type: 'string' },
240
+ limit: { type: 'string' },
241
+ project: { type: 'string' },
242
+ status: { type: 'string' },
243
+ token: { type: 'string' },
244
+ },
245
+ })
246
+ } catch (e) {
247
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
91
248
  return 2
92
249
  }
93
-
250
+ if (parsed.values.help) {
251
+ console.log(HELP)
252
+ return 0
253
+ }
254
+ const cfg = parseAdminCfg(parsed.values)
255
+ if (!cfg) return 2
256
+ const status = parsed.values.status
257
+ if (status && !['active', 'closed', 'resolved', 'silenced'].includes(status)) {
258
+ console.error(`error: --status must be one of: active, silenced, resolved, closed`)
259
+ return 2
260
+ }
261
+ const limitStr = parsed.values.limit
262
+ const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined
94
263
  try {
95
- const result = await uploadSourcemaps({
96
- apiUrl,
97
- dryRun,
98
- paths: positionals,
99
- release,
100
- token: token ?? '',
264
+ const rows = await issueList({
265
+ config: cfg,
266
+ errorType: parsed.values['error-type'],
267
+ limit,
268
+ status: status as 'active' | 'closed' | 'resolved' | 'silenced' | undefined,
101
269
  })
102
- if (dryRun) {
103
- console.log(
104
- `would upload ${result.files.length} file(s) to ${apiUrl.replace(/\/+$/, '')}/admin/api/releases/${encodeURIComponent(release)}/sourcemaps:`,
105
- )
106
- for (const f of result.files) console.log(` ${f}`)
107
- } else {
108
- console.log(`uploaded ${result.uploaded} file(s) for release "${release}"`)
109
- for (const a of result.artifacts ?? []) console.log(` ${a.name} [${a.kind}]`)
110
- console.log('done — minified stack traces on this release will now resolve to source.')
270
+ if (rows.length === 0) {
271
+ console.log('(no matching issues)')
272
+ return 0
111
273
  }
274
+ for (const r of rows) console.log(formatIssueLine(r))
112
275
  return 0
113
276
  } catch (e) {
114
- console.error(`upload failed: ${(e as Error).message}`)
277
+ console.error(`issue list failed: ${(e as Error).message}`)
278
+ return 1
279
+ }
280
+ }
281
+
282
+ async function cmdIssuePatch(
283
+ argv: string[],
284
+ body: { resolvedInRelease?: string; status: 'active' | 'closed' | 'resolved' | 'silenced' },
285
+ verb: 'closed' | 'resolved' | 'silenced',
286
+ ): Promise<number> {
287
+ let parsed
288
+ try {
289
+ parsed = parseArgs({
290
+ allowPositionals: true,
291
+ args: argv,
292
+ options: {
293
+ 'api-url': { type: 'string' },
294
+ help: { short: 'h', type: 'boolean' },
295
+ 'in-release': { type: 'string' },
296
+ 'ingest-url': { type: 'string' },
297
+ project: { type: 'string' },
298
+ token: { type: 'string' },
299
+ },
300
+ })
301
+ } catch (e) {
302
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
303
+ return 2
304
+ }
305
+ if (parsed.values.help) {
306
+ console.log(HELP)
307
+ return 0
308
+ }
309
+ const cfg = parseAdminCfg(parsed.values)
310
+ if (!cfg) return 2
311
+ const issueId = parsed.positionals[0]
312
+ if (!issueId) {
313
+ console.error('error: <issue-uuid> is required')
314
+ return 2
315
+ }
316
+ if (verb === 'resolved' && typeof parsed.values['in-release'] === 'string') {
317
+ body.resolvedInRelease = parsed.values['in-release']
318
+ }
319
+ try {
320
+ const updated = await issuePatch(cfg, issueId, body)
321
+ console.log(
322
+ `${issueId} → ${verb}${body.resolvedInRelease ? ` (in ${body.resolvedInRelease})` : ''}: ${updated.errorType}`,
323
+ )
324
+ return 0
325
+ } catch (e) {
326
+ console.error(`issue ${verb} failed: ${(e as Error).message}`)
115
327
  return 1
116
328
  }
117
329
  }
118
330
 
331
+ async function main(argv: string[]): Promise<number> {
332
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
333
+ console.log(HELP)
334
+ return 0
335
+ }
336
+ const [a, b, ...rest] = argv
337
+ if (a === 'upload' && b === 'sourcemap') return cmdUploadSourcemap(rest)
338
+ if (a === 'react-native' && b === 'upload') return cmdReactNativeUpload(rest)
339
+ if (a === 'issue' && b === 'list') return cmdIssueList(rest)
340
+ if (a === 'issue' && b === 'resolve') return cmdIssuePatch(rest, { status: 'resolved' }, 'resolved')
341
+ if (a === 'issue' && b === 'silence') return cmdIssuePatch(rest, { status: 'silenced' }, 'silenced')
342
+ if (a === 'issue' && b === 'close') return cmdIssuePatch(rest, { status: 'closed' }, 'closed')
343
+ console.error(`unknown command: ${[a, b].filter(Boolean).join(' ') || '(none)'}\n`)
344
+ console.error(HELP)
345
+ return 2
346
+ }
347
+
119
348
  main(process.argv.slice(2)).then(
120
349
  (code) => process.exit(code),
121
350
  (e: unknown) => {
package/src/issue.ts ADDED
@@ -0,0 +1,81 @@
1
+ // `sentori-cli issue list / resolve / silence` — CI triage helpers.
2
+
3
+ type Issue = {
4
+ errorType: string
5
+ eventCount: number
6
+ id: string
7
+ lastSeen: string
8
+ messageSample: string
9
+ status: 'active' | 'closed' | 'resolved' | 'silenced'
10
+ }
11
+
12
+ type AdminConfig = {
13
+ apiUrl: string
14
+ projectId: string
15
+ token: string
16
+ }
17
+
18
+ function url(c: AdminConfig, path: string): string {
19
+ return `${c.apiUrl.replace(/\/+$/, '')}/admin/api/projects/${c.projectId}${path}`
20
+ }
21
+
22
+ async function adminFetch<T>(c: AdminConfig, path: string, init?: RequestInit): Promise<T> {
23
+ const resp = await fetch(url(c, path), {
24
+ ...init,
25
+ headers: {
26
+ Authorization: `Bearer ${c.token}`,
27
+ 'Content-Type': 'application/json',
28
+ ...(init?.headers ?? {}),
29
+ },
30
+ })
31
+ if (!resp.ok) {
32
+ let detail = ''
33
+ try {
34
+ detail = await resp.text()
35
+ } catch {
36
+ // ignore
37
+ }
38
+ throw new Error(
39
+ `${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
40
+ )
41
+ }
42
+ // PATCH /issues/<id> returns the row; some endpoints might return no
43
+ // content — handle both.
44
+ const txt = await resp.text()
45
+ return (txt ? JSON.parse(txt) : null) as T
46
+ }
47
+
48
+ export type IssueListOptions = {
49
+ config: AdminConfig
50
+ errorType?: string
51
+ limit?: number
52
+ status?: 'active' | 'closed' | 'resolved' | 'silenced'
53
+ }
54
+
55
+ export async function issueList(opts: IssueListOptions): Promise<Issue[]> {
56
+ const q = new URLSearchParams()
57
+ if (opts.status) q.set('status', opts.status)
58
+ if (opts.limit) q.set('limit', String(opts.limit))
59
+ if (opts.errorType) q.set('errorType', opts.errorType)
60
+ const qs = q.toString()
61
+ return adminFetch<Issue[]>(opts.config, `/issues${qs ? '?' + qs : ''}`)
62
+ }
63
+
64
+ export async function issuePatch(
65
+ config: AdminConfig,
66
+ issueId: string,
67
+ body: { resolvedInRelease?: string; status: 'active' | 'closed' | 'resolved' | 'silenced' },
68
+ ): Promise<Issue> {
69
+ return adminFetch<Issue>(config, `/issues/${encodeURIComponent(issueId)}`, {
70
+ body: JSON.stringify(body),
71
+ method: 'PATCH',
72
+ })
73
+ }
74
+
75
+ /** Format one issue for terminal output — short, one line, scannable. */
76
+ export function formatIssueLine(i: Issue): string {
77
+ const status = i.status.padEnd(9)
78
+ const title = `${i.errorType}${i.messageSample ? `: ${i.messageSample}` : ''}`
79
+ const events = `${i.eventCount}×`
80
+ return `${i.id} ${status} ${title.slice(0, 80).padEnd(80)} ${events}`
81
+ }