@goliapkg/sentori-cli 1.8.0 → 1.9.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/README.md +58 -8
- package/lib/artifacts.d.ts +7 -0
- package/lib/artifacts.d.ts.map +1 -1
- package/lib/artifacts.js +1 -1
- package/lib/artifacts.js.map +1 -1
- package/lib/compatibility.js +1 -1
- package/lib/compatibility.js.map +1 -1
- package/lib/index.js +8 -5
- package/lib/index.js.map +1 -1
- package/lib/issue.d.ts +13 -2
- package/lib/issue.d.ts.map +1 -1
- package/lib/issue.js +12 -1
- package/lib/issue.js.map +1 -1
- package/lib/mcp.d.ts +6 -0
- package/lib/mcp.d.ts.map +1 -1
- package/lib/mcp.js +228 -65
- package/lib/mcp.js.map +1 -1
- package/lib/workflow.d.ts.map +1 -1
- package/lib/workflow.js +12 -2
- package/lib/workflow.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/issue.test.ts +27 -0
- package/src/__tests__/mcp.test.ts +128 -1
- package/src/__tests__/workflow.test.ts +41 -1
- package/src/artifacts.ts +3 -1
- package/src/compatibility.ts +1 -1
- package/src/index.ts +8 -5
- package/src/issue.ts +16 -5
- package/src/mcp.ts +205 -67
- package/src/workflow.ts +7 -2
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
import { describe, expect, test } from 'bun:test'
|
|
4
4
|
|
|
5
5
|
import { buildTools } from '../mcp.js'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
6
7
|
|
|
7
8
|
describe('mcp tools', () => {
|
|
8
9
|
const tools = buildTools()
|
|
9
10
|
|
|
10
|
-
test('
|
|
11
|
+
test('the closed loop includes durable watch', () => {
|
|
11
12
|
expect(tools.map((t) => t.name).sort()).toEqual([
|
|
13
|
+
'sentori_issue_attachment',
|
|
12
14
|
'sentori_issue_bundle',
|
|
13
15
|
'sentori_issue_list',
|
|
14
16
|
'sentori_issue_note',
|
|
17
|
+
'sentori_issue_read',
|
|
15
18
|
'sentori_issue_resolve',
|
|
19
|
+
'sentori_issue_watch',
|
|
16
20
|
])
|
|
17
21
|
})
|
|
18
22
|
|
|
@@ -30,3 +34,126 @@ describe('mcp tools', () => {
|
|
|
30
34
|
}
|
|
31
35
|
})
|
|
32
36
|
})
|
|
37
|
+
|
|
38
|
+
test('watch and evidence use the project API with credentials and lossless cursors', async () => {
|
|
39
|
+
const requests: { url: URL; auth: string | null }[] = []
|
|
40
|
+
const server = Bun.serve({
|
|
41
|
+
port: 0,
|
|
42
|
+
fetch(req) {
|
|
43
|
+
const url = new URL(req.url)
|
|
44
|
+
requests.push({ url, auth: req.headers.get('authorization') })
|
|
45
|
+
if (url.searchParams.get('format') === 'json') return Response.json({ schemaVersion: 1, evidence: { missing: ['stack'] } })
|
|
46
|
+
if (url.pathname.endsWith('/bundle')) return new Response('# Evidence')
|
|
47
|
+
return Response.json({ changes: [], nextCursor: 'v1:test:9223372036854775807', hasMore: false })
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
try {
|
|
51
|
+
const tools = buildTools()
|
|
52
|
+
const ctx = { apiUrl: server.url.toString(), token: 'test-only' }
|
|
53
|
+
const watch = tools.find((t) => t.name === 'sentori_issue_watch')!
|
|
54
|
+
const bundle = tools.find((t) => t.name === 'sentori_issue_bundle')!
|
|
55
|
+
const cursor = 'v1:test:9223372036854775807'
|
|
56
|
+
const result = await watch.handler({ cursor, limit: 17, waitSeconds: 25 }, ctx)
|
|
57
|
+
expect(result).toEqual({ changes: [], nextCursor: cursor, hasMore: false })
|
|
58
|
+
expect(requests[0].url.pathname).toBe('/api/issues/watch')
|
|
59
|
+
expect(requests[0].url.searchParams.get('cursor')).toBe(cursor)
|
|
60
|
+
expect(requests[0].url.searchParams.get('waitSeconds')).toBe('25')
|
|
61
|
+
expect(requests[0].auth).toBe('Bearer test-only')
|
|
62
|
+
await bundle.handler({ issueId: 'a/b', format: 'json' }, ctx)
|
|
63
|
+
expect(requests[1].url.pathname).toBe('/api/issues/a%2Fb/bundle')
|
|
64
|
+
expect(requests[1].url.searchParams.get('format')).toBe('json')
|
|
65
|
+
expect(await bundle.handler({ issueId: 'test' }, ctx)).toBe('# Evidence')
|
|
66
|
+
for (const args of [{ waitSeconds: 26 }, { limit: 0 }, { limit: 1.5 }]) {
|
|
67
|
+
await expect(watch.handler(args, ctx)).rejects.toThrow()
|
|
68
|
+
}
|
|
69
|
+
expect(requests).toHaveLength(3)
|
|
70
|
+
} finally { server.stop(true) }
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('stdio negotiates, stays responsive during watch, and separates protocol and tool errors', async () => {
|
|
74
|
+
const server = Bun.serve({
|
|
75
|
+
port: 0,
|
|
76
|
+
async fetch(req) {
|
|
77
|
+
if (new URL(req.url).pathname.endsWith('/attachments/test')) {
|
|
78
|
+
return new Response(Buffer.from('R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', 'base64'), { headers: { 'Content-Type': 'image/gif' } })
|
|
79
|
+
}
|
|
80
|
+
if (new URL(req.url).pathname.endsWith('/watch')) {
|
|
81
|
+
await Bun.sleep(200)
|
|
82
|
+
return Response.json({ schemaVersion: 1, changes: [], nextCursor: 'v1:test:0', hasMore: false })
|
|
83
|
+
}
|
|
84
|
+
return Response.json({ error: 'database_unavailable' }, { status: 503 })
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
const source = fileURLToPath(new URL('../mcp.ts', import.meta.url))
|
|
88
|
+
const requests = [
|
|
89
|
+
{ id: 0, method: 'initialize', params: { protocolVersion: '2025-11-25' } },
|
|
90
|
+
{ id: 1, method: 'tools/call', params: { name: 'sentori_issue_watch', arguments: { waitSeconds: 1 } } },
|
|
91
|
+
{ id: 2, method: 'ping' },
|
|
92
|
+
{ id: 3, method: 'tools/call', params: { name: 'sentori_issue_list' } },
|
|
93
|
+
{ id: 4, method: 'tools/call', params: { name: 'sentori_issue_watch', arguments: { waitSeconds: 26 } } },
|
|
94
|
+
{ id: 5, method: 'unknown' },
|
|
95
|
+
{ id: 6, method: 'tools/call', params: { name: 'missing' } },
|
|
96
|
+
{ id: 7, method: 'tools/list' },
|
|
97
|
+
{ id: 8, method: 'tools/call', params: { name: 'sentori_issue_attachment', arguments: { issueId: 'issue', attachmentRef: 'test' } } },
|
|
98
|
+
].map((r) => JSON.stringify({ jsonrpc: '2.0', ...r })).join('\n') + '\nnull\n{broken\n'
|
|
99
|
+
try {
|
|
100
|
+
const child = Bun.spawn([process.execPath, '-e', `import {runMcpServer} from ${JSON.stringify(source)}; await runMcpServer({apiUrl: ${JSON.stringify(server.url.toString())}, token: 'test-only'})`], {
|
|
101
|
+
stdin: new Blob([requests]), stdout: 'pipe', stderr: 'pipe',
|
|
102
|
+
})
|
|
103
|
+
const output = await new Response(child.stdout).text()
|
|
104
|
+
expect(await child.exited).toBe(0)
|
|
105
|
+
const responses = output.trim().split('\n').map((line) => JSON.parse(line))
|
|
106
|
+
const byId = (id: number) => responses.find((r) => r.id === id)
|
|
107
|
+
expect(byId(0).result.protocolVersion).toBe('2025-11-25')
|
|
108
|
+
expect(byId(1).result.structuredContent.nextCursor).toBe('v1:test:0')
|
|
109
|
+
expect(responses.indexOf(byId(2))).toBeLessThan(responses.indexOf(byId(1)))
|
|
110
|
+
expect(byId(3).result.isError).toBe(true)
|
|
111
|
+
expect(byId(3).result.structuredContent.error).toEqual({ code: 'database_unavailable', status: 503, retryable: true })
|
|
112
|
+
expect(byId(4).result.isError).toBe(true)
|
|
113
|
+
expect(byId(5).error.code).toBe(-32601)
|
|
114
|
+
expect(byId(6).error.code).toBe(-32602)
|
|
115
|
+
expect(byId(7).result.tools.find((t: { name: string }) => t.name === 'sentori_issue_watch').annotations.readOnlyHint).toBe(true)
|
|
116
|
+
expect(byId(8).result.content[0]).toEqual({ type: 'image', mimeType: 'image/gif', data: 'R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==' })
|
|
117
|
+
expect(responses.filter((r) => r.id === null).map((r) => r.error.code)).toEqual([-32600, -32700])
|
|
118
|
+
} finally { server.stop(true) }
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('attachment reads are bounded even without Content-Length', async () => {
|
|
122
|
+
const server = Bun.serve({
|
|
123
|
+
port: 0,
|
|
124
|
+
fetch() {
|
|
125
|
+
return new Response(new ReadableStream({
|
|
126
|
+
start(controller) {
|
|
127
|
+
controller.enqueue(new Uint8Array(1024 * 1024))
|
|
128
|
+
controller.enqueue(new Uint8Array(1))
|
|
129
|
+
controller.close()
|
|
130
|
+
},
|
|
131
|
+
}), { headers: { 'Content-Type': 'text/plain' } })
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
try {
|
|
135
|
+
const tool = buildTools().find((t) => t.name === 'sentori_issue_attachment')!
|
|
136
|
+
await expect(tool.handler({ issueId: 'i', attachmentRef: 'r' }, { apiUrl: server.url.toString(), token: 'test-only' })).rejects.toThrow('1 MiB')
|
|
137
|
+
} finally { server.stop(true) }
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('a cancelled watch releases the request without blocking subsequent calls', async () => {
|
|
141
|
+
const server = Bun.serve({ port: 0, fetch: () => new Promise<Response>(() => {}) })
|
|
142
|
+
const source = fileURLToPath(new URL('../mcp.ts', import.meta.url))
|
|
143
|
+
const input = [
|
|
144
|
+
{ id: 1, method: 'tools/call', params: { name: 'sentori_issue_watch', arguments: { waitSeconds: 25 } } },
|
|
145
|
+
{ method: 'notifications/cancelled', params: { requestId: 1, reason: 'test cancellation' } },
|
|
146
|
+
{ id: 2, method: 'ping' },
|
|
147
|
+
].map((r) => JSON.stringify({ jsonrpc: '2.0', ...r })).join('\n') + '\n'
|
|
148
|
+
try {
|
|
149
|
+
const child = Bun.spawn([process.execPath, '-e', `import {runMcpServer} from ${JSON.stringify(source)}; await runMcpServer({apiUrl: ${JSON.stringify(server.url.toString())}, token: 'test-only'})`], {
|
|
150
|
+
stdin: new Blob([input]), stdout: 'pipe', stderr: 'pipe',
|
|
151
|
+
})
|
|
152
|
+
const output = await new Response(child.stdout).text()
|
|
153
|
+
expect(await child.exited).toBe(0)
|
|
154
|
+
const responses = output.trim().split('\n').map((line) => JSON.parse(line))
|
|
155
|
+
expect(responses).toHaveLength(2)
|
|
156
|
+
expect(responses.find((r) => r.id === 1).result.isError).toBe(true)
|
|
157
|
+
expect(responses.find((r) => r.id === 2).result).toEqual({})
|
|
158
|
+
} finally { server.stop(true) }
|
|
159
|
+
})
|
|
@@ -42,7 +42,8 @@ describe('unified release upload', () => {
|
|
|
42
42
|
const bytes = gunzipSync(Buffer.from(await file.arrayBuffer()))
|
|
43
43
|
const kind = String(form.get('kind'))
|
|
44
44
|
if (kind === 'srcbundle') expect(inspectSourceArchive(bytes, 'app@1+7').files).toEqual(['src/a.ts'])
|
|
45
|
-
|
|
45
|
+
const contentHash = createHash('sha256').update(bytes).digest('hex')
|
|
46
|
+
uploaded.push({ kind, name: file.name.replace(/\.gz$/, ''), usable: true, contentHash, storageVerification: { integrity: 'verified', actualContentHash: contentHash, actualSizeBytes: bytes.length, usable: true } })
|
|
46
47
|
return reply({ id: String(uploaded.length), usable: true }, 201)
|
|
47
48
|
}
|
|
48
49
|
return reply({ release: 'app@1+7', known: uploaded.length > 0, artifacts: uploaded,
|
|
@@ -135,3 +136,42 @@ test('matching project is verified before artifact compatibility and upload', as
|
|
|
135
136
|
expect(calls[0]).toBe('https://upload.test/api/project')
|
|
136
137
|
expect(calls).toHaveLength(2)
|
|
137
138
|
})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
test('verification fails closed for stale metadata and corrupted or missing stored bytes', async () => {
|
|
142
|
+
writeFileSync(join(root, 'mapping.txt'), 'A -> a:\n')
|
|
143
|
+
const plan = prepareUploadPlan(config({ mapping: ['mapping.txt'] }))
|
|
144
|
+
const file = plan.files[0]!
|
|
145
|
+
const row = { kind: file.kind, name: file.name, contentHash: file.hash, usable: true }
|
|
146
|
+
for (const storageVerification of [undefined,
|
|
147
|
+
{ integrity: 'mismatch', actualContentHash: file.hash, actualSizeBytes: 0, usable: true },
|
|
148
|
+
{ integrity: 'unavailable' },
|
|
149
|
+
{ integrity: 'verified', actualContentHash: 'wrong', actualSizeBytes: file.bytes, usable: true },
|
|
150
|
+
]) {
|
|
151
|
+
globalThis.fetch = (async url => {
|
|
152
|
+
expect(String(url)).toEndWith('?verify=true')
|
|
153
|
+
return reply({ known: true, release: plan.release, artifacts: [{ ...row, storageVerification }] })
|
|
154
|
+
}) as typeof fetch
|
|
155
|
+
expect((await verifyUploadPlan(plan, 't'))[0]).toContain('stored bytes not verified')
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
test('strict upload preserves HTTP failure details even when verification also fails', async () => {
|
|
161
|
+
writeFileSync(join(root, 'mapping.txt'), 'A -> a:\n')
|
|
162
|
+
const path = config({ mapping: ['mapping.txt'] })
|
|
163
|
+
let posted = false
|
|
164
|
+
globalThis.fetch = (async (_url, init) => {
|
|
165
|
+
if (init?.method === 'POST') { posted = true; return reply({ error: 'upstream unavailable' }, 502) }
|
|
166
|
+
if (posted) return reply({ error: 'verification unavailable' }, 503)
|
|
167
|
+
return reply({ known: false, release: 'app@1+7', artifacts: [] })
|
|
168
|
+
}) as typeof fetch
|
|
169
|
+
const originalError = console.error, messages: string[] = []
|
|
170
|
+
console.error = (...args) => { messages.push(args.join(' ')) }
|
|
171
|
+
try { expect(await runWorkflow('upload', ['--config', path, '--strict'])).toBe(1) }
|
|
172
|
+
finally { console.error = originalError }
|
|
173
|
+
expect(messages.join('\n')).toContain('mapping.txt: 502')
|
|
174
|
+
expect(messages.join('\n')).toContain('upstream unavailable')
|
|
175
|
+
expect(messages.join('\n')).toContain('verification request failed: 503')
|
|
176
|
+
expect(messages.join('\n')).not.toContain('api-test')
|
|
177
|
+
})
|
package/src/artifacts.ts
CHANGED
|
@@ -31,6 +31,7 @@ export type ArtifactRow = {
|
|
|
31
31
|
* the check existed — "never looked at", not "looked at and
|
|
32
32
|
* fine". `false` means it stored and will symbolicate nothing. */
|
|
33
33
|
usable?: boolean | null
|
|
34
|
+
storageVerification?: { integrity: string; actualContentHash?: string; actualSizeBytes?: number; usable?: boolean | null }
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export type ArtifactsResponse = {
|
|
@@ -55,10 +56,11 @@ export async function fetchArtifacts(opts: {
|
|
|
55
56
|
apiUrl: string
|
|
56
57
|
release: string
|
|
57
58
|
token: string
|
|
59
|
+
verify?: boolean
|
|
58
60
|
}): Promise<ArtifactsResponse> {
|
|
59
61
|
const url =
|
|
60
62
|
`${opts.apiUrl.replace(/\/+$/, '')}/v1/releases/` +
|
|
61
|
-
`${encodeURIComponent(opts.release)}/artifacts`
|
|
63
|
+
`${encodeURIComponent(opts.release)}/artifacts${opts.verify ? "?verify=true" : ""}`
|
|
62
64
|
const resp = await fetch(url, {
|
|
63
65
|
headers: { Authorization: `Bearer ${opts.token}` },
|
|
64
66
|
})
|
package/src/compatibility.ts
CHANGED
|
@@ -9,7 +9,7 @@ export function checkCompatibility(result: ArtifactsResponse, sourceZip: boolean
|
|
|
9
9
|
const info = result.compatibility
|
|
10
10
|
if (info === undefined) {
|
|
11
11
|
if (sourceZip) throw new Error('This server does not advertise source ZIP support. No files were uploaded. Upgrade the Sentori server to a build advertising artifact protocol 1 and sourceBundleFormats: ["zip"], then run sentori doctor. Existing CLI 1.5 pipelines may continue using legacy JSON sources.')
|
|
12
|
-
return 'Legacy artifact API: server capabilities are not advertised. Source maps, dSYM and mapping uploads remain available; verification
|
|
12
|
+
return 'Legacy artifact API: server capabilities are not advertised. Source maps, dSYM and mapping uploads remain available; verification requires a server that supplies fresh storageVerification proof; inventory metadata alone cannot pass.'
|
|
13
13
|
}
|
|
14
14
|
if (!info || typeof info.serverVersion !== 'string' || info.artifactProtocol !== 1
|
|
15
15
|
|| !Array.isArray(info.sourceBundleFormats) || !Array.isArray(info.verification)
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { listApps, showProject } from './apps.js'
|
|
|
8
8
|
import {
|
|
9
9
|
fetchBundle,
|
|
10
10
|
formatIssueLine,
|
|
11
|
-
|
|
11
|
+
listIssuesPage,
|
|
12
12
|
noteIssue,
|
|
13
13
|
resolveIssue,
|
|
14
14
|
} from './issue.js'
|
|
@@ -58,7 +58,7 @@ CI triage (the same /api surface an AI agent uses):
|
|
|
58
58
|
sentori project show [--json] project selected by the API token
|
|
59
59
|
sentori app list [--json] apps within that project
|
|
60
60
|
sentori issue list [--status open] [--kind error] [--app <uuid>]
|
|
61
|
-
[--platform <platform>] [--environment <env>] [--release <release>] [--json]
|
|
61
|
+
[--platform <platform>] [--environment <env>] [--release <release>] [--limit 50] [--offset 0] [--json]
|
|
62
62
|
sentori issue resolve <issue-id> [--in-release <r>]
|
|
63
63
|
sentori issue note <issue-id> --body "fixed in abc123"
|
|
64
64
|
sentori issue bundle <issue-id>
|
|
@@ -466,7 +466,7 @@ async function cmdIssueList(argv: string[]): Promise<number> {
|
|
|
466
466
|
try {
|
|
467
467
|
parsed = parseArgs({
|
|
468
468
|
args: argv,
|
|
469
|
-
options: { ...ISSUE_OPTS, kind: { type: 'string' }, status: { type: 'string' }, app: { type: 'string' }, platform: { type: 'string' }, environment: { type: 'string' }, release: { type: 'string' }, json: { type: 'boolean' } },
|
|
469
|
+
options: { ...ISSUE_OPTS, kind: { type: 'string' }, status: { type: 'string' }, app: { type: 'string' }, platform: { type: 'string' }, environment: { type: 'string' }, release: { type: 'string' }, limit: { type: 'string' }, offset: { type: 'string' }, json: { type: 'boolean' } },
|
|
470
470
|
})
|
|
471
471
|
} catch (e) {
|
|
472
472
|
console.error(`error: ${(e as Error).message}\n${HELP}`)
|
|
@@ -479,15 +479,18 @@ async function cmdIssueList(argv: string[]): Promise<number> {
|
|
|
479
479
|
const cfg = parseApiCfg(parsed.values)
|
|
480
480
|
if (!cfg) return 2
|
|
481
481
|
try {
|
|
482
|
-
const
|
|
482
|
+
const page = await listIssuesPage(cfg, {
|
|
483
483
|
kind: parsed.values.kind as string | undefined,
|
|
484
484
|
status: (parsed.values.status as string | undefined) ?? 'open',
|
|
485
485
|
app: parsed.values.app,
|
|
486
486
|
platform: parsed.values.platform,
|
|
487
487
|
environment: parsed.values.environment,
|
|
488
488
|
release: parsed.values.release,
|
|
489
|
+
limit: parsed.values.limit === undefined ? undefined : Number(parsed.values.limit),
|
|
490
|
+
offset: parsed.values.offset === undefined ? undefined : Number(parsed.values.offset),
|
|
489
491
|
})
|
|
490
|
-
if (parsed.values.json) { console.log(JSON.stringify(
|
|
492
|
+
if (parsed.values.json) { console.log(JSON.stringify(page, null, 2)); return 0 }
|
|
493
|
+
const rows = page.issues
|
|
491
494
|
if (rows.length === 0) {
|
|
492
495
|
console.log('(no matching issues)')
|
|
493
496
|
return 0
|
package/src/issue.ts
CHANGED
|
@@ -37,10 +37,19 @@ async function apiFetch<T>(c: ApiConfig, path: string, init?: RequestInit): Prom
|
|
|
37
37
|
return (await resp.json()) as T
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export
|
|
40
|
+
export type IssueListOptions = { status?: string; kind?: string; app?: string; platform?: string; environment?: string; release?: string; limit?: number; offset?: number }
|
|
41
|
+
export type IssuePage = { issues: Issue[]; total?: number; limit?: number; offset?: number; nextOffset?: number | null }
|
|
42
|
+
|
|
43
|
+
export async function listIssues(c: ApiConfig, opts: IssueListOptions = {}): Promise<Issue[]> {
|
|
44
|
+
return (await listIssuesPage(c, opts)).issues
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function listIssuesPage(
|
|
41
48
|
c: ApiConfig,
|
|
42
|
-
opts:
|
|
43
|
-
): Promise<
|
|
49
|
+
opts: IssueListOptions = {},
|
|
50
|
+
): Promise<IssuePage> {
|
|
51
|
+
if (opts.limit !== undefined && (!Number.isSafeInteger(opts.limit) || opts.limit < 1 || opts.limit > 200)) throw new Error('--limit must be an integer from 1 to 200')
|
|
52
|
+
if (opts.offset !== undefined && (!Number.isSafeInteger(opts.offset) || opts.offset < 0)) throw new Error('--offset must be a non-negative safe integer')
|
|
44
53
|
const q = new URLSearchParams()
|
|
45
54
|
if (opts.status) q.set('status', opts.status)
|
|
46
55
|
if (opts.kind) q.set('kind', opts.kind)
|
|
@@ -48,9 +57,11 @@ export async function listIssues(
|
|
|
48
57
|
if (opts.platform) q.set('platform', opts.platform)
|
|
49
58
|
if (opts.environment) q.set('environment', opts.environment)
|
|
50
59
|
if (opts.release) q.set('release', opts.release)
|
|
60
|
+
if (opts.limit !== undefined) q.set('limit', String(opts.limit))
|
|
61
|
+
if (opts.offset !== undefined) q.set('offset', String(opts.offset))
|
|
51
62
|
const qs = q.toString()
|
|
52
|
-
const body = await apiFetch<
|
|
53
|
-
return body
|
|
63
|
+
const body = await apiFetch<IssuePage>(c, `/api/issues${qs ? `?${qs}` : ''}`)
|
|
64
|
+
return body
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
export async function resolveIssue(
|