@ycadence/sdk 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/README.md +111 -2
- package/dist/client.d.ts +190 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/errors.d.ts +100 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +538 -0
- package/dist/index.js.map +15 -0
- package/dist/operations.d.ts +128 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/pagination.d.ts +13 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/types.d.ts +280 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/package.json +53 -4
- package/src/client.ts +550 -0
- package/src/errors.ts +144 -0
- package/src/index.ts +37 -0
- package/src/operations.ts +74 -0
- package/src/pagination.ts +36 -0
- package/src/types.ts +364 -0
- package/src/version.ts +11 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The YCadence client.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the CLI, the MCP server and anyone integrating from TypeScript, so
|
|
5
|
+
* there is one place that knows how to talk to the API — how errors are shaped,
|
|
6
|
+
* how paging works, how a key is presented, when a request is safe to retry.
|
|
7
|
+
* Three separate implementations of that would disagree, and the disagreement
|
|
8
|
+
* would surface as "the CLI works but the MCP server does not".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { type ApiFailure, createError, RequestIntercepted, YCadenceError } from './errors'
|
|
12
|
+
import { buildPath, isIdempotentMethod, OPERATIONS, type OperationId } from './operations'
|
|
13
|
+
import { paginate } from './pagination'
|
|
14
|
+
import type {
|
|
15
|
+
CreateViewInput,
|
|
16
|
+
CreateWorkItemInput,
|
|
17
|
+
Cycle,
|
|
18
|
+
CycleProgress,
|
|
19
|
+
Health,
|
|
20
|
+
ImportInput,
|
|
21
|
+
ImportPreview,
|
|
22
|
+
ImportRun,
|
|
23
|
+
IntakeItem,
|
|
24
|
+
Label,
|
|
25
|
+
ListWorkItemsOptions,
|
|
26
|
+
Me,
|
|
27
|
+
Page,
|
|
28
|
+
Project,
|
|
29
|
+
State,
|
|
30
|
+
SubmitIntakeInput,
|
|
31
|
+
SubmitIntakeResult,
|
|
32
|
+
TriageInput,
|
|
33
|
+
TriageResult,
|
|
34
|
+
UpdateWorkItemInput,
|
|
35
|
+
View,
|
|
36
|
+
WorkItem,
|
|
37
|
+
WorkItemDetail,
|
|
38
|
+
} from './types'
|
|
39
|
+
import { SDK_VERSION } from './version'
|
|
40
|
+
|
|
41
|
+
export interface RetryOptions {
|
|
42
|
+
/** Attempts after the first. Default 2. `0` turns retrying off. */
|
|
43
|
+
retries?: number
|
|
44
|
+
/** First backoff, doubled each attempt, with jitter. Default 250ms. */
|
|
45
|
+
baseDelayMs?: number
|
|
46
|
+
/** Ceiling for one wait, including a server's `Retry-After`. Default 8s. */
|
|
47
|
+
maxDelayMs?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What happened to one HTTP exchange; for logging. Never carries credentials. */
|
|
51
|
+
export interface ResponseInfo {
|
|
52
|
+
method: string
|
|
53
|
+
path: string
|
|
54
|
+
status: number
|
|
55
|
+
requestId: string | undefined
|
|
56
|
+
durationMs: number
|
|
57
|
+
attempt: number
|
|
58
|
+
replayed: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ClientOptions {
|
|
62
|
+
/** e.g. https://ycadence.example.com. Trailing slashes are tolerated. */
|
|
63
|
+
baseUrl: string
|
|
64
|
+
apiKey: string
|
|
65
|
+
/**
|
|
66
|
+
* A Google identity token for an instance behind IAP or an authenticated
|
|
67
|
+
* Cloud Run service, sent as `Authorization: Bearer`. The transport's
|
|
68
|
+
* credential and the product's are different things and travel in different
|
|
69
|
+
* headers: the key always goes in `x-api-key`.
|
|
70
|
+
*
|
|
71
|
+
* A function is called before every request, because these tokens last
|
|
72
|
+
* about an hour and a long-running caller — the MCP server — outlives one.
|
|
73
|
+
*/
|
|
74
|
+
identityToken?: string | (() => string | undefined | Promise<string | undefined>)
|
|
75
|
+
/** Defaults to 30s. A request that hangs forever is worse than one that fails. */
|
|
76
|
+
timeoutMs?: number
|
|
77
|
+
fetch?: typeof globalThis.fetch
|
|
78
|
+
/**
|
|
79
|
+
* Retries for 429, 5xx, timeouts and network errors. Only GET requests, and
|
|
80
|
+
* mutations sent with an idempotency key, are retried: repeating any other
|
|
81
|
+
* write could apply it twice.
|
|
82
|
+
*/
|
|
83
|
+
retry?: RetryOptions
|
|
84
|
+
/** Prepended to the default `User-Agent`, e.g. `ycadence-cli/0.2.0`. */
|
|
85
|
+
userAgent?: string
|
|
86
|
+
/** Called after every response, including ones that are retried. */
|
|
87
|
+
onResponse?: (info: ResponseInfo) => void
|
|
88
|
+
/** Injected for tests; defaults to a timer. */
|
|
89
|
+
sleep?: (ms: number) => Promise<void>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Per-call options for any request. */
|
|
93
|
+
export interface RequestOptions {
|
|
94
|
+
/**
|
|
95
|
+
* Sent as `Idempotency-Key` on a mutation, which also makes the mutation
|
|
96
|
+
* safe to retry. Servers that do not support the header ignore it.
|
|
97
|
+
*/
|
|
98
|
+
idempotencyKey?: string
|
|
99
|
+
/**
|
|
100
|
+
* The version the caller last saw, sent as `If-Match: "<version>"`. A server
|
|
101
|
+
* that versions the resource answers 409 `version_conflict` or 412 when it
|
|
102
|
+
* has moved on; one that does not ignores the header.
|
|
103
|
+
*/
|
|
104
|
+
expectedVersion?: number | string
|
|
105
|
+
signal?: AbortSignal
|
|
106
|
+
/** Overrides the client's retry count for this call. */
|
|
107
|
+
retries?: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const DEFAULT_RETRY: Required<RetryOptions> = { retries: 2, baseDelayMs: 250, maxDelayMs: 8_000 }
|
|
111
|
+
|
|
112
|
+
export class YCadenceClient {
|
|
113
|
+
private readonly baseUrl: string
|
|
114
|
+
private readonly apiKey: string
|
|
115
|
+
private readonly identityToken: ClientOptions['identityToken']
|
|
116
|
+
private readonly timeoutMs: number
|
|
117
|
+
private readonly doFetch: typeof globalThis.fetch
|
|
118
|
+
private readonly retry: Required<RetryOptions>
|
|
119
|
+
private readonly userAgent: string
|
|
120
|
+
private readonly onResponse: ClientOptions['onResponse']
|
|
121
|
+
private readonly sleep: (ms: number) => Promise<void>
|
|
122
|
+
|
|
123
|
+
constructor(private readonly options: ClientOptions) {
|
|
124
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, '')
|
|
125
|
+
this.apiKey = options.apiKey
|
|
126
|
+
this.identityToken = options.identityToken
|
|
127
|
+
this.timeoutMs = options.timeoutMs ?? 30_000
|
|
128
|
+
this.doFetch =
|
|
129
|
+
options.fetch ?? (((input, init) => globalThis.fetch(input, init)) as typeof globalThis.fetch)
|
|
130
|
+
this.retry = { ...DEFAULT_RETRY, ...options.retry }
|
|
131
|
+
this.userAgent = [options.userAgent, `ycadence-sdk/${SDK_VERSION}`]
|
|
132
|
+
.filter((part) => part !== undefined && part !== '')
|
|
133
|
+
.join(' ')
|
|
134
|
+
this.onResponse = options.onResponse
|
|
135
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A copy of this client with some options replaced. */
|
|
139
|
+
withOptions(overrides: Partial<ClientOptions>): YCadenceClient {
|
|
140
|
+
return new YCadenceClient({ ...this.options, ...overrides })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The fetch this client sends with; for wrapping in `withOptions`. */
|
|
144
|
+
get fetchImplementation(): typeof globalThis.fetch {
|
|
145
|
+
return this.doFetch
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async health(): Promise<Health> {
|
|
149
|
+
return this.call('health')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Who the credential is. A 404 means the server predates Slice 2A. */
|
|
153
|
+
async me(options?: RequestOptions): Promise<Me> {
|
|
154
|
+
return this.call('me', {}, undefined, undefined, options)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Projects the credential can read; `q` matches a name or identifier prefix. */
|
|
158
|
+
async listProjects(
|
|
159
|
+
query: { q?: string; archived?: boolean; limit?: number; cursor?: string } = {},
|
|
160
|
+
options?: RequestOptions
|
|
161
|
+
): Promise<Page<Project>> {
|
|
162
|
+
return this.call('listProjects', {}, query, undefined, options)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async listWorkItems(
|
|
166
|
+
projectId: string,
|
|
167
|
+
query: ListWorkItemsOptions = {},
|
|
168
|
+
options?: RequestOptions
|
|
169
|
+
): Promise<Page<WorkItem>> {
|
|
170
|
+
return this.call(
|
|
171
|
+
'listWorkItems',
|
|
172
|
+
{ projectId },
|
|
173
|
+
{
|
|
174
|
+
filter: query.filter === undefined ? undefined : JSON.stringify(query.filter),
|
|
175
|
+
limit: query.limit,
|
|
176
|
+
cursor: query.cursor,
|
|
177
|
+
state: query.state,
|
|
178
|
+
priority: query.priority,
|
|
179
|
+
},
|
|
180
|
+
undefined,
|
|
181
|
+
options
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Every work item matching a filter, following the cursor.
|
|
187
|
+
*
|
|
188
|
+
* An async generator rather than an array: a project can hold tens of
|
|
189
|
+
* thousands of items, and a caller that wanted the first twenty should not
|
|
190
|
+
* pay for all of them — nor should the process hold them all in memory.
|
|
191
|
+
*/
|
|
192
|
+
iterateWorkItems(
|
|
193
|
+
projectId: string,
|
|
194
|
+
options: { filter?: unknown; pageSize?: number } = {}
|
|
195
|
+
): AsyncGenerator<WorkItem> {
|
|
196
|
+
return paginate((cursor) =>
|
|
197
|
+
this.listWorkItems(projectId, {
|
|
198
|
+
filter: options.filter,
|
|
199
|
+
limit: options.pageSize ?? 100,
|
|
200
|
+
cursor,
|
|
201
|
+
})
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async createWorkItem(
|
|
206
|
+
projectId: string,
|
|
207
|
+
body: CreateWorkItemInput | Record<string, unknown>,
|
|
208
|
+
options?: RequestOptions
|
|
209
|
+
): Promise<WorkItem> {
|
|
210
|
+
return this.call('createWorkItem', { projectId }, undefined, body, options)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async getWorkItem(workItemId: string, options?: RequestOptions): Promise<WorkItemDetail> {
|
|
214
|
+
return this.call('getWorkItem', { workItemId }, undefined, undefined, options)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async updateWorkItem(
|
|
218
|
+
workItemId: string,
|
|
219
|
+
body: UpdateWorkItemInput | Record<string, unknown>,
|
|
220
|
+
options?: RequestOptions
|
|
221
|
+
): Promise<WorkItem> {
|
|
222
|
+
return this.call('updateWorkItem', { workItemId }, undefined, body, options)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async deleteWorkItem(workItemId: string, options?: RequestOptions): Promise<void> {
|
|
226
|
+
await this.call('deleteWorkItem', { workItemId }, undefined, undefined, options)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async listStates(projectId: string, options?: RequestOptions): Promise<{ states: State[] }> {
|
|
230
|
+
return this.call('listStates', { projectId }, undefined, undefined, options)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async listLabels(projectId: string, options?: RequestOptions): Promise<{ labels: Label[] }> {
|
|
234
|
+
return this.call('listLabels', { projectId }, undefined, undefined, options)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async createLabel(
|
|
238
|
+
projectId: string,
|
|
239
|
+
body: { name: string; color: string; description?: string },
|
|
240
|
+
options?: RequestOptions
|
|
241
|
+
): Promise<Label> {
|
|
242
|
+
return this.call('createLabel', { projectId }, undefined, body, options)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async listTypes(projectId: string, options?: RequestOptions): Promise<{ types: unknown[] }> {
|
|
246
|
+
return this.call('listTypes', { projectId }, undefined, undefined, options)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async listCycles(projectId: string, options?: RequestOptions): Promise<{ cycles: Cycle[] }> {
|
|
250
|
+
return this.call('listCycles', { projectId }, undefined, undefined, options)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async createCycle(
|
|
254
|
+
projectId: string,
|
|
255
|
+
body: { name: string; description?: string; startDate?: string; endDate?: string },
|
|
256
|
+
options?: RequestOptions
|
|
257
|
+
): Promise<Cycle> {
|
|
258
|
+
return this.call('createCycle', { projectId }, undefined, body, options)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async getCycleProgress(cycleId: string, options?: RequestOptions): Promise<CycleProgress> {
|
|
262
|
+
return this.call('getCycleProgress', { cycleId }, undefined, undefined, options)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async addWorkItemsToCycle(
|
|
266
|
+
cycleId: string,
|
|
267
|
+
workItemIds: string[],
|
|
268
|
+
options?: RequestOptions
|
|
269
|
+
): Promise<{ added: number }> {
|
|
270
|
+
return this.call('addWorkItemsToCycle', { cycleId }, undefined, { workItemIds }, options)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async listViews(projectId: string, options?: RequestOptions): Promise<{ views: View[] }> {
|
|
274
|
+
return this.call('listViews', { projectId }, undefined, undefined, options)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async createView(
|
|
278
|
+
projectId: string,
|
|
279
|
+
body: CreateViewInput,
|
|
280
|
+
options?: RequestOptions
|
|
281
|
+
): Promise<View> {
|
|
282
|
+
return this.call('createView', { projectId }, undefined, body, options)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async listIntake(
|
|
286
|
+
projectId: string,
|
|
287
|
+
query: { includeDecided?: boolean } = {},
|
|
288
|
+
options?: RequestOptions
|
|
289
|
+
): Promise<{ items: IntakeItem[] }> {
|
|
290
|
+
return this.call('listIntake', { projectId }, query, undefined, options)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async submitIntake(
|
|
294
|
+
projectId: string,
|
|
295
|
+
body: SubmitIntakeInput,
|
|
296
|
+
options?: RequestOptions
|
|
297
|
+
): Promise<SubmitIntakeResult> {
|
|
298
|
+
return this.call('submitIntake', { projectId }, undefined, body, options)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async triageIntake(
|
|
302
|
+
intakeItemId: string,
|
|
303
|
+
body: TriageInput,
|
|
304
|
+
options?: RequestOptions
|
|
305
|
+
): Promise<TriageResult> {
|
|
306
|
+
return this.call('triageIntake', { intakeItemId }, undefined, body, options)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async previewImport(
|
|
310
|
+
projectId: string,
|
|
311
|
+
body: ImportInput | Record<string, unknown>,
|
|
312
|
+
options?: RequestOptions
|
|
313
|
+
): Promise<ImportPreview> {
|
|
314
|
+
return this.call('previewImport', { projectId }, undefined, body, options)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async startImport(
|
|
318
|
+
projectId: string,
|
|
319
|
+
body: ImportInput | Record<string, unknown>,
|
|
320
|
+
options?: RequestOptions
|
|
321
|
+
): Promise<{ runId: string }> {
|
|
322
|
+
return this.call('startImport', { projectId }, undefined, body, options)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async getImport(runId: string, options?: RequestOptions): Promise<ImportRun> {
|
|
326
|
+
return this.call('getImport', { runId }, undefined, undefined, options)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async listImports(projectId: string, options?: RequestOptions): Promise<{ runs: ImportRun[] }> {
|
|
330
|
+
return this.call('listImports', { projectId }, undefined, undefined, options)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Call any operation in the table by id. */
|
|
334
|
+
async call<T>(
|
|
335
|
+
operation: OperationId,
|
|
336
|
+
params: Record<string, string> = {},
|
|
337
|
+
query?: Record<string, string | number | boolean | undefined>,
|
|
338
|
+
body?: unknown,
|
|
339
|
+
options: RequestOptions = {}
|
|
340
|
+
): Promise<T> {
|
|
341
|
+
const { method, path } = OPERATIONS[operation]
|
|
342
|
+
let url = buildPath(path, params)
|
|
343
|
+
if (query !== undefined) {
|
|
344
|
+
const search = new URLSearchParams()
|
|
345
|
+
for (const [name, value] of Object.entries(query)) {
|
|
346
|
+
if (value !== undefined) search.set(name, String(value))
|
|
347
|
+
}
|
|
348
|
+
const encoded = search.toString()
|
|
349
|
+
if (encoded.length > 0) url += `?${encoded}`
|
|
350
|
+
}
|
|
351
|
+
return this.request<T>(method, url, body, options)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private async request<T>(
|
|
355
|
+
method: string,
|
|
356
|
+
path: string,
|
|
357
|
+
body: unknown,
|
|
358
|
+
options: RequestOptions
|
|
359
|
+
): Promise<T> {
|
|
360
|
+
const retryable = isIdempotentMethod(method) || options.idempotencyKey !== undefined
|
|
361
|
+
const retries = retryable ? (options.retries ?? this.retry.retries) : 0
|
|
362
|
+
|
|
363
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
364
|
+
try {
|
|
365
|
+
return await this.send<T>(method, path, body, options, attempt)
|
|
366
|
+
} catch (error) {
|
|
367
|
+
const failure = error as YCadenceError
|
|
368
|
+
const again =
|
|
369
|
+
attempt < retries &&
|
|
370
|
+
typeof failure.retryable === 'boolean' &&
|
|
371
|
+
// 409 request_in_progress: the first request with this key is still
|
|
372
|
+
// running, so asking again later is exactly right.
|
|
373
|
+
(failure.retryable || failure.code === 'request_in_progress')
|
|
374
|
+
if (!again) throw error
|
|
375
|
+
await this.sleep(this.backoff(attempt, failure.retryAfter))
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private backoff(attempt: number, retryAfter: number | undefined): number {
|
|
381
|
+
if (retryAfter !== undefined) return Math.min(retryAfter * 1000, this.retry.maxDelayMs)
|
|
382
|
+
const exponential = this.retry.baseDelayMs * 2 ** attempt
|
|
383
|
+
// Full jitter on the upper half, so a fleet of clients does not retry in step.
|
|
384
|
+
const jittered = exponential / 2 + Math.random() * (exponential / 2)
|
|
385
|
+
return Math.min(jittered, this.retry.maxDelayMs)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private async send<T>(
|
|
389
|
+
method: string,
|
|
390
|
+
path: string,
|
|
391
|
+
body: unknown,
|
|
392
|
+
options: RequestOptions,
|
|
393
|
+
attempt: number
|
|
394
|
+
): Promise<T> {
|
|
395
|
+
// An explicit timeout, because the default is none: a hung request holds a
|
|
396
|
+
// CLI open until the user gives up, and blocks an agent indefinitely.
|
|
397
|
+
const controller = new AbortController()
|
|
398
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs)
|
|
399
|
+
const abort = () => controller.abort()
|
|
400
|
+
options.signal?.addEventListener('abort', abort)
|
|
401
|
+
const started = Date.now()
|
|
402
|
+
|
|
403
|
+
let response: Response
|
|
404
|
+
try {
|
|
405
|
+
const identityToken =
|
|
406
|
+
typeof this.identityToken === 'function' ? await this.identityToken() : this.identityToken
|
|
407
|
+
|
|
408
|
+
const headers: Record<string, string> = {
|
|
409
|
+
// Never Authorization for the key: in front of IAP that header must
|
|
410
|
+
// carry the Google token, and a key there is rejected before the app runs.
|
|
411
|
+
'x-api-key': this.apiKey,
|
|
412
|
+
...(identityToken === undefined || identityToken.length === 0
|
|
413
|
+
? {}
|
|
414
|
+
: { Authorization: `Bearer ${identityToken}` }),
|
|
415
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
416
|
+
Accept: 'application/json',
|
|
417
|
+
'User-Agent': this.userAgent,
|
|
418
|
+
}
|
|
419
|
+
if (options.idempotencyKey !== undefined && !isIdempotentMethod(method)) {
|
|
420
|
+
headers['Idempotency-Key'] = options.idempotencyKey
|
|
421
|
+
}
|
|
422
|
+
if (options.expectedVersion !== undefined && !isIdempotentMethod(method)) {
|
|
423
|
+
headers['If-Match'] = `"${String(options.expectedVersion).replace(/"/g, '')}"`
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
response = await this.doFetch(`${this.baseUrl}${path}`, {
|
|
427
|
+
method,
|
|
428
|
+
headers,
|
|
429
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
430
|
+
signal: controller.signal,
|
|
431
|
+
})
|
|
432
|
+
} catch (error) {
|
|
433
|
+
if (error instanceof RequestIntercepted) throw error
|
|
434
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
435
|
+
throw createError(
|
|
436
|
+
408,
|
|
437
|
+
'timeout',
|
|
438
|
+
`${method} ${path} timed out after ${this.timeoutMs}ms.`,
|
|
439
|
+
undefined,
|
|
440
|
+
{ method, path }
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
throw createError(
|
|
444
|
+
0,
|
|
445
|
+
'network_error',
|
|
446
|
+
`Could not reach ${this.baseUrl}: ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
447
|
+
undefined,
|
|
448
|
+
{ method, path }
|
|
449
|
+
)
|
|
450
|
+
} finally {
|
|
451
|
+
clearTimeout(timer)
|
|
452
|
+
options.signal?.removeEventListener('abort', abort)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const headerRequestId = response.headers?.get?.('x-request-id') ?? undefined
|
|
456
|
+
this.onResponse?.({
|
|
457
|
+
method,
|
|
458
|
+
path,
|
|
459
|
+
status: response.status,
|
|
460
|
+
requestId: headerRequestId,
|
|
461
|
+
durationMs: Date.now() - started,
|
|
462
|
+
attempt,
|
|
463
|
+
replayed: response.headers?.get?.('idempotent-replayed') === 'true',
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
if (response.status === 204) return undefined as T
|
|
467
|
+
|
|
468
|
+
const text = await response.text()
|
|
469
|
+
const parsed = text.length === 0 ? undefined : safeJson(text)
|
|
470
|
+
|
|
471
|
+
if (!response.ok) {
|
|
472
|
+
const failure = (isRecord(parsed) ? parsed : {}) as Partial<ApiFailure>
|
|
473
|
+
throw createError(
|
|
474
|
+
response.status,
|
|
475
|
+
typeof failure.code === 'string' ? failure.code : codeForStatus(response.status),
|
|
476
|
+
// The server's message when there is one; the status only as a fallback,
|
|
477
|
+
// so an unexpected error shape still produces something actionable.
|
|
478
|
+
typeof failure.error === 'string'
|
|
479
|
+
? failure.error
|
|
480
|
+
: `${method} ${path} failed with ${response.status}.`,
|
|
481
|
+
failure.issues,
|
|
482
|
+
{
|
|
483
|
+
requestId: failure.requestId ?? headerRequestId,
|
|
484
|
+
details: failure.details,
|
|
485
|
+
retryAfter: parseRetryAfter(response.headers?.get?.('retry-after') ?? null),
|
|
486
|
+
method,
|
|
487
|
+
path,
|
|
488
|
+
}
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return parsed as T
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function safeJson(text: string): unknown {
|
|
497
|
+
try {
|
|
498
|
+
return JSON.parse(text)
|
|
499
|
+
} catch {
|
|
500
|
+
return undefined
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
505
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function codeForStatus(status: number): string {
|
|
509
|
+
const codes: Record<number, string> = {
|
|
510
|
+
400: 'bad_request',
|
|
511
|
+
401: 'unauthorized',
|
|
512
|
+
403: 'forbidden',
|
|
513
|
+
404: 'not_found',
|
|
514
|
+
409: 'conflict',
|
|
515
|
+
412: 'precondition_failed',
|
|
516
|
+
422: 'validation_failed',
|
|
517
|
+
429: 'rate_limited',
|
|
518
|
+
}
|
|
519
|
+
return codes[status] ?? (status >= 500 ? 'server_error' : 'unknown')
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Seconds, from either form `Retry-After` takes: a number or an HTTP date. */
|
|
523
|
+
export function parseRetryAfter(value: string | null, now = Date.now()): number | undefined {
|
|
524
|
+
if (value === null || value.trim() === '') return undefined
|
|
525
|
+
const seconds = Number(value)
|
|
526
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds)
|
|
527
|
+
const date = Date.parse(value)
|
|
528
|
+
if (Number.isNaN(date)) return undefined
|
|
529
|
+
return Math.max(0, Math.ceil((date - now) / 1000))
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export type { ImportPreview, ImportRun, Page, WorkItem } from './types'
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* @deprecated Renamed to {@link YCadenceClient}. This alias stays for one
|
|
536
|
+
* release so existing integrations keep compiling.
|
|
537
|
+
*/
|
|
538
|
+
export const DevPulseClient = YCadenceClient
|
|
539
|
+
/** @deprecated Renamed to {@link YCadenceClient}. */
|
|
540
|
+
export type DevPulseClient = YCadenceClient
|
|
541
|
+
|
|
542
|
+
export { type ApiFailure, YCadenceError } from './errors'
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* @deprecated Renamed to {@link YCadenceError}. The alias is the same class,
|
|
546
|
+
* so `error instanceof DevPulseError` still matches errors the client throws.
|
|
547
|
+
*/
|
|
548
|
+
export const DevPulseError = YCadenceError
|
|
549
|
+
/** @deprecated Renamed to {@link YCadenceError}. */
|
|
550
|
+
export type DevPulseError = YCadenceError
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors the client throws.
|
|
3
|
+
*
|
|
4
|
+
* One base class, `YCadenceError`, so `instanceof YCadenceError` catches every
|
|
5
|
+
* failure the client produces, and a subclass per kind of failure, so a caller
|
|
6
|
+
* can branch on the class instead of comparing status codes. The base keeps its
|
|
7
|
+
* original positional constructor `(status, code, message, issues)`, because
|
|
8
|
+
* integrations construct it in their own tests.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ApiIssue {
|
|
12
|
+
path: string
|
|
13
|
+
message: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The error body every v1 route returns (`packages/api/src/types.ts`). */
|
|
17
|
+
export interface ApiFailure {
|
|
18
|
+
error: string
|
|
19
|
+
code: string
|
|
20
|
+
issues?: ApiIssue[]
|
|
21
|
+
/** Added by the work management contracts; absent on older servers. */
|
|
22
|
+
details?: Record<string, unknown>
|
|
23
|
+
requestId?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ErrorExtras {
|
|
27
|
+
/** The server's request id, from the body or the `x-request-id` header. */
|
|
28
|
+
requestId?: string
|
|
29
|
+
details?: Record<string, unknown>
|
|
30
|
+
/** Seconds, from `Retry-After`, when the server sent one. */
|
|
31
|
+
retryAfter?: number
|
|
32
|
+
method?: string
|
|
33
|
+
path?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class YCadenceError extends Error {
|
|
37
|
+
readonly requestId: string | undefined
|
|
38
|
+
readonly details: Record<string, unknown> | undefined
|
|
39
|
+
readonly retryAfter: number | undefined
|
|
40
|
+
readonly method: string | undefined
|
|
41
|
+
readonly path: string | undefined
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
readonly status: number,
|
|
45
|
+
readonly code: string,
|
|
46
|
+
message: string,
|
|
47
|
+
readonly issues?: ApiIssue[],
|
|
48
|
+
extras: ErrorExtras = {}
|
|
49
|
+
) {
|
|
50
|
+
super(message)
|
|
51
|
+
this.name = 'YCadenceError'
|
|
52
|
+
this.requestId = extras.requestId
|
|
53
|
+
this.details = extras.details
|
|
54
|
+
this.retryAfter = extras.retryAfter
|
|
55
|
+
this.method = extras.method
|
|
56
|
+
this.path = extras.path
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether retrying unchanged could plausibly succeed: rate limits, server
|
|
61
|
+
* errors, timeouts and an unreachable host. A 4xx means the request itself
|
|
62
|
+
* needs to change.
|
|
63
|
+
*/
|
|
64
|
+
get retryable(): boolean {
|
|
65
|
+
return this.status === 0 || this.status === 408 || this.status === 429 || this.status >= 500
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 400 or 422: the request was understood and refused as invalid. */
|
|
70
|
+
export class YCadenceValidationError extends YCadenceError {
|
|
71
|
+
override name = 'YCadenceValidationError'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 401 or 403: no credential, a bad one, or not allowed. */
|
|
75
|
+
export class YCadencePermissionError extends YCadenceError {
|
|
76
|
+
override name = 'YCadencePermissionError'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 404: missing, deleted, or in another organization. */
|
|
80
|
+
export class YCadenceNotFoundError extends YCadenceError {
|
|
81
|
+
override name = 'YCadenceNotFoundError'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 409: a domain rule, a version conflict, or the same idempotency key still running. */
|
|
85
|
+
export class YCadenceConflictError extends YCadenceError {
|
|
86
|
+
override name = 'YCadenceConflictError'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 412: an `If-Match` precondition failed; reload and try again. */
|
|
90
|
+
export class YCadencePreconditionError extends YCadenceError {
|
|
91
|
+
override name = 'YCadencePreconditionError'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 429: slow down; `retryAfter` says for how long when the server said. */
|
|
95
|
+
export class YCadenceRateLimitError extends YCadenceError {
|
|
96
|
+
override name = 'YCadenceRateLimitError'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Status 0 or 408: the host could not be reached, or did not answer in time. */
|
|
100
|
+
export class YCadenceNetworkError extends YCadenceError {
|
|
101
|
+
override name = 'YCadenceNetworkError'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The class that matches a status. */
|
|
105
|
+
export function errorClassFor(status: number): typeof YCadenceError {
|
|
106
|
+
if (status === 0 || status === 408) return YCadenceNetworkError
|
|
107
|
+
if (status === 400 || status === 422) return YCadenceValidationError
|
|
108
|
+
if (status === 401 || status === 403) return YCadencePermissionError
|
|
109
|
+
if (status === 404) return YCadenceNotFoundError
|
|
110
|
+
if (status === 409) return YCadenceConflictError
|
|
111
|
+
if (status === 412) return YCadencePreconditionError
|
|
112
|
+
if (status === 429) return YCadenceRateLimitError
|
|
113
|
+
return YCadenceError
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createError(
|
|
117
|
+
status: number,
|
|
118
|
+
code: string,
|
|
119
|
+
message: string,
|
|
120
|
+
issues?: ApiIssue[],
|
|
121
|
+
extras: ErrorExtras = {}
|
|
122
|
+
): YCadenceError {
|
|
123
|
+
const ErrorClass = errorClassFor(status)
|
|
124
|
+
return new ErrorClass(status, code, message, issues, extras)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Thrown, unchanged, through the client by a custom `fetch` that wants to stop
|
|
129
|
+
* a request without it being reported as a network error. The CLI's
|
|
130
|
+
* `--dry-run` uses it to show a mutation instead of sending it.
|
|
131
|
+
*/
|
|
132
|
+
export class RequestIntercepted extends Error {
|
|
133
|
+
override name = 'RequestIntercepted'
|
|
134
|
+
constructor(
|
|
135
|
+
readonly request: {
|
|
136
|
+
method: string
|
|
137
|
+
url: string
|
|
138
|
+
headers: Record<string, string>
|
|
139
|
+
body: unknown
|
|
140
|
+
}
|
|
141
|
+
) {
|
|
142
|
+
super(`${request.method} ${request.url} was not sent.`)
|
|
143
|
+
}
|
|
144
|
+
}
|