@goliapkg/sentori-cli 0.5.2 → 0.5.3

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.
@@ -1,121 +0,0 @@
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 { collectFiles, uploadSourcemaps } from '../upload.js'
8
-
9
- let dir = ''
10
- beforeEach(async () => {
11
- dir = await mkdtemp(join(tmpdir(), 'sentori-cli-test-'))
12
- await writeFile(join(dir, 'main.jsbundle'), '/*bundle*/')
13
- await writeFile(join(dir, 'main.jsbundle.map'), '{"version":3}')
14
- await writeFile(join(dir, 'app.js'), 'console.log(1)')
15
- await writeFile(join(dir, 'app.js.map'), '{"version":3}')
16
- await writeFile(join(dir, 'README.txt'), 'not a build artifact')
17
- })
18
- afterEach(async () => {
19
- await rm(dir, { force: true, recursive: true })
20
- })
21
-
22
- describe('collectFiles', () => {
23
- test('a directory yields its .map / .js / .bundle files, not others', async () => {
24
- const files = await collectFiles([dir])
25
- const names = files.map((f) => f.replace(`${dir}/`, '')).sort()
26
- expect(names).toEqual(['app.js', 'app.js.map', 'main.jsbundle', 'main.jsbundle.map'])
27
- })
28
-
29
- test('an explicit file is taken as-is regardless of extension', async () => {
30
- const files = await collectFiles([join(dir, 'README.txt')])
31
- expect(files).toEqual([join(dir, 'README.txt')])
32
- })
33
-
34
- test('dedupes when a file is named both directly and via its dir', async () => {
35
- const files = await collectFiles([dir, join(dir, 'app.js.map')])
36
- expect(files.filter((f) => f.endsWith('app.js.map'))).toHaveLength(1)
37
- })
38
-
39
- test('throws on a nonexistent path', async () => {
40
- await expect(collectFiles([join(dir, 'nope')])).rejects.toThrow('no such file or directory')
41
- })
42
-
43
- test('throws when a directory has no uploadable files', async () => {
44
- const empty = await mkdtemp(join(tmpdir(), 'sentori-cli-empty-'))
45
- try {
46
- await expect(collectFiles([empty])).rejects.toThrow('no .map')
47
- } finally {
48
- await rm(empty, { force: true, recursive: true })
49
- }
50
- })
51
- })
52
-
53
- describe('uploadSourcemaps', () => {
54
- test('dryRun returns the file list, makes no request', async () => {
55
- let called = false
56
- const orig = globalThis.fetch
57
- globalThis.fetch = (async () => {
58
- called = true
59
- return new Response()
60
- }) as typeof fetch
61
- try {
62
- const r = await uploadSourcemaps({
63
- apiUrl: 'https://api.example.com',
64
- dryRun: true,
65
- paths: [dir],
66
- release: 'app@1.0.0+1',
67
- token: 'st_pk_x',
68
- })
69
- expect(r.files).toHaveLength(4)
70
- expect(called).toBe(false)
71
- } finally {
72
- globalThis.fetch = orig
73
- }
74
- })
75
-
76
- test('POSTs multipart to /admin/api/releases/<release>/sourcemaps with the token', async () => {
77
- const calls: { headers: Headers; url: string }[] = []
78
- const orig = globalThis.fetch
79
- globalThis.fetch = (async (url: Request | string | URL, init?: RequestInit) => {
80
- calls.push({ headers: new Headers(init?.headers), url: String(url) })
81
- return new Response(JSON.stringify({ artifacts: [], uploaded: 4 }), {
82
- headers: { 'content-type': 'application/json' },
83
- status: 200,
84
- })
85
- }) as typeof fetch
86
- try {
87
- const r = await uploadSourcemaps({
88
- apiUrl: 'https://api.example.com/',
89
- paths: [dir],
90
- release: 'my app@1.0.0+1',
91
- token: 'st_pk_secret',
92
- })
93
- expect(calls).toHaveLength(1)
94
- expect(calls[0]?.url).toBe(
95
- 'https://api.example.com/admin/api/releases/my%20app%401.0.0%2B1/sourcemaps',
96
- )
97
- expect(calls[0]?.headers.get('authorization')).toBe('Bearer st_pk_secret')
98
- expect(r.uploaded).toBe(4)
99
- } finally {
100
- globalThis.fetch = orig
101
- }
102
- })
103
-
104
- test('throws with the server detail on a non-2xx response', async () => {
105
- const orig = globalThis.fetch
106
- globalThis.fetch = (async () =>
107
- new Response('release frozen', { status: 403, statusText: 'Forbidden' })) as typeof fetch
108
- try {
109
- await expect(
110
- uploadSourcemaps({
111
- apiUrl: 'https://api.example.com',
112
- paths: [dir],
113
- release: 'app@1.0.0+1',
114
- token: 'st_pk_x',
115
- }),
116
- ).rejects.toThrow('403 Forbidden — release frozen')
117
- } finally {
118
- globalThis.fetch = orig
119
- }
120
- })
121
- })
package/src/index.ts DELETED
@@ -1,496 +0,0 @@
1
- #!/usr/bin/env node
2
- import { parseArgs } from 'node:util'
3
-
4
- import { formatIssueLine, issueList, issuePatch } from './issue.js'
5
- import { uploadDsym, uploadMapping } from './native-artifacts.js'
6
- import { reactNativeUpload } from './react-native.js'
7
- import { uploadSourcemaps } from './upload.js'
8
-
9
- const HELP = `sentori-cli — Sentori command-line interface
10
-
11
- Source-map upload:
12
- sentori-cli upload sourcemap [options] <path...>
13
- Upload one or more files or directories. A directory is scanned
14
- (one level) for *.map / *.js / *.jsbundle / *.bundle / *.hbc;
15
- a file given explicitly is uploaded as-is. Use this when you
16
- already have a composed sourcemap on disk:
17
- - web bundlers (point at the build dir),
18
- - iOS post-\`react-native-xcode.sh\` where the build phase has
19
- already composed packager + Hermes maps into the final
20
- \`$SOURCEMAP_FILE\` and deleted the intermediates.
21
- Composed-then-uploaded vs raw-then-server-composed yield
22
- identical symbolication — the server stores the same shape
23
- either way.
24
-
25
- sentori-cli react-native upload [options]
26
- Compose a Metro packager map + a Hermes map into one source map
27
- (uses react-native's \`scripts/compose-source-maps.js\`) and
28
- upload the result. Use this when you have both raw maps on
29
- disk — typical Android release path where the gradle
30
- \`bundleReleaseJsAndAssets\` task leaves both maps untouched.
31
- Requires --metro-map and --hermes-map. On iOS the build phase
32
- deletes the intermediates, so use \`upload sourcemap\` instead.
33
-
34
- Native artifacts (project-scoped, need --project + admin token):
35
- sentori-cli upload dsym --project <uuid> [--release <r>] [--object-name <n>] [--debug-id <uuid> --arch <a>] <path>
36
- Upload iOS dSYM debug info. By default walks a Foo.dSYM bundle
37
- and uses "dwarfdump --uuid" to enumerate slices, uploading each.
38
- Pass --debug-id and --arch to upload a single slice without
39
- dwarfdump (useful in Linux CI where the toolchain isn't there).
40
-
41
- sentori-cli upload mapping --project <uuid> [--release <r>] [--debug-id <uuid>] <mapping.txt>
42
- Upload an R8 / ProGuard mapping (raw bytes). If the file starts
43
- with a "# pg_map_id:" line the server sniffs the debug-id from
44
- it; otherwise you can pass it explicitly.
45
-
46
- CI triage:
47
- sentori-cli issue list --project <uuid> [--status active|silenced|resolved|closed] [--limit N] [--error-type <t>]
48
- sentori-cli issue resolve <issue-uuid> --project <uuid> [--in-release <r>]
49
- sentori-cli issue silence <issue-uuid> --project <uuid>
50
-
51
- Options (upload commands):
52
- --release <r> release identifier — MUST equal the value the SDK
53
- reports via init({ release }). Required.
54
- --token <t> Sentori token (or set $SENTORI_TOKEN).
55
- --api-url <url> Sentori API base (default https://api.sentori.golia.jp,
56
- or $SENTORI_API_URL). For a self-hosted instance, your
57
- host. (Accepts --ingest-url as an alias.)
58
- --dry-run describe what would be uploaded; don't upload.
59
- -h, --help show this help.
60
-
61
- Options (react-native upload):
62
- --metro-map <p> the *.packager.map Metro emits (--sourcemap-output).
63
- --hermes-map <p> the *.hbc.map the Hermes compiler emits.
64
- --bundle <p> optional: also upload the bundle (.jsbundle / .bundle).
65
-
66
- Options (issue commands):
67
- --project <uuid> project id (or set $SENTORI_PROJECT_ID).
68
- --token <t> admin token, sk_… prefix (or $SENTORI_ADMIN_TOKEN /
69
- $SENTORI_TOKEN). The ingest st_pk_ token may also work
70
- on a self-hosted instance.
71
- --api-url <url> Sentori API base (same as above).
72
- --in-release <r> (resolve only) mark this release as where the fix
73
- landed; the regression detector flips the issue back
74
- to "regressed" if a matching event lands later.
75
-
76
- Hermes release build, by hand:
77
-
78
- Android (raw maps still on disk after \`./gradlew bundleRelease\`):
79
- npx @goliapkg/sentori-cli react-native upload \\
80
- --release "<app>@<version>+<build>" --token "$SENTORI_TOKEN" \\
81
- --metro-map android/app/build/intermediates/sourcemaps/react/release/index.android.bundle.packager.map \\
82
- --hermes-map android/app/build/intermediates/sourcemaps/react/release/index.android.bundle.compiler.map \\
83
- --bundle android/app/build/generated/assets/react/release/index.android.bundle
84
-
85
- iOS (already-composed map after \`xcodebuild archive\`; the build
86
- phase deletes intermediates so you only have the composed map):
87
- npx @goliapkg/sentori-cli upload sourcemap \\
88
- --release "<app>@<version>+<build>" --token "$SENTORI_TOKEN" \\
89
- "$BUILT_PRODUCTS_DIR/main.jsbundle.map" \\
90
- "$BUILT_PRODUCTS_DIR/main.jsbundle"
91
- `
92
-
93
- type Common = { apiUrl: string; dryRun: boolean; release: string; token: string }
94
-
95
- /** Parse the shared options, or print an error + return null. */
96
- function parseCommon(values: Record<string, unknown>): Common | null {
97
- const release = typeof values.release === 'string' ? values.release : undefined
98
- if (!release) {
99
- console.error('error: --release is required (must match the SDK’s init({ release }))')
100
- return null
101
- }
102
- const dryRun = values['dry-run'] === true
103
- const token =
104
- (typeof values.token === 'string' ? values.token : undefined) ?? process.env.SENTORI_TOKEN
105
- if (!token && !dryRun) {
106
- console.error('error: --token (or $SENTORI_TOKEN) is required')
107
- return null
108
- }
109
- const apiUrl =
110
- (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
111
- (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
112
- process.env.SENTORI_API_URL ??
113
- 'https://api.sentori.golia.jp'
114
- return { apiUrl, dryRun, release, token: token ?? '' }
115
- }
116
-
117
- async function cmdUploadSourcemap(argv: string[]): Promise<number> {
118
- let parsed
119
- try {
120
- parsed = parseArgs({
121
- allowPositionals: true,
122
- args: argv,
123
- options: {
124
- 'api-url': { type: 'string' },
125
- 'dry-run': { type: 'boolean' },
126
- help: { short: 'h', type: 'boolean' },
127
- 'ingest-url': { type: 'string' },
128
- release: { type: 'string' },
129
- token: { type: 'string' },
130
- },
131
- })
132
- } catch (e) {
133
- console.error(`error: ${(e as Error).message}\n`)
134
- console.error(HELP)
135
- return 2
136
- }
137
- if (parsed.values.help) {
138
- console.log(HELP)
139
- return 0
140
- }
141
- const c = parseCommon(parsed.values)
142
- if (!c) return 2
143
- if (parsed.positionals.length === 0) {
144
- console.error('error: at least one path (file or directory) is required')
145
- return 2
146
- }
147
- try {
148
- const result = await uploadSourcemaps({
149
- apiUrl: c.apiUrl,
150
- dryRun: c.dryRun,
151
- paths: parsed.positionals,
152
- release: c.release,
153
- token: c.token,
154
- })
155
- reportUpload(result, c)
156
- return 0
157
- } catch (e) {
158
- console.error(`upload failed: ${(e as Error).message}`)
159
- return 1
160
- }
161
- }
162
-
163
- async function cmdReactNativeUpload(argv: string[]): Promise<number> {
164
- let parsed
165
- try {
166
- parsed = parseArgs({
167
- args: argv,
168
- options: {
169
- 'api-url': { type: 'string' },
170
- bundle: { type: 'string' },
171
- 'dry-run': { type: 'boolean' },
172
- help: { short: 'h', type: 'boolean' },
173
- 'hermes-map': { type: 'string' },
174
- 'ingest-url': { type: 'string' },
175
- 'metro-map': { type: 'string' },
176
- release: { type: 'string' },
177
- token: { type: 'string' },
178
- },
179
- })
180
- } catch (e) {
181
- console.error(`error: ${(e as Error).message}\n`)
182
- console.error(HELP)
183
- return 2
184
- }
185
- if (parsed.values.help) {
186
- console.log(HELP)
187
- return 0
188
- }
189
- const c = parseCommon(parsed.values)
190
- if (!c) return 2
191
- const metroMap = parsed.values['metro-map']
192
- const hermesMap = parsed.values['hermes-map']
193
- if (typeof metroMap !== 'string' || typeof hermesMap !== 'string') {
194
- console.error('error: --metro-map and --hermes-map are both required')
195
- return 2
196
- }
197
- try {
198
- const result = await reactNativeUpload({
199
- apiUrl: c.apiUrl,
200
- bundle: typeof parsed.values.bundle === 'string' ? parsed.values.bundle : undefined,
201
- dryRun: c.dryRun,
202
- hermesMap,
203
- metroMap,
204
- release: c.release,
205
- token: c.token,
206
- })
207
- reportUpload(result, c)
208
- return 0
209
- } catch (e) {
210
- console.error(`react-native upload failed: ${(e as Error).message}`)
211
- return 1
212
- }
213
- }
214
-
215
- function reportUpload(
216
- result: { files: string[]; uploaded?: number },
217
- c: Common,
218
- ): void {
219
- if (c.dryRun) {
220
- console.log(
221
- `would upload ${result.files.length} file(s) to ${c.apiUrl.replace(/\/+$/, '')}/admin/api/releases/${encodeURIComponent(c.release)}/sourcemaps:`,
222
- )
223
- for (const f of result.files) console.log(` ${f}`)
224
- } else {
225
- console.log(
226
- `uploaded ${result.uploaded ?? result.files.length} file(s) for release "${c.release}" — minified stacks on this release will now resolve to source.`,
227
- )
228
- }
229
- }
230
-
231
- // ── issue commands ────────────────────────────────────────────────
232
-
233
- type AdminCfg = { apiUrl: string; projectId: string; token: string }
234
-
235
- function parseAdminCfg(values: Record<string, unknown>): AdminCfg | null {
236
- const projectId =
237
- (typeof values.project === 'string' ? values.project : undefined) ??
238
- process.env.SENTORI_PROJECT_ID
239
- if (!projectId) {
240
- console.error('error: --project <uuid> (or $SENTORI_PROJECT_ID) is required')
241
- return null
242
- }
243
- const token =
244
- (typeof values.token === 'string' ? values.token : undefined) ??
245
- process.env.SENTORI_ADMIN_TOKEN ??
246
- process.env.SENTORI_TOKEN
247
- if (!token) {
248
- console.error(
249
- 'error: --token (or $SENTORI_ADMIN_TOKEN / $SENTORI_TOKEN) is required for issue commands',
250
- )
251
- return null
252
- }
253
- const apiUrl =
254
- (typeof values['api-url'] === 'string' ? values['api-url'] : undefined) ??
255
- (typeof values['ingest-url'] === 'string' ? values['ingest-url'] : undefined) ??
256
- process.env.SENTORI_API_URL ??
257
- 'https://api.sentori.golia.jp'
258
- return { apiUrl, projectId, token }
259
- }
260
-
261
- async function cmdIssueList(argv: string[]): Promise<number> {
262
- let parsed
263
- try {
264
- parsed = parseArgs({
265
- args: argv,
266
- options: {
267
- 'api-url': { type: 'string' },
268
- 'error-type': { type: 'string' },
269
- help: { short: 'h', type: 'boolean' },
270
- 'ingest-url': { type: 'string' },
271
- limit: { type: 'string' },
272
- project: { type: 'string' },
273
- status: { type: 'string' },
274
- token: { type: 'string' },
275
- },
276
- })
277
- } catch (e) {
278
- console.error(`error: ${(e as Error).message}\n${HELP}`)
279
- return 2
280
- }
281
- if (parsed.values.help) {
282
- console.log(HELP)
283
- return 0
284
- }
285
- const cfg = parseAdminCfg(parsed.values)
286
- if (!cfg) return 2
287
- const status = parsed.values.status
288
- if (status && !['active', 'closed', 'resolved', 'silenced'].includes(status)) {
289
- console.error(`error: --status must be one of: active, silenced, resolved, closed`)
290
- return 2
291
- }
292
- const limitStr = parsed.values.limit
293
- const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined
294
- try {
295
- const rows = await issueList({
296
- config: cfg,
297
- errorType: parsed.values['error-type'],
298
- limit,
299
- status: status as 'active' | 'closed' | 'resolved' | 'silenced' | undefined,
300
- })
301
- if (rows.length === 0) {
302
- console.log('(no matching issues)')
303
- return 0
304
- }
305
- for (const r of rows) console.log(formatIssueLine(r))
306
- return 0
307
- } catch (e) {
308
- console.error(`issue list failed: ${(e as Error).message}`)
309
- return 1
310
- }
311
- }
312
-
313
- async function cmdIssuePatch(
314
- argv: string[],
315
- body: { resolvedInRelease?: string; status: 'active' | 'closed' | 'resolved' | 'silenced' },
316
- verb: 'closed' | 'resolved' | 'silenced',
317
- ): Promise<number> {
318
- let parsed
319
- try {
320
- parsed = parseArgs({
321
- allowPositionals: true,
322
- args: argv,
323
- options: {
324
- 'api-url': { type: 'string' },
325
- help: { short: 'h', type: 'boolean' },
326
- 'in-release': { type: 'string' },
327
- 'ingest-url': { type: 'string' },
328
- project: { type: 'string' },
329
- token: { type: 'string' },
330
- },
331
- })
332
- } catch (e) {
333
- console.error(`error: ${(e as Error).message}\n${HELP}`)
334
- return 2
335
- }
336
- if (parsed.values.help) {
337
- console.log(HELP)
338
- return 0
339
- }
340
- const cfg = parseAdminCfg(parsed.values)
341
- if (!cfg) return 2
342
- const issueId = parsed.positionals[0]
343
- if (!issueId) {
344
- console.error('error: <issue-uuid> is required')
345
- return 2
346
- }
347
- if (verb === 'resolved' && typeof parsed.values['in-release'] === 'string') {
348
- body.resolvedInRelease = parsed.values['in-release']
349
- }
350
- try {
351
- const updated = await issuePatch(cfg, issueId, body)
352
- console.log(
353
- `${issueId} → ${verb}${body.resolvedInRelease ? ` (in ${body.resolvedInRelease})` : ''}: ${updated.errorType}`,
354
- )
355
- return 0
356
- } catch (e) {
357
- console.error(`issue ${verb} failed: ${(e as Error).message}`)
358
- return 1
359
- }
360
- }
361
-
362
- // ── native artifact upload ────────────────────────────────────────
363
-
364
- async function cmdUploadDsym(argv: string[]): Promise<number> {
365
- let parsed
366
- try {
367
- parsed = parseArgs({
368
- allowPositionals: true,
369
- args: argv,
370
- options: {
371
- 'api-url': { type: 'string' },
372
- arch: { type: 'string' },
373
- 'debug-id': { type: 'string' },
374
- help: { short: 'h', type: 'boolean' },
375
- 'ingest-url': { type: 'string' },
376
- 'object-name': { type: 'string' },
377
- project: { type: 'string' },
378
- release: { type: 'string' },
379
- token: { type: 'string' },
380
- },
381
- })
382
- } catch (e) {
383
- console.error(`error: ${(e as Error).message}\n${HELP}`)
384
- return 2
385
- }
386
- if (parsed.values.help) {
387
- console.log(HELP)
388
- return 0
389
- }
390
- const cfg = parseAdminCfg(parsed.values)
391
- if (!cfg) return 2
392
- const path = parsed.positionals[0]
393
- if (!path) {
394
- console.error('error: a path to a .dSYM bundle or DWARF binary is required')
395
- return 2
396
- }
397
- const debugId = parsed.values['debug-id']
398
- const arch = parsed.values.arch
399
- if ((debugId && !arch) || (arch && !debugId)) {
400
- console.error('error: --debug-id and --arch must be passed together (or both omitted)')
401
- return 2
402
- }
403
- try {
404
- const r = await uploadDsym({
405
- apiUrl: cfg.apiUrl,
406
- arch: typeof arch === 'string' ? arch : undefined,
407
- debugId: typeof debugId === 'string' ? debugId : undefined,
408
- objectName: typeof parsed.values['object-name'] === 'string' ? parsed.values['object-name'] : undefined,
409
- path,
410
- projectId: cfg.projectId,
411
- release: typeof parsed.values.release === 'string' ? parsed.values.release : undefined,
412
- token: cfg.token,
413
- })
414
- console.log(`uploaded ${r.slices.length} dSYM slice(s):`)
415
- for (const s of r.slices) console.log(` ${s.debugId} (${s.arch})`)
416
- return 0
417
- } catch (e) {
418
- console.error(`dsym upload failed: ${(e as Error).message}`)
419
- return 1
420
- }
421
- }
422
-
423
- async function cmdUploadMapping(argv: string[]): Promise<number> {
424
- let parsed
425
- try {
426
- parsed = parseArgs({
427
- allowPositionals: true,
428
- args: argv,
429
- options: {
430
- 'api-url': { type: 'string' },
431
- 'debug-id': { type: 'string' },
432
- help: { short: 'h', type: 'boolean' },
433
- 'ingest-url': { type: 'string' },
434
- project: { type: 'string' },
435
- release: { type: 'string' },
436
- token: { type: 'string' },
437
- },
438
- })
439
- } catch (e) {
440
- console.error(`error: ${(e as Error).message}\n${HELP}`)
441
- return 2
442
- }
443
- if (parsed.values.help) {
444
- console.log(HELP)
445
- return 0
446
- }
447
- const cfg = parseAdminCfg(parsed.values)
448
- if (!cfg) return 2
449
- const path = parsed.positionals[0]
450
- if (!path) {
451
- console.error('error: a path to mapping.txt is required')
452
- return 2
453
- }
454
- try {
455
- await uploadMapping({
456
- apiUrl: cfg.apiUrl,
457
- debugId: typeof parsed.values['debug-id'] === 'string' ? parsed.values['debug-id'] : undefined,
458
- path,
459
- projectId: cfg.projectId,
460
- release: typeof parsed.values.release === 'string' ? parsed.values.release : undefined,
461
- token: cfg.token,
462
- })
463
- console.log(`uploaded mapping for project ${cfg.projectId}${parsed.values.release ? ` / ${parsed.values.release}` : ''}`)
464
- return 0
465
- } catch (e) {
466
- console.error(`mapping upload failed: ${(e as Error).message}`)
467
- return 1
468
- }
469
- }
470
-
471
- async function main(argv: string[]): Promise<number> {
472
- if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
473
- console.log(HELP)
474
- return 0
475
- }
476
- const [a, b, ...rest] = argv
477
- if (a === 'upload' && b === 'sourcemap') return cmdUploadSourcemap(rest)
478
- if (a === 'upload' && b === 'dsym') return cmdUploadDsym(rest)
479
- if (a === 'upload' && b === 'mapping') return cmdUploadMapping(rest)
480
- if (a === 'react-native' && b === 'upload') return cmdReactNativeUpload(rest)
481
- if (a === 'issue' && b === 'list') return cmdIssueList(rest)
482
- if (a === 'issue' && b === 'resolve') return cmdIssuePatch(rest, { status: 'resolved' }, 'resolved')
483
- if (a === 'issue' && b === 'silence') return cmdIssuePatch(rest, { status: 'silenced' }, 'silenced')
484
- if (a === 'issue' && b === 'close') return cmdIssuePatch(rest, { status: 'closed' }, 'closed')
485
- console.error(`unknown command: ${[a, b].filter(Boolean).join(' ') || '(none)'}\n`)
486
- console.error(HELP)
487
- return 2
488
- }
489
-
490
- main(process.argv.slice(2)).then(
491
- (code) => process.exit(code),
492
- (e: unknown) => {
493
- console.error(`fatal: ${(e as Error).message}`)
494
- process.exit(1)
495
- },
496
- )
package/src/issue.ts DELETED
@@ -1,81 +0,0 @@
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
- }