@goliapkg/sentori-cli 0.3.0 → 0.5.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 @@
1
+ {"version":3,"file":"native-artifacts.js","sourceRoot":"","sources":["../src/native-artifacts.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goliapkg/sentori-cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Sentori CLI \u2014 upload source maps so dashboard stack traces resolve to your original source.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://sentori.golia.jp",
@@ -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,132 @@
1
+ import { mkdir, 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 {
8
+ dsymSlicesFromDwarfdump,
9
+ dwarfBinariesIn,
10
+ uploadDsym,
11
+ uploadMapping,
12
+ } from '../native-artifacts.js'
13
+
14
+ const ADMIN = {
15
+ apiUrl: 'https://api.example.com/',
16
+ projectId: 'proj-1',
17
+ token: 'sk_test',
18
+ }
19
+
20
+ let dir = ''
21
+ const origFetch = globalThis.fetch
22
+ let calls: { body: Buffer | null; headers: Headers; method: string; url: string }[]
23
+ beforeEach(async () => {
24
+ dir = await mkdtemp(join(tmpdir(), 'sentori-cli-native-'))
25
+ calls = []
26
+ })
27
+ afterEach(async () => {
28
+ globalThis.fetch = origFetch
29
+ await rm(dir, { force: true, recursive: true })
30
+ })
31
+
32
+ function mockFetch(status = 200, respBody = ''): void {
33
+ globalThis.fetch = (async (url: Request | string | URL, init?: RequestInit) => {
34
+ const body = init?.body
35
+ calls.push({
36
+ body: body instanceof Uint8Array ? Buffer.from(body) : null,
37
+ headers: new Headers(init?.headers),
38
+ method: init?.method ?? 'GET',
39
+ url: String(url),
40
+ })
41
+ return new Response(respBody, { status })
42
+ }) as typeof fetch
43
+ }
44
+
45
+ describe('uploadMapping', () => {
46
+ test('POSTs raw bytes to /mappings with Bearer + release query', async () => {
47
+ mockFetch()
48
+ const path = join(dir, 'mapping.txt')
49
+ await writeFile(path, '# pg_map_id: abc\nfoo.bar -> a.b:\n')
50
+ await uploadMapping({ ...ADMIN, path, release: 'app@1.0.0+1' })
51
+ expect(calls).toHaveLength(1)
52
+ expect(calls[0]?.method).toBe('POST')
53
+ expect(calls[0]?.url).toBe(
54
+ 'https://api.example.com/admin/api/projects/proj-1/mappings?release=app%401.0.0%2B1',
55
+ )
56
+ expect(calls[0]?.headers.get('authorization')).toBe('Bearer sk_test')
57
+ expect(calls[0]?.headers.get('content-type')).toBe('application/octet-stream')
58
+ expect(calls[0]?.body?.toString()).toContain('pg_map_id')
59
+ })
60
+
61
+ test('sets x-sentori-debug-id when given', async () => {
62
+ mockFetch()
63
+ const path = join(dir, 'mapping.txt')
64
+ await writeFile(path, 'x')
65
+ await uploadMapping({ ...ADMIN, debugId: '1234-uuid', path })
66
+ expect(calls[0]?.headers.get('x-sentori-debug-id')).toBe('1234-uuid')
67
+ })
68
+
69
+ test('throws on empty file', async () => {
70
+ const path = join(dir, 'empty.txt')
71
+ await writeFile(path, '')
72
+ await expect(uploadMapping({ ...ADMIN, path })).rejects.toThrow('empty mapping file')
73
+ })
74
+
75
+ test('throws on non-2xx with server detail', async () => {
76
+ mockFetch(403, 'forbidden')
77
+ const path = join(dir, 'mapping.txt')
78
+ await writeFile(path, 'x')
79
+ await expect(uploadMapping({ ...ADMIN, path })).rejects.toThrow('403')
80
+ })
81
+ })
82
+
83
+ describe('uploadDsym (explicit single-slice)', () => {
84
+ test('POSTs to /dsyms with x-sentori-debug-id, x-sentori-arch, release + objectName', async () => {
85
+ mockFetch()
86
+ const path = join(dir, 'Foo.dSYM/Contents/Resources/DWARF/Foo')
87
+ await mkdir(join(dir, 'Foo.dSYM/Contents/Resources/DWARF'), { recursive: true })
88
+ await writeFile(path, 'macho-bytes')
89
+ const r = await uploadDsym({
90
+ ...ADMIN,
91
+ arch: 'arm64',
92
+ debugId: '1234abcd-1234-1234-1234-1234567890ab',
93
+ objectName: 'Foo',
94
+ path,
95
+ release: 'app@1.0.0+1',
96
+ })
97
+ expect(calls).toHaveLength(1)
98
+ expect(calls[0]?.method).toBe('POST')
99
+ expect(calls[0]?.url).toContain('/admin/api/projects/proj-1/dsyms')
100
+ expect(calls[0]?.url).toContain('release=app%401.0.0%2B1')
101
+ expect(calls[0]?.url).toContain('objectName=Foo')
102
+ expect(calls[0]?.headers.get('x-sentori-debug-id')).toBe('1234ABCD-1234-1234-1234-1234567890AB')
103
+ expect(calls[0]?.headers.get('x-sentori-arch')).toBe('arm64')
104
+ expect(r.slices).toEqual([{ arch: 'arm64', debugId: '1234ABCD-1234-1234-1234-1234567890AB' }])
105
+ })
106
+ })
107
+
108
+ describe('helpers', () => {
109
+ test('dwarfBinariesIn walks a .dSYM bundle', async () => {
110
+ const bundle = join(dir, 'Foo.dSYM')
111
+ const dwarfDir = join(bundle, 'Contents/Resources/DWARF')
112
+ await mkdir(dwarfDir, { recursive: true })
113
+ await writeFile(join(dwarfDir, 'Foo'), 'x')
114
+ await writeFile(join(dwarfDir, '.DS_Store'), 'noise')
115
+ const found = dwarfBinariesIn(bundle)
116
+ expect(found.map((p) => p.replace(bundle + '/', ''))).toEqual([
117
+ 'Contents/Resources/DWARF/Foo',
118
+ ])
119
+ })
120
+
121
+ test('dwarfBinariesIn on a plain file returns just that file', async () => {
122
+ const f = join(dir, 'binary')
123
+ await writeFile(f, 'x')
124
+ expect(dwarfBinariesIn(f)).toEqual([f])
125
+ })
126
+
127
+ test('dsymSlicesFromDwarfdump returns [] when dwarfdump fails (or is absent)', () => {
128
+ // pass a path that doesn't exist; dwarfdump exits non-zero (or isn't installed)
129
+ const slices = dsymSlicesFromDwarfdump(join(dir, 'nope.dSYM'))
130
+ expect(slices).toEqual([])
131
+ })
132
+ })
package/src/index.ts CHANGED
@@ -1,12 +1,14 @@
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 { uploadDsym, uploadMapping } from './native-artifacts.js'
4
6
  import { reactNativeUpload } from './react-native.js'
5
7
  import { uploadSourcemaps } from './upload.js'
6
8
 
7
- const HELP = `sentori-cli — upload release artifacts to Sentori
9
+ const HELP = `sentori-cli — Sentori command-line interface
8
10
 
9
- Usage:
11
+ Source-map upload:
10
12
  sentori-cli upload sourcemap [options] <path...>
11
13
  Upload one or more files or directories. A directory is scanned
12
14
  (one level) for *.map / *.js / *.jsbundle / *.bundle / *.hbc;
@@ -18,14 +20,30 @@ Usage:
18
20
  and upload it (plus the bundle). Use this for a Hermes release
19
21
  build. Requires --metro-map and --hermes-map.
20
22
 
21
- Options (both commands):
23
+ Native artifacts (project-scoped, need --project + admin token):
24
+ sentori-cli upload dsym --project <uuid> [--release <r>] [--object-name <n>] [--debug-id <uuid> --arch <a>] <path>
25
+ Upload iOS dSYM debug info. By default walks a Foo.dSYM bundle
26
+ and uses "dwarfdump --uuid" to enumerate slices, uploading each.
27
+ Pass --debug-id and --arch to upload a single slice without
28
+ dwarfdump (useful in Linux CI where the toolchain isn't there).
29
+
30
+ sentori-cli upload mapping --project <uuid> [--release <r>] [--debug-id <uuid>] <mapping.txt>
31
+ Upload an R8 / ProGuard mapping (raw bytes). If the file starts
32
+ with a "# pg_map_id:" line the server sniffs the debug-id from
33
+ it; otherwise you can pass it explicitly.
34
+
35
+ CI triage:
36
+ sentori-cli issue list --project <uuid> [--status active|silenced|resolved|closed] [--limit N] [--error-type <t>]
37
+ sentori-cli issue resolve <issue-uuid> --project <uuid> [--in-release <r>]
38
+ sentori-cli issue silence <issue-uuid> --project <uuid>
39
+
40
+ Options (upload commands):
22
41
  --release <r> release identifier — MUST equal the value the SDK
23
- reports via init({ release }). Required. A mismatch
24
- means the dashboard silently can't symbolicate.
42
+ reports via init({ release }). Required.
25
43
  --token <t> Sentori token (or set $SENTORI_TOKEN).
26
44
  --api-url <url> Sentori API base (default https://api.sentori.golia.jp,
27
- or $SENTORI_API_URL). For a self-hosted instance,
28
- your host. (Accepts --ingest-url as an alias.)
45
+ or $SENTORI_API_URL). For a self-hosted instance, your
46
+ host. (Accepts --ingest-url as an alias.)
29
47
  --dry-run describe what would be uploaded; don't upload.
30
48
  -h, --help show this help.
31
49
 
@@ -34,6 +52,16 @@ Options (react-native upload):
34
52
  --hermes-map <p> the *.hbc.map the Hermes compiler emits.
35
53
  --bundle <p> optional: also upload the bundle (.jsbundle / .bundle).
36
54
 
55
+ Options (issue commands):
56
+ --project <uuid> project id (or set $SENTORI_PROJECT_ID).
57
+ --token <t> admin token, sk_… prefix (or $SENTORI_ADMIN_TOKEN /
58
+ $SENTORI_TOKEN). The ingest st_pk_ token may also work
59
+ on a self-hosted instance.
60
+ --api-url <url> Sentori API base (same as above).
61
+ --in-release <r> (resolve only) mark this release as where the fix
62
+ landed; the regression detector flips the issue back
63
+ to "regressed" if a matching event lands later.
64
+
37
65
  Hermes release build, by hand:
38
66
  npx react-native bundle --platform ios --dev false --entry-file index.js \\
39
67
  --bundle-output main.jsbundle --sourcemap-output main.jsbundle.packager.map
@@ -182,6 +210,246 @@ function reportUpload(
182
210
  }
183
211
  }
184
212
 
213
+ // ── issue commands ────────────────────────────────────────────────
214
+
215
+ type AdminCfg = { apiUrl: string; projectId: string; token: string }
216
+
217
+ function parseAdminCfg(values: Record<string, unknown>): AdminCfg | null {
218
+ const projectId =
219
+ (typeof values.project === 'string' ? values.project : undefined) ??
220
+ process.env.SENTORI_PROJECT_ID
221
+ if (!projectId) {
222
+ console.error('error: --project <uuid> (or $SENTORI_PROJECT_ID) is required')
223
+ return null
224
+ }
225
+ const token =
226
+ (typeof values.token === 'string' ? values.token : undefined) ??
227
+ process.env.SENTORI_ADMIN_TOKEN ??
228
+ process.env.SENTORI_TOKEN
229
+ if (!token) {
230
+ console.error(
231
+ 'error: --token (or $SENTORI_ADMIN_TOKEN / $SENTORI_TOKEN) is required for issue commands',
232
+ )
233
+ return null
234
+ }
235
+ const apiUrl =
236
+ (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
237
+ (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
238
+ process.env.SENTORI_API_URL ??
239
+ 'https://api.sentori.golia.jp'
240
+ return { apiUrl, projectId, token }
241
+ }
242
+
243
+ async function cmdIssueList(argv: string[]): Promise<number> {
244
+ let parsed
245
+ try {
246
+ parsed = parseArgs({
247
+ args: argv,
248
+ options: {
249
+ 'api-url': { type: 'string' },
250
+ 'error-type': { type: 'string' },
251
+ help: { short: 'h', type: 'boolean' },
252
+ 'ingest-url': { type: 'string' },
253
+ limit: { type: 'string' },
254
+ project: { type: 'string' },
255
+ status: { type: 'string' },
256
+ token: { type: 'string' },
257
+ },
258
+ })
259
+ } catch (e) {
260
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
261
+ return 2
262
+ }
263
+ if (parsed.values.help) {
264
+ console.log(HELP)
265
+ return 0
266
+ }
267
+ const cfg = parseAdminCfg(parsed.values)
268
+ if (!cfg) return 2
269
+ const status = parsed.values.status
270
+ if (status && !['active', 'closed', 'resolved', 'silenced'].includes(status)) {
271
+ console.error(`error: --status must be one of: active, silenced, resolved, closed`)
272
+ return 2
273
+ }
274
+ const limitStr = parsed.values.limit
275
+ const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined
276
+ try {
277
+ const rows = await issueList({
278
+ config: cfg,
279
+ errorType: parsed.values['error-type'],
280
+ limit,
281
+ status: status as 'active' | 'closed' | 'resolved' | 'silenced' | undefined,
282
+ })
283
+ if (rows.length === 0) {
284
+ console.log('(no matching issues)')
285
+ return 0
286
+ }
287
+ for (const r of rows) console.log(formatIssueLine(r))
288
+ return 0
289
+ } catch (e) {
290
+ console.error(`issue list failed: ${(e as Error).message}`)
291
+ return 1
292
+ }
293
+ }
294
+
295
+ async function cmdIssuePatch(
296
+ argv: string[],
297
+ body: { resolvedInRelease?: string; status: 'active' | 'closed' | 'resolved' | 'silenced' },
298
+ verb: 'closed' | 'resolved' | 'silenced',
299
+ ): Promise<number> {
300
+ let parsed
301
+ try {
302
+ parsed = parseArgs({
303
+ allowPositionals: true,
304
+ args: argv,
305
+ options: {
306
+ 'api-url': { type: 'string' },
307
+ help: { short: 'h', type: 'boolean' },
308
+ 'in-release': { type: 'string' },
309
+ 'ingest-url': { type: 'string' },
310
+ project: { type: 'string' },
311
+ token: { type: 'string' },
312
+ },
313
+ })
314
+ } catch (e) {
315
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
316
+ return 2
317
+ }
318
+ if (parsed.values.help) {
319
+ console.log(HELP)
320
+ return 0
321
+ }
322
+ const cfg = parseAdminCfg(parsed.values)
323
+ if (!cfg) return 2
324
+ const issueId = parsed.positionals[0]
325
+ if (!issueId) {
326
+ console.error('error: <issue-uuid> is required')
327
+ return 2
328
+ }
329
+ if (verb === 'resolved' && typeof parsed.values['in-release'] === 'string') {
330
+ body.resolvedInRelease = parsed.values['in-release']
331
+ }
332
+ try {
333
+ const updated = await issuePatch(cfg, issueId, body)
334
+ console.log(
335
+ `${issueId} → ${verb}${body.resolvedInRelease ? ` (in ${body.resolvedInRelease})` : ''}: ${updated.errorType}`,
336
+ )
337
+ return 0
338
+ } catch (e) {
339
+ console.error(`issue ${verb} failed: ${(e as Error).message}`)
340
+ return 1
341
+ }
342
+ }
343
+
344
+ // ── native artifact upload ────────────────────────────────────────
345
+
346
+ async function cmdUploadDsym(argv: string[]): Promise<number> {
347
+ let parsed
348
+ try {
349
+ parsed = parseArgs({
350
+ allowPositionals: true,
351
+ args: argv,
352
+ options: {
353
+ 'api-url': { type: 'string' },
354
+ arch: { type: 'string' },
355
+ 'debug-id': { type: 'string' },
356
+ help: { short: 'h', type: 'boolean' },
357
+ 'ingest-url': { type: 'string' },
358
+ 'object-name': { type: 'string' },
359
+ project: { type: 'string' },
360
+ release: { type: 'string' },
361
+ token: { type: 'string' },
362
+ },
363
+ })
364
+ } catch (e) {
365
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
366
+ return 2
367
+ }
368
+ if (parsed.values.help) {
369
+ console.log(HELP)
370
+ return 0
371
+ }
372
+ const cfg = parseAdminCfg(parsed.values)
373
+ if (!cfg) return 2
374
+ const path = parsed.positionals[0]
375
+ if (!path) {
376
+ console.error('error: a path to a .dSYM bundle or DWARF binary is required')
377
+ return 2
378
+ }
379
+ const debugId = parsed.values['debug-id']
380
+ const arch = parsed.values.arch
381
+ if ((debugId && !arch) || (arch && !debugId)) {
382
+ console.error('error: --debug-id and --arch must be passed together (or both omitted)')
383
+ return 2
384
+ }
385
+ try {
386
+ const r = await uploadDsym({
387
+ apiUrl: cfg.apiUrl,
388
+ arch: typeof arch === 'string' ? arch : undefined,
389
+ debugId: typeof debugId === 'string' ? debugId : undefined,
390
+ objectName: typeof parsed.values['object-name'] === 'string' ? parsed.values['object-name'] : undefined,
391
+ path,
392
+ projectId: cfg.projectId,
393
+ release: typeof parsed.values.release === 'string' ? parsed.values.release : undefined,
394
+ token: cfg.token,
395
+ })
396
+ console.log(`uploaded ${r.slices.length} dSYM slice(s):`)
397
+ for (const s of r.slices) console.log(` ${s.debugId} (${s.arch})`)
398
+ return 0
399
+ } catch (e) {
400
+ console.error(`dsym upload failed: ${(e as Error).message}`)
401
+ return 1
402
+ }
403
+ }
404
+
405
+ async function cmdUploadMapping(argv: string[]): Promise<number> {
406
+ let parsed
407
+ try {
408
+ parsed = parseArgs({
409
+ allowPositionals: true,
410
+ args: argv,
411
+ options: {
412
+ 'api-url': { type: 'string' },
413
+ 'debug-id': { type: 'string' },
414
+ help: { short: 'h', type: 'boolean' },
415
+ 'ingest-url': { type: 'string' },
416
+ project: { type: 'string' },
417
+ release: { type: 'string' },
418
+ token: { type: 'string' },
419
+ },
420
+ })
421
+ } catch (e) {
422
+ console.error(`error: ${(e as Error).message}\n${HELP}`)
423
+ return 2
424
+ }
425
+ if (parsed.values.help) {
426
+ console.log(HELP)
427
+ return 0
428
+ }
429
+ const cfg = parseAdminCfg(parsed.values)
430
+ if (!cfg) return 2
431
+ const path = parsed.positionals[0]
432
+ if (!path) {
433
+ console.error('error: a path to mapping.txt is required')
434
+ return 2
435
+ }
436
+ try {
437
+ await uploadMapping({
438
+ apiUrl: cfg.apiUrl,
439
+ debugId: typeof parsed.values['debug-id'] === 'string' ? parsed.values['debug-id'] : undefined,
440
+ path,
441
+ projectId: cfg.projectId,
442
+ release: typeof parsed.values.release === 'string' ? parsed.values.release : undefined,
443
+ token: cfg.token,
444
+ })
445
+ console.log(`uploaded mapping for project ${cfg.projectId}${parsed.values.release ? ` / ${parsed.values.release}` : ''}`)
446
+ return 0
447
+ } catch (e) {
448
+ console.error(`mapping upload failed: ${(e as Error).message}`)
449
+ return 1
450
+ }
451
+ }
452
+
185
453
  async function main(argv: string[]): Promise<number> {
186
454
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
187
455
  console.log(HELP)
@@ -189,7 +457,13 @@ async function main(argv: string[]): Promise<number> {
189
457
  }
190
458
  const [a, b, ...rest] = argv
191
459
  if (a === 'upload' && b === 'sourcemap') return cmdUploadSourcemap(rest)
460
+ if (a === 'upload' && b === 'dsym') return cmdUploadDsym(rest)
461
+ if (a === 'upload' && b === 'mapping') return cmdUploadMapping(rest)
192
462
  if (a === 'react-native' && b === 'upload') return cmdReactNativeUpload(rest)
463
+ if (a === 'issue' && b === 'list') return cmdIssueList(rest)
464
+ if (a === 'issue' && b === 'resolve') return cmdIssuePatch(rest, { status: 'resolved' }, 'resolved')
465
+ if (a === 'issue' && b === 'silence') return cmdIssuePatch(rest, { status: 'silenced' }, 'silenced')
466
+ if (a === 'issue' && b === 'close') return cmdIssuePatch(rest, { status: 'closed' }, 'closed')
193
467
  console.error(`unknown command: ${[a, b].filter(Boolean).join(' ') || '(none)'}\n`)
194
468
  console.error(HELP)
195
469
  return 2
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
+ }