@ycadence/sdk 0.1.0 → 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/src/client.ts CHANGED
@@ -3,71 +3,111 @@
3
3
  *
4
4
  * Shared by the CLI, the MCP server and anyone integrating from TypeScript, so
5
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. Three separate implementations of
7
- * that would disagree, and the disagreement would surface as "the CLI works but
8
- * the MCP server does not".
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
9
  */
10
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
+
11
61
  export interface ClientOptions {
12
- /** e.g. https://devpulse.yarlis.com. Trailing slashes are tolerated. */
62
+ /** e.g. https://ycadence.example.com. Trailing slashes are tolerated. */
13
63
  baseUrl: string
14
64
  apiKey: string
15
65
  /**
16
66
  * A Google identity token for an instance behind IAP or an authenticated
17
67
  * Cloud Run service, sent as `Authorization: Bearer`. The transport's
18
- * credential and YCadence's are different things and travel in different
68
+ * credential and the product's are different things and travel in different
19
69
  * headers: the key always goes in `x-api-key`.
20
70
  *
21
71
  * A function is called before every request, because these tokens last
22
72
  * about an hour and a long-running caller — the MCP server — outlives one.
23
73
  */
24
- identityToken?: string | (() => string | Promise<string>)
74
+ identityToken?: string | (() => string | undefined | Promise<string | undefined>)
25
75
  /** Defaults to 30s. A request that hangs forever is worse than one that fails. */
26
76
  timeoutMs?: number
27
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>
28
90
  }
29
91
 
30
- export interface ApiFailure {
31
- error: string
32
- code: string
33
- issues?: { path: string; message: string }[]
34
- }
35
-
36
- export class YCadenceError extends Error {
37
- constructor(
38
- readonly status: number,
39
- readonly code: string,
40
- message: string,
41
- readonly issues?: ApiFailure['issues']
42
- ) {
43
- super(message)
44
- this.name = 'YCadenceError'
45
- }
46
-
47
- /** Whether retrying unchanged could plausibly succeed. */
48
- get retryable(): boolean {
49
- return this.status === 429 || this.status >= 500
50
- }
51
- }
52
-
53
- export interface WorkItem {
54
- id: string
55
- key: string
56
- sequenceId: number
57
- name: string
58
- priority: 'urgent' | 'high' | 'medium' | 'low' | 'none'
59
- stateId: string | null
60
- assigneeIds: string[]
61
- labelIds: string[]
62
- parentId: string | null
63
- sortOrder: string
64
- createdAt: string
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
65
108
  }
66
109
 
67
- export interface Page<T> {
68
- items: T[]
69
- nextCursor: string | null
70
- }
110
+ const DEFAULT_RETRY: Required<RetryOptions> = { retries: 2, baseDelayMs: 250, maxDelayMs: 8_000 }
71
111
 
