@goliapkg/sentori-cli 0.6.0 → 1.1.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.
- package/lib/index.js +269 -340
- package/lib/index.js.map +1 -1
- package/lib/issue.d.ts +16 -18
- package/lib/issue.d.ts.map +1 -1
- package/lib/issue.js +29 -32
- package/lib/issue.js.map +1 -1
- package/lib/lenient.d.ts +13 -0
- package/lib/lenient.d.ts.map +1 -0
- package/lib/lenient.js +25 -0
- package/lib/lenient.js.map +1 -0
- package/lib/mcp.d.ts +4 -2
- package/lib/mcp.d.ts.map +1 -1
- package/lib/mcp.js +43 -220
- package/lib/mcp.js.map +1 -1
- package/lib/native-artifacts.d.ts +0 -1
- package/lib/native-artifacts.d.ts.map +1 -1
- package/lib/native-artifacts.js +34 -41
- package/lib/native-artifacts.js.map +1 -1
- package/lib/probes.d.ts +10 -0
- package/lib/probes.d.ts.map +1 -0
- package/lib/probes.js +75 -0
- package/lib/probes.js.map +1 -0
- package/lib/react-native.d.ts.map +1 -1
- package/lib/react-native.js +17 -9
- package/lib/react-native.js.map +1 -1
- package/lib/upload.d.ts +21 -20
- package/lib/upload.d.ts.map +1 -1
- package/lib/upload.js +65 -57
- package/lib/upload.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/lenient.test.ts +32 -0
- package/src/__tests__/mcp.test.ts +20 -112
- package/src/__tests__/native-artifacts.test.ts +43 -12
- package/src/__tests__/probes.test.ts +41 -0
- package/src/index.ts +291 -362
- package/src/issue.ts +47 -46
- package/src/lenient.ts +39 -0
- package/src/mcp.ts +46 -215
- package/src/native-artifacts.ts +42 -44
- package/src/probes.ts +75 -0
- package/src/react-native.ts +16 -9
- package/src/upload.ts +78 -68
- package/src/__tests__/issue.test.ts +0 -95
- package/src/__tests__/source-bundle-from-dir.test.ts +0 -85
- package/src/__tests__/source-bundle.test.ts +0 -105
- package/src/__tests__/upload.test.ts +0 -121
- package/src/source-bundle.ts +0 -234
package/src/issue.ts
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
// `sentori-cli issue list / resolve
|
|
1
|
+
// `sentori-cli issue list / resolve` — CI triage over the AI
|
|
2
|
+
// surface (`/api/*`, api-scope token; the same loop an agent runs).
|
|
2
3
|
|
|
3
4
|
type Issue = {
|
|
4
|
-
errorType: string
|
|
5
|
-
eventCount: number
|
|
6
5
|
id: string
|
|
7
|
-
|
|
6
|
+
kind: string
|
|
7
|
+
title: string
|
|
8
8
|
messageSample: string
|
|
9
|
-
status:
|
|
9
|
+
status: string
|
|
10
|
+
eventCount: number
|
|
11
|
+
usersCount: number
|
|
12
|
+
maxPerUser: number
|
|
13
|
+
lastSeen: string
|
|
14
|
+
regressed: boolean
|
|
10
15
|
}
|
|
11
16
|
|
|
12
|
-
type
|
|
17
|
+
export type ApiConfig = {
|
|
13
18
|
apiUrl: string
|
|
14
|
-
projectId: string
|
|
15
19
|
token: string
|
|
16
20
|
}
|
|
17
21
|
|
|
18
|
-
function
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function adminFetch<T>(c: AdminConfig, path: string, init?: RequestInit): Promise<T> {
|
|
23
|
-
const resp = await fetch(url(c, path), {
|
|
22
|
+
async function apiFetch<T>(c: ApiConfig, path: string, init?: RequestInit): Promise<T> {
|
|
23
|
+
const resp = await fetch(`${c.apiUrl.replace(/\/+$/, '')}${path}`, {
|
|
24
24
|
...init,
|
|
25
25
|
headers: {
|
|
26
26
|
Authorization: `Bearer ${c.token}`,
|
|
@@ -29,53 +29,54 @@ async function adminFetch<T>(c: AdminConfig, path: string, init?: RequestInit):
|
|
|
29
29
|
},
|
|
30
30
|
})
|
|
31
31
|
if (!resp.ok) {
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
detail = await resp.text()
|
|
35
|
-
} catch {
|
|
36
|
-
// ignore
|
|
37
|
-
}
|
|
32
|
+
const detail = await resp.text().catch(() => '')
|
|
38
33
|
throw new Error(
|
|
39
34
|
`${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
|
|
40
35
|
)
|
|
41
36
|
}
|
|
42
|
-
|
|
43
|
-
// content — handle both.
|
|
44
|
-
const txt = await resp.text()
|
|
45
|
-
return (txt ? JSON.parse(txt) : null) as T
|
|
37
|
+
return (await resp.json()) as T
|
|
46
38
|
}
|
|
47
39
|
|
|
48
|
-
export
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
status?: 'active' | 'closed' | 'resolved' | 'silenced'
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export async function issueList(opts: IssueListOptions): Promise<Issue[]> {
|
|
40
|
+
export async function listIssues(
|
|
41
|
+
c: ApiConfig,
|
|
42
|
+
opts: { status?: string; kind?: string } = {},
|
|
43
|
+
): Promise<Issue[]> {
|
|
56
44
|
const q = new URLSearchParams()
|
|
57
45
|
if (opts.status) q.set('status', opts.status)
|
|
58
|
-
if (opts.
|
|
59
|
-
if (opts.errorType) q.set('errorType', opts.errorType)
|
|
46
|
+
if (opts.kind) q.set('kind', opts.kind)
|
|
60
47
|
const qs = q.toString()
|
|
61
|
-
|
|
48
|
+
const body = await apiFetch<{ issues: Issue[] }>(c, `/api/issues${qs ? `?${qs}` : ''}`)
|
|
49
|
+
return body.issues
|
|
62
50
|
}
|
|
63
51
|
|
|
64
|
-
export async function
|
|
65
|
-
|
|
52
|
+
export async function resolveIssue(
|
|
53
|
+
c: ApiConfig,
|
|
66
54
|
issueId: string,
|
|
67
|
-
|
|
68
|
-
): Promise<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
55
|
+
release?: string,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
await apiFetch(c, `/api/issues/${encodeURIComponent(issueId)}/resolve`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
body: JSON.stringify(release ? { release } : {}),
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function noteIssue(c: ApiConfig, issueId: string, body: string): Promise<void> {
|
|
64
|
+
await apiFetch(c, `/api/issues/${encodeURIComponent(issueId)}/notes`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
body: JSON.stringify({ body }),
|
|
72
67
|
})
|
|
73
68
|
}
|
|
74
69
|
|
|
75
|
-
|
|
70
|
+
export async function fetchBundle(c: ApiConfig, issueId: string): Promise<string> {
|
|
71
|
+
const resp = await fetch(
|
|
72
|
+
`${c.apiUrl.replace(/\/+$/, '')}/api/issues/${encodeURIComponent(issueId)}/bundle`,
|
|
73
|
+
{ headers: { Authorization: `Bearer ${c.token}` } },
|
|
74
|
+
)
|
|
75
|
+
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`)
|
|
76
|
+
return resp.text()
|
|
77
|
+
}
|
|
78
|
+
|
|
76
79
|
export function formatIssueLine(i: Issue): string {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
const events = `${i.eventCount}×`
|
|
80
|
-
return `${i.id} ${status} ${title.slice(0, 80).padEnd(80)} ${events}`
|
|
80
|
+
const flag = i.regressed ? ' REGRESSED' : ''
|
|
81
|
+
return `${i.id} [${i.kind}] ${i.title} ${i.usersCount}u×${i.maxPerUser} ${i.eventCount}ev${flag}`
|
|
81
82
|
}
|
package/src/lenient.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The iron rule reaches build-time (design.md §6 dim 3): an upload
|
|
2
|
+
// failure must never break the customer's build or release. Every
|
|
3
|
+
// upload-family command runs through this wrapper — on failure it
|
|
4
|
+
// prints a friendly, actionable note and exits 0. `--strict` opts
|
|
5
|
+
// into a real non-zero exit for pipelines that want the hard
|
|
6
|
+
// guarantee.
|
|
7
|
+
|
|
8
|
+
export type LenientOutcome = {
|
|
9
|
+
/** What failed, one line. */
|
|
10
|
+
failure: string
|
|
11
|
+
/** What it means for the customer, one or two lines. */
|
|
12
|
+
impact: string
|
|
13
|
+
/** A copy-pasteable retry command. */
|
|
14
|
+
retry: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const isStrict = (argv: string[]): boolean => argv.includes('--strict')
|
|
18
|
+
|
|
19
|
+
/** Strip --strict before handing argv to parseArgs. */
|
|
20
|
+
export const stripStrict = (argv: string[]): string[] =>
|
|
21
|
+
argv.filter((a) => a !== '--strict')
|
|
22
|
+
|
|
23
|
+
export const lenientFail = (strict: boolean, o: LenientOutcome): number => {
|
|
24
|
+
console.error(`[sentori] ⚠ ${o.failure}`)
|
|
25
|
+
if (!strict) {
|
|
26
|
+
console.error('[sentori] Your build is NOT blocked by this.')
|
|
27
|
+
}
|
|
28
|
+
console.error(`[sentori] Impact: ${o.impact}`)
|
|
29
|
+
console.error('[sentori] Retry anytime:')
|
|
30
|
+
console.error(`[sentori] ${o.retry}`)
|
|
31
|
+
console.error(
|
|
32
|
+
'[sentori] Events received in the meantime are re-symbolicated automatically after upload.',
|
|
33
|
+
)
|
|
34
|
+
if (strict) {
|
|
35
|
+
console.error('[sentori] (--strict: exiting non-zero)')
|
|
36
|
+
return 1
|
|
37
|
+
}
|
|
38
|
+
return 0
|
|
39
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -19,8 +19,6 @@
|
|
|
19
19
|
|
|
20
20
|
import { createInterface } from 'node:readline'
|
|
21
21
|
|
|
22
|
-
import type { AdminUpload } from './native-artifacts.js'
|
|
23
|
-
|
|
24
22
|
type JsonRpcRequest = {
|
|
25
23
|
id?: number | string | null
|
|
26
24
|
jsonrpc: '2.0'
|
|
@@ -44,7 +42,7 @@ type ToolDef = {
|
|
|
44
42
|
handler: ToolHandler
|
|
45
43
|
}
|
|
46
44
|
|
|
47
|
-
type McpCtx =
|
|
45
|
+
type McpCtx = { apiUrl: string; token: string }
|
|
48
46
|
|
|
49
47
|
/** Run the MCP server over stdio. Returns when stdin closes. */
|
|
50
48
|
export async function runMcpServer(ctx: McpCtx): Promise<void> {
|
|
@@ -135,265 +133,98 @@ async function dispatch(
|
|
|
135
133
|
}
|
|
136
134
|
}
|
|
137
135
|
|
|
138
|
-
// ── Tool implementations
|
|
136
|
+
// ── Tool implementations — the /api closed loop ──────────────────
|
|
137
|
+
//
|
|
138
|
+
// Four tools, mirroring exactly what an agent needs (design.md §9):
|
|
139
|
+
// pick work, pull the evidence, write back, resolve. The bundle is
|
|
140
|
+
// the product; everything else is triage plumbing.
|
|
139
141
|
|
|
140
|
-
async function
|
|
141
|
-
const url = `${ctx.apiUrl.replace(/\/+$/, '')}
|
|
142
|
-
const resp = await fetch(url, {
|
|
143
|
-
headers: { Authorization: `Bearer ${ctx.token}` },
|
|
144
|
-
})
|
|
142
|
+
async function apiGet(ctx: McpCtx, path: string, raw = false): Promise<unknown> {
|
|
143
|
+
const url = `${ctx.apiUrl.replace(/\/+$/, '')}${path}`
|
|
144
|
+
const resp = await fetch(url, { headers: { Authorization: `Bearer ${ctx.token}` } })
|
|
145
145
|
if (!resp.ok) throw new Error(`GET ${path} → ${resp.status} ${resp.statusText}`)
|
|
146
|
-
return (
|
|
146
|
+
return raw ? resp.text() : resp.json()
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
async function
|
|
150
|
-
const url = `${ctx.apiUrl.replace(/\/+$/, '')}
|
|
149
|
+
async function apiPost(ctx: McpCtx, path: string, body: unknown): Promise<unknown> {
|
|
150
|
+
const url = `${ctx.apiUrl.replace(/\/+$/, '')}${path}`
|
|
151
151
|
const resp = await fetch(url, {
|
|
152
152
|
body: JSON.stringify(body),
|
|
153
153
|
headers: {
|
|
154
154
|
Authorization: `Bearer ${ctx.token}`,
|
|
155
155
|
'Content-Type': 'application/json',
|
|
156
156
|
},
|
|
157
|
-
method: 'PATCH',
|
|
158
|
-
})
|
|
159
|
-
if (!resp.ok) throw new Error(`PATCH ${path} → ${resp.status} ${resp.statusText}`)
|
|
160
|
-
return (await resp.json()) as T
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async function adminPost<T>(ctx: McpCtx, path: string, body: unknown): Promise<T> {
|
|
164
|
-
const url = `${ctx.apiUrl.replace(/\/+$/, '')}/admin/api${path}`
|
|
165
|
-
const resp = await fetch(url, {
|
|
166
|
-
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
167
|
-
headers: {
|
|
168
|
-
Authorization: `Bearer ${ctx.token}`,
|
|
169
|
-
'Content-Type': 'application/json',
|
|
170
|
-
},
|
|
171
157
|
method: 'POST',
|
|
172
158
|
})
|
|
173
159
|
if (!resp.ok) throw new Error(`POST ${path} → ${resp.status} ${resp.statusText}`)
|
|
174
|
-
|
|
175
|
-
return (await resp.json()) as T
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async function adminPut<T>(ctx: McpCtx, path: string): Promise<T> {
|
|
179
|
-
const url = `${ctx.apiUrl.replace(/\/+$/, '')}/admin/api${path}`
|
|
180
|
-
const resp = await fetch(url, {
|
|
181
|
-
headers: { Authorization: `Bearer ${ctx.token}` },
|
|
182
|
-
method: 'PUT',
|
|
183
|
-
})
|
|
184
|
-
if (!resp.ok) throw new Error(`PUT ${path} → ${resp.status} ${resp.statusText}`)
|
|
185
|
-
if (resp.status === 204) return null as T
|
|
186
|
-
return (await resp.json()) as T
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
async function adminDelete<T>(ctx: McpCtx, path: string): Promise<T> {
|
|
190
|
-
const url = `${ctx.apiUrl.replace(/\/+$/, '')}/admin/api${path}`
|
|
191
|
-
const resp = await fetch(url, {
|
|
192
|
-
headers: { Authorization: `Bearer ${ctx.token}` },
|
|
193
|
-
method: 'DELETE',
|
|
194
|
-
})
|
|
195
|
-
if (!resp.ok) throw new Error(`DELETE ${path} → ${resp.status} ${resp.statusText}`)
|
|
196
|
-
if (resp.status === 204) return null as T
|
|
197
|
-
return (await resp.json()) as T
|
|
160
|
+
return resp.json()
|
|
198
161
|
}
|
|
199
162
|
|
|
200
163
|
function asString(v: unknown, name: string): string {
|
|
201
|
-
if (typeof v !== 'string' || v.length === 0) {
|
|
202
|
-
throw new Error(`${name} is required (string)`)
|
|
203
|
-
}
|
|
204
|
-
return v
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function asOptionalString(v: unknown): string | undefined {
|
|
208
|
-
if (v === undefined || v === null) return undefined
|
|
209
|
-
if (typeof v !== 'string') throw new Error('expected string')
|
|
164
|
+
if (typeof v !== 'string' || v.length === 0) throw new Error(`${name} must be a non-empty string`)
|
|
210
165
|
return v
|
|
211
166
|
}
|
|
212
167
|
|
|
213
168
|
export function buildTools(): ToolDef[] {
|
|
214
169
|
return [
|
|
215
170
|
{
|
|
171
|
+
name: 'sentori_issue_list',
|
|
216
172
|
description:
|
|
217
|
-
'List issues
|
|
218
|
-
handler: async (args, ctx) => {
|
|
219
|
-
const projectId = asString(args.projectId, 'projectId')
|
|
220
|
-
const usp = new URLSearchParams()
|
|
221
|
-
const status = asOptionalString(args.status) ?? 'any'
|
|
222
|
-
usp.set('status', status)
|
|
223
|
-
if (args.priority) usp.set('priority', String(args.priority))
|
|
224
|
-
if (args.label) usp.set('labels', String(args.label))
|
|
225
|
-
if (typeof args.limit === 'number') usp.set('limit', String(args.limit))
|
|
226
|
-
return await adminGet(ctx, `/projects/${projectId}/issues?${usp}`)
|
|
227
|
-
},
|
|
173
|
+
'List issues (default: open, ordered by objective importance — regressed first, then breadth × depth). Filter with status (open|resolved|ignored) and kind (error|warn|trace|assert|probe).',
|
|
228
174
|
inputSchema: {
|
|
229
|
-
properties: {
|
|
230
|
-
label: { type: 'string' },
|
|
231
|
-
limit: { type: 'number' },
|
|
232
|
-
priority: { type: 'string' },
|
|
233
|
-
projectId: { type: 'string' },
|
|
234
|
-
status: { type: 'string' },
|
|
235
|
-
},
|
|
236
|
-
required: ['projectId'],
|
|
237
175
|
type: 'object',
|
|
238
|
-
},
|
|
239
|
-
name: 'sentori_issue_list',
|
|
240
|
-
},
|
|
241
|
-
{
|
|
242
|
-
description: 'Get full detail for one Sentori issue including its activity feed.',
|
|
243
|
-
handler: async (args, ctx) => {
|
|
244
|
-
const projectId = asString(args.projectId, 'projectId')
|
|
245
|
-
const issueId = asString(args.issueId, 'issueId')
|
|
246
|
-
const [issue, activity] = await Promise.all([
|
|
247
|
-
adminGet(ctx, `/projects/${projectId}/issues/${issueId}`),
|
|
248
|
-
adminGet(ctx, `/projects/${projectId}/issues/${issueId}/activity`),
|
|
249
|
-
])
|
|
250
|
-
return { activity, issue }
|
|
251
|
-
},
|
|
252
|
-
inputSchema: {
|
|
253
176
|
properties: {
|
|
254
|
-
|
|
255
|
-
|
|
177
|
+
status: { type: 'string', enum: ['open', 'resolved', 'ignored'] },
|
|
178
|
+
kind: { type: 'string', enum: ['assert', 'error', 'probe', 'trace', 'warn'] },
|
|
256
179
|
},
|
|
257
|
-
required: ['projectId', 'issueId'],
|
|
258
|
-
type: 'object',
|
|
259
180
|
},
|
|
260
|
-
name: 'sentori_issue_get',
|
|
261
|
-
},
|
|
262
|
-
{
|
|
263
|
-
description: 'Add a comment to a Sentori issue.',
|
|
264
181
|
handler: async (args, ctx) => {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
})
|
|
271
|
-
},
|
|
272
|
-
inputSchema: {
|
|
273
|
-
properties: {
|
|
274
|
-
body: { type: 'string' },
|
|
275
|
-
issueId: { type: 'string' },
|
|
276
|
-
projectId: { type: 'string' },
|
|
277
|
-
},
|
|
278
|
-
required: ['projectId', 'issueId', 'body'],
|
|
279
|
-
type: 'object',
|
|
182
|
+
const q = new URLSearchParams()
|
|
183
|
+
if (typeof args.status === 'string') q.set('status', args.status)
|
|
184
|
+
if (typeof args.kind === 'string') q.set('kind', args.kind)
|
|
185
|
+
const qs = q.toString()
|
|
186
|
+
return apiGet(ctx, `/api/issues${qs ? `?${qs}` : ''}`)
|
|
280
187
|
},
|
|
281
|
-
name: 'sentori_issue_comment',
|
|
282
188
|
},
|
|
283
189
|
{
|
|
190
|
+
name: 'sentori_issue_bundle',
|
|
284
191
|
description:
|
|
285
|
-
'
|
|
286
|
-
handler: async (args, ctx) => {
|
|
287
|
-
const projectId = asString(args.projectId, 'projectId')
|
|
288
|
-
const issueId = asString(args.issueId, 'issueId')
|
|
289
|
-
const status = asString(args.status, 'status')
|
|
290
|
-
return await adminPatch(ctx, `/projects/${projectId}/issues/${issueId}`, {
|
|
291
|
-
status,
|
|
292
|
-
})
|
|
293
|
-
},
|
|
192
|
+
'Fetch the full evidence bundle for one issue as markdown — read it and you have everything needed to fix: stack, user timeline, environment, distribution, guard-probe status.',
|
|
294
193
|
inputSchema: {
|
|
295
|
-
properties: {
|
|
296
|
-
issueId: { type: 'string' },
|
|
297
|
-
projectId: { type: 'string' },
|
|
298
|
-
status: {
|
|
299
|
-
enum: ['active', 'silenced', 'muted', 'resolved', 'closed'],
|
|
300
|
-
type: 'string',
|
|
301
|
-
},
|
|
302
|
-
},
|
|
303
|
-
required: ['projectId', 'issueId', 'status'],
|
|
304
194
|
type: 'object',
|
|
195
|
+
properties: { issueId: { type: 'string' } },
|
|
196
|
+
required: ['issueId'],
|
|
305
197
|
},
|
|
306
|
-
|
|
198
|
+
handler: async (args, ctx) =>
|
|
199
|
+
apiGet(ctx, `/api/issues/${encodeURIComponent(asString(args.issueId, 'issueId'))}/bundle`, true),
|
|
307
200
|
},
|
|
308
201
|
{
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const issueId = asString(args.issueId, 'issueId')
|
|
313
|
-
const userId = args.userId === null ? null : asOptionalString(args.userId)
|
|
314
|
-
return await adminPatch(ctx, `/projects/${projectId}/issues/${issueId}`, {
|
|
315
|
-
assigneeUserId: userId ?? null,
|
|
316
|
-
})
|
|
317
|
-
},
|
|
318
|
-
inputSchema: {
|
|
319
|
-
properties: {
|
|
320
|
-
issueId: { type: 'string' },
|
|
321
|
-
projectId: { type: 'string' },
|
|
322
|
-
userId: { type: ['string', 'null'] },
|
|
323
|
-
},
|
|
324
|
-
required: ['projectId', 'issueId', 'userId'],
|
|
325
|
-
type: 'object',
|
|
326
|
-
},
|
|
327
|
-
name: 'sentori_issue_assign',
|
|
328
|
-
},
|
|
329
|
-
{
|
|
330
|
-
description: 'Set the triage priority on an issue.',
|
|
331
|
-
handler: async (args, ctx) => {
|
|
332
|
-
const projectId = asString(args.projectId, 'projectId')
|
|
333
|
-
const issueId = asString(args.issueId, 'issueId')
|
|
334
|
-
const priority = asString(args.priority, 'priority')
|
|
335
|
-
return await adminPatch(ctx, `/projects/${projectId}/issues/${issueId}`, {
|
|
336
|
-
priority,
|
|
337
|
-
})
|
|
338
|
-
},
|
|
339
|
-
inputSchema: {
|
|
340
|
-
properties: {
|
|
341
|
-
issueId: { type: 'string' },
|
|
342
|
-
priority: { enum: ['p0', 'p1', 'p2', 'p3'], type: 'string' },
|
|
343
|
-
projectId: { type: 'string' },
|
|
344
|
-
},
|
|
345
|
-
required: ['projectId', 'issueId', 'priority'],
|
|
346
|
-
type: 'object',
|
|
347
|
-
},
|
|
348
|
-
name: 'sentori_issue_set_priority',
|
|
349
|
-
},
|
|
350
|
-
{
|
|
351
|
-
description: 'Replace the label set on an issue. Pass [] to clear all.',
|
|
352
|
-
handler: async (args, ctx) => {
|
|
353
|
-
const projectId = asString(args.projectId, 'projectId')
|
|
354
|
-
const issueId = asString(args.issueId, 'issueId')
|
|
355
|
-
if (!Array.isArray(args.labels)) throw new Error('labels must be string[]')
|
|
356
|
-
const labels = args.labels.map((l) => {
|
|
357
|
-
if (typeof l !== 'string') throw new Error('each label must be a string')
|
|
358
|
-
return l
|
|
359
|
-
})
|
|
360
|
-
return await adminPatch(ctx, `/projects/${projectId}/issues/${issueId}`, {
|
|
361
|
-
labels,
|
|
362
|
-
})
|
|
363
|
-
},
|
|
202
|
+
name: 'sentori_issue_note',
|
|
203
|
+
description:
|
|
204
|
+
'Append a note to an issue — write back what you did ("fixed in abc123, probe SENT-42 planted").',
|
|
364
205
|
inputSchema: {
|
|
365
|
-
properties: {
|
|
366
|
-
issueId: { type: 'string' },
|
|
367
|
-
labels: { items: { type: 'string' }, type: 'array' },
|
|
368
|
-
projectId: { type: 'string' },
|
|
369
|
-
},
|
|
370
|
-
required: ['projectId', 'issueId', 'labels'],
|
|
371
206
|
type: 'object',
|
|
207
|
+
properties: { issueId: { type: 'string' }, body: { type: 'string' } },
|
|
208
|
+
required: ['issueId', 'body'],
|
|
372
209
|
},
|
|
373
|
-
|
|
210
|
+
handler: async (args, ctx) =>
|
|
211
|
+
apiPost(ctx, `/api/issues/${encodeURIComponent(asString(args.issueId, 'issueId'))}/notes`, {
|
|
212
|
+
body: asString(args.body, 'body'),
|
|
213
|
+
}),
|
|
374
214
|
},
|
|
375
215
|
{
|
|
216
|
+
name: 'sentori_issue_resolve',
|
|
376
217
|
description:
|
|
377
|
-
'
|
|
378
|
-
handler: async (args, ctx) => {
|
|
379
|
-
const projectId = asString(args.projectId, 'projectId')
|
|
380
|
-
const issueId = asString(args.issueId, 'issueId')
|
|
381
|
-
const watch = args.watch === true
|
|
382
|
-
if (watch) {
|
|
383
|
-
return await adminPut(ctx, `/projects/${projectId}/issues/${issueId}/watch`)
|
|
384
|
-
}
|
|
385
|
-
return await adminDelete(ctx, `/projects/${projectId}/issues/${issueId}/watch`)
|
|
386
|
-
},
|
|
218
|
+
'Resolve an issue, anchored on the release that carries the fix — only a recurrence in that release or newer counts as a regression.',
|
|
387
219
|
inputSchema: {
|
|
388
|
-
properties: {
|
|
389
|
-
issueId: { type: 'string' },
|
|
390
|
-
projectId: { type: 'string' },
|
|
391
|
-
watch: { type: 'boolean' },
|
|
392
|
-
},
|
|
393
|
-
required: ['projectId', 'issueId', 'watch'],
|
|
394
220
|
type: 'object',
|
|
221
|
+
properties: { issueId: { type: 'string' }, release: { type: 'string' } },
|
|
222
|
+
required: ['issueId'],
|
|
395
223
|
},
|
|
396
|
-
|
|
224
|
+
handler: async (args, ctx) =>
|
|
225
|
+
apiPost(ctx, `/api/issues/${encodeURIComponent(asString(args.issueId, 'issueId'))}/resolve`, {
|
|
226
|
+
release: typeof args.release === 'string' ? args.release : undefined,
|
|
227
|
+
}),
|
|
397
228
|
},
|
|
398
229
|
]
|
|
399
230
|
}
|
package/src/native-artifacts.ts
CHANGED
|
@@ -1,47 +1,46 @@
|
|
|
1
|
-
// `sentori-cli upload dsym` (iOS) + `sentori-cli upload mapping`
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// `sentori-cli upload dsym` (iOS) + `sentori-cli upload mapping`
|
|
2
|
+
// (Android). dwarfdump slice discovery stays local; the bytes land
|
|
3
|
+
// on the unified artifacts endpoint (multipart kind + file).
|
|
4
4
|
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
6
|
import { readFile } from 'node:fs/promises'
|
|
7
7
|
import { basename, extname, join } from 'node:path'
|
|
8
8
|
import { statSync, readdirSync } from 'node:fs'
|
|
9
9
|
|
|
10
|
+
import { gzipForWire, wireArrayBuffer } from './upload.js'
|
|
11
|
+
|
|
10
12
|
export type AdminUpload = {
|
|
11
13
|
apiUrl: string
|
|
12
|
-
projectId: string
|
|
13
14
|
release?: string
|
|
14
15
|
token: string
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
async function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
async function uploadBytes(opts: {
|
|
19
|
+
apiUrl: string
|
|
20
|
+
token: string
|
|
21
|
+
release: string
|
|
22
|
+
kind: 'dsym' | 'proguard'
|
|
23
|
+
name: string
|
|
24
|
+
bytes: Buffer
|
|
25
|
+
}): Promise<void> {
|
|
26
|
+
// Gzip on the wire — DWARF ~3:1, R8 mapping ~10:1; a real app's
|
|
27
|
+
// main dSYM only fits the server's transport cap compressed.
|
|
28
|
+
const { wire, wireName } = gzipForWire(opts.bytes, opts.name)
|
|
29
|
+
const form = new FormData()
|
|
30
|
+
form.append('kind', opts.kind)
|
|
31
|
+
form.append('file', new Blob([wireArrayBuffer(wire)]), wireName)
|
|
32
|
+
const url = `${opts.apiUrl.replace(/\/+$/, '')}/v1/releases/${encodeURIComponent(opts.release)}/artifacts`
|
|
23
33
|
const resp = await fetch(url, {
|
|
24
|
-
body,
|
|
25
|
-
headers: {
|
|
26
|
-
Authorization: `Bearer ${token}`,
|
|
27
|
-
'Content-Type': 'application/octet-stream',
|
|
28
|
-
...headers,
|
|
29
|
-
},
|
|
34
|
+
body: form,
|
|
35
|
+
headers: { Authorization: `Bearer ${opts.token}` },
|
|
30
36
|
method: 'POST',
|
|
31
37
|
})
|
|
32
38
|
if (!resp.ok) {
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
detail = await resp.text()
|
|
36
|
-
} catch {
|
|
37
|
-
// ignore
|
|
38
|
-
}
|
|
39
|
+
const detail = await resp.text().catch(() => '')
|
|
39
40
|
throw new Error(
|
|
40
41
|
`${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
|
|
41
42
|
)
|
|
42
43
|
}
|
|
43
|
-
const txt = await resp.text()
|
|
44
|
-
return txt ? JSON.parse(txt) : null
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
// ── dSYM ──────────────────────────────────────────────────────────
|
|
@@ -135,20 +134,19 @@ export async function uploadDsym(opts: DsymUploadOptions): Promise<DsymUploadRes
|
|
|
135
134
|
slices.push(...found)
|
|
136
135
|
}
|
|
137
136
|
|
|
138
|
-
const
|
|
139
|
-
const q = new URLSearchParams()
|
|
140
|
-
if (opts.release) q.set('release', opts.release)
|
|
141
|
-
if (opts.objectName ?? basename(opts.path).replace(/\.dSYM$/, ''))
|
|
142
|
-
q.set('objectName', opts.objectName ?? basename(opts.path).replace(/\.dSYM$/, ''))
|
|
143
|
-
const qs = q.toString()
|
|
144
|
-
const url = `${base}/admin/api/projects/${encodeURIComponent(opts.projectId)}/dsyms${qs ? '?' + qs : ''}`
|
|
145
|
-
|
|
137
|
+
const objectName = opts.objectName ?? basename(opts.path).replace(/\.dSYM$/, '')
|
|
146
138
|
const uploaded: { arch: string; debugId: string }[] = []
|
|
147
139
|
for (const s of slices) {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
140
|
+
const bytes = await readFile(s.file)
|
|
141
|
+
// One artifact per slice; the name carries the lookup identity
|
|
142
|
+
// (object-arch-debugId) until the dwarf wiring lands server-side.
|
|
143
|
+
await uploadBytes({
|
|
144
|
+
apiUrl: opts.apiUrl,
|
|
145
|
+
token: opts.token,
|
|
146
|
+
release: opts.release ?? '',
|
|
147
|
+
kind: 'dsym',
|
|
148
|
+
name: `${objectName}-${s.arch}-${s.debugId}`,
|
|
149
|
+
bytes,
|
|
152
150
|
})
|
|
153
151
|
uploaded.push({ arch: s.arch, debugId: s.debugId })
|
|
154
152
|
}
|
|
@@ -171,12 +169,12 @@ export async function uploadMapping(opts: MappingUploadOptions): Promise<void> {
|
|
|
171
169
|
const body = await readFile(opts.path)
|
|
172
170
|
if (body.length === 0) throw new Error(`empty mapping file: ${opts.path}`)
|
|
173
171
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
172
|
+
await uploadBytes({
|
|
173
|
+
apiUrl: opts.apiUrl,
|
|
174
|
+
token: opts.token,
|
|
175
|
+
release: opts.release ?? '',
|
|
176
|
+
kind: 'proguard',
|
|
177
|
+
name: opts.debugId ? `mapping-${opts.debugId}.txt` : basename(opts.path),
|
|
178
|
+
bytes: body,
|
|
179
|
+
})
|
|
182
180
|
}
|