72
112
  export class YCadenceClient {
73
113
  private readonly baseUrl: string
@@ -75,29 +115,71 @@ export class YCadenceClient {
75
115
  private readonly identityToken: ClientOptions['identityToken']
76
116
  private readonly timeoutMs: number
77
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>
78
122
 
79
- constructor(options: ClientOptions) {
123
+ constructor(private readonly options: ClientOptions) {
80
124
  this.baseUrl = options.baseUrl.replace(/\/+$/, '')
81
125
  this.apiKey = options.apiKey
82
126
  this.identityToken = options.identityToken
83
127
  this.timeoutMs = options.timeoutMs ?? 30_000
84
- this.doFetch = options.fetch ?? globalThis.fetch
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')
85
150
  }
86
151
 
87
- async health(): Promise<{ status: string; checks: Record<string, string> }> {
88
- return this.request('GET', '/api/health')
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)
89
163
  }
90
164
 
91
165
  async listWorkItems(
92
166
  projectId: string,
93
- options: { filter?: unknown; limit?: number; cursor?: string } = {}
167
+ query: ListWorkItemsOptions = {},
168
+ options?: RequestOptions
94
169
  ): Promise<Page<WorkItem>> {
95
- const query = new URLSearchParams()
96
- if (options.filter !== undefined) query.set('filter', JSON.stringify(options.filter))
97
- if (options.limit !== undefined) query.set('limit', String(options.limit))
98
- if (options.cursor !== undefined) query.set('cursor', options.cursor)
99
-
100
- return this.request('GET', `/api/v1/projects/${projectId}/work-items?${query}`)
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
+ )
101
183
  }
102
184
 
103
185
  /**
@@ -107,131 +189,303 @@ export class YCadenceClient {
107
189
  * thousands of items, and a caller that wanted the first twenty should not
108
190
  * pay for all of them — nor should the process hold them all in memory.
109
191
  */
110
- async *iterateWorkItems(
192
+ iterateWorkItems(
111
193
  projectId: string,
112
194
  options: { filter?: unknown; pageSize?: number } = {}
113
195
  ): AsyncGenerator<WorkItem> {
114
- let cursor: string | undefined
115
-
116
- do {
117
- const page = await this.listWorkItems(projectId, {
196
+ return paginate((cursor) =>
197
+ this.listWorkItems(projectId, {
118
198
  filter: options.filter,
119
199
  limit: options.pageSize ?? 100,
120
200
  cursor,
121
201
  })
122
- for (const item of page.items) yield item
123
- cursor = page.nextCursor ?? undefined
124
- } while (cursor !== undefined)
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)
125
243
  }
126
244
 
127
- async createWorkItem(projectId: string, body: Record<string, unknown>): Promise<WorkItem> {
128
- return this.request('POST', `/api/v1/projects/${projectId}/work-items`, body)
245
+ async listTypes(projectId: string, options?: RequestOptions): Promise<{ types: unknown[] }> {
246
+ return this.call('listTypes', { projectId }, undefined, undefined, options)
129
247
  }
130
248
 
131
- async getWorkItem(workItemId: string): Promise<WorkItem & { description: string | null }> {
132
- return this.request('GET', `/api/v1/work-items/${workItemId}`)
249
+ async listCycles(projectId: string, options?: RequestOptions): Promise<{ cycles: Cycle[] }> {
250
+ return this.call('listCycles', { projectId }, undefined, undefined, options)
133
251
  }
134
252
 
135
- async updateWorkItem(workItemId: string, body: Record<string, unknown>): Promise<WorkItem> {
136
- return this.request('PATCH', `/api/v1/work-items/${workItemId}`, body)
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)
137
291
  }
138
292
 
139
- async deleteWorkItem(workItemId: string): Promise<void> {
140
- await this.request('DELETE', `/api/v1/work-items/${workItemId}`)
293
+ async submitIntake(
294
+ projectId: string,
295
+ body: SubmitIntakeInput,
296
+ options?: RequestOptions
297
+ ): Promise<SubmitIntakeResult> {
298
+ return this.call('submitIntake', { projectId }, undefined, body, options)
141
299
  }
142
300
 
143
- async listStates(
144
- projectId: string
145
- ): Promise<{ states: { id: string; name: string; group: string }[] }> {
146
- return this.request('GET', `/api/v1/projects/${projectId}/states`)
301
+ async triageIntake(
302
+ intakeItemId: string,
303
+ body: TriageInput,
304
+ options?: RequestOptions
305
+ ): Promise<TriageResult> {
306
+ return this.call('triageIntake', { intakeItemId }, undefined, body, options)
147
307
  }
148
308
 
149
- async listLabels(projectId: string): Promise<{ labels: { id: string; name: string }[] }> {
150
- return this.request('GET', `/api/v1/projects/${projectId}/labels`)
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)
151
315
  }
152
316
 
153
- async listCycles(
154
- projectId: string
155
- ): Promise<{ cycles: { id: string; name: string; status: string }[] }> {
156
- return this.request('GET', `/api/v1/projects/${projectId}/cycles`)
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)
157
323
  }
158
324
 
159
- async previewImport(projectId: string, body: Record<string, unknown>): Promise<ImportPreview> {
160
- return this.request('POST', `/api/v1/projects/${projectId}/imports/preview`, body)
325
+ async getImport(runId: string, options?: RequestOptions): Promise<ImportRun> {
326
+ return this.call('getImport', { runId }, undefined, undefined, options)
161
327
  }
162
328
 
163
- async startImport(projectId: string, body: Record<string, unknown>): Promise<{ runId: string }> {
164
- return this.request('POST', `/api/v1/projects/${projectId}/imports`, body)
329
+ async listImports(projectId: string, options?: RequestOptions): Promise<{ runs: ImportRun[] }> {
330
+ return this.call('listImports', { projectId }, undefined, undefined, options)
165
331
  }
166
332
 
167
- async getImport(runId: string): Promise<ImportRun> {
168
- return this.request('GET', `/api/v1/imports/${runId}`)
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)
169
352
  }
170
353
 
171
- async listImports(projectId: string): Promise<{ runs: ImportRun[] }> {
172
- return this.request('GET', `/api/v1/projects/${projectId}/imports`)
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
+ }
173
378
  }
174
379
 
175
- async listViews(projectId: string): Promise<{ views: { id: string; name: string }[] }> {
176
- return this.request('GET', `/api/v1/projects/${projectId}/views`)
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)
177
386
  }
178
387
 
179
- private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
388
+ private async send<T>(
389
+ method: string,
390
+ path: string,
391
+ body: unknown,
392
+ options: RequestOptions,
393
+ attempt: number
394
+ ): Promise<T> {
180
395
  // An explicit timeout, because the default is none: a hung request holds a
181
396
  // CLI open until the user gives up, and blocks an agent indefinitely.
182
397
  const controller = new AbortController()
183
398
  const timer = setTimeout(() => controller.abort(), this.timeoutMs)
399
+ const abort = () => controller.abort()
400
+ options.signal?.addEventListener('abort', abort)
401
+ const started = Date.now()
184
402
 
185
403
  let response: Response
186
404
  try {
187
405
  const identityToken =
188
406
  typeof this.identityToken === 'function' ? await this.identityToken() : this.identityToken
189
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
+
190
426
  response = await this.doFetch(`${this.baseUrl}${path}`, {
191
427
  method,
192
- headers: {
193
- // Never Authorization: in front of IAP that header must carry the
194
- // Google token, and a key there is rejected before the app runs.
195
- 'x-api-key': this.apiKey,
196
- ...(identityToken === undefined || identityToken.length === 0
197
- ? {}
198
- : { Authorization: `Bearer ${identityToken}` }),
199
- ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
200
- },
428
+ headers,
201
429
  body: body === undefined ? undefined : JSON.stringify(body),
202
430
  signal: controller.signal,
203
431
  })
204
432
  } catch (error) {
433
+ if (error instanceof RequestIntercepted) throw error
205
434
  if (error instanceof Error && error.name === 'AbortError') {
206
- throw new YCadenceError(
435
+ throw createError(
207
436
  408,
208
437
  'timeout',
209
- `${method} ${path} timed out after ${this.timeoutMs}ms.`
438
+ `${method} ${path} timed out after ${this.timeoutMs}ms.`,
439
+ undefined,
440
+ { method, path }
210
441
  )
211
442
  }
212
- throw new YCadenceError(
443
+ throw createError(
213
444
  0,
214
445
  'network_error',
215
- `Could not reach ${this.baseUrl}: ${error instanceof Error ? error.message : 'unknown error'}`
446
+ `Could not reach ${this.baseUrl}: ${error instanceof Error ? error.message : 'unknown error'}`,
447
+ undefined,
448
+ { method, path }
216
449
  )
217
450
  } finally {
218
451
  clearTimeout(timer)
452
+ options.signal?.removeEventListener('abort', abort)
219
453
  }
220
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
+
221
466
  if (response.status === 204) return undefined as T
222
467
 
223
468
  const text = await response.text()
224
469
  const parsed = text.length === 0 ? undefined : safeJson(text)
225
470
 
226
471
  if (!response.ok) {
227
- const failure = (parsed ?? {}) as ApiFailure
228
- throw new YCadenceError(
472
+ const failure = (isRecord(parsed) ? parsed : {}) as Partial<ApiFailure>
473
+ throw createError(
229
474
  response.status,
230
- failure.code ?? 'unknown',
475
+ typeof failure.code === 'string' ? failure.code : codeForStatus(response.status),
231
476
  // The server's message when there is one; the status only as a fallback,
232
477
  // so an unexpected error shape still produces something actionable.
233
- failure.error ?? `${method} ${path} failed with ${response.status}.`,
234
- failure.issues
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
+ }
235
489
  )
236
490
  }
237
491
 
@@ -247,34 +501,36 @@ function safeJson(text: string): unknown {
247
501
  }
248
502
  }
249
503
 
250
- export interface ImportPreview {
251
- source: string
252
- total: number
253
- states: { name: string; suggestedStateId: string | null }[]
254
- labels: { name: string; exists: boolean }[]
255
- assignees: { value: string; matched: boolean }[]
256
- skipped: { row: number; reason: string }[]
257
- unmappedPriorities: string[]
258
- /**
259
- * False when re-importing would create duplicates rather than update — a CSV
260
- * with no id column. Worth showing before the import, not after.
261
- */
262
- idempotent: boolean
504
+ function isRecord(value: unknown): value is Record<string, unknown> {
505
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
263
506
  }
264
507
 
265
- export interface ImportRun {
266
- id: string
267
- source: string
268
- status: string
269
- totalItems: number
270
- createdCount: number
271
- updatedCount: number
272
- cursor: number
273
- skipped: { row: number; reason: string }[]
274
- unmatched: { kind: string; value: string; item: string }[]
275
- error: string | null
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')
276
520
  }
277
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
+
278
534
  /**
279
535
  * @deprecated Renamed to {@link YCadenceClient}. This alias stays for one
280
536
  * release so existing integrations keep compiling.
@@ -283,6 +539,8 @@ export const DevPulseClient = YCadenceClient
283
539
  /** @deprecated Renamed to {@link YCadenceClient}. */
284
540
  export type DevPulseClient = YCadenceClient
285
541
 
542
+ export { type ApiFailure, YCadenceError } from './errors'
543
+
286
544
  /**
287
545
  * @deprecated Renamed to {@link YCadenceError}. The alias is the same class,
288
546
  * so `error instanceof DevPulseError` still matches errors the client throws.