@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/README.md +38 -0
- package/dist/client.d.ts +123 -122
- package/dist/client.d.ts.map +1 -1
- package/dist/errors.d.ts +100 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +457 -64
- package/dist/index.js.map +9 -4
- 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 +2 -2
- package/src/client.ts +393 -135
- package/src/errors.ts +144 -0
- package/src/index.ts +31 -4
- 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
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
|
|
7
|
-
* that would disagree, and the disagreement
|
|
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://
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
167
|
+
query: ListWorkItemsOptions = {},
|
|
168
|
+
options?: RequestOptions
|
|
94
169
|
): Promise<Page<WorkItem>> {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
192
|
+
iterateWorkItems(
|
|
111
193
|
projectId: string,
|
|
112
194
|
options: { filter?: unknown; pageSize?: number } = {}
|
|
113
195
|
): AsyncGenerator<WorkItem> {
|
|
114
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
128
|
-
return this.
|
|
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
|
|
132
|
-
return this.
|
|
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
|
|
136
|
-
|
|
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
|
|
140
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
150
|
-
|
|
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
|
|
154
|
-
projectId: string
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
160
|
-
return this.
|
|
325
|
+
async getImport(runId: string, options?: RequestOptions): Promise<ImportRun> {
|
|
326
|
+
return this.call('getImport', { runId }, undefined, undefined, options)
|
|
161
327
|
}
|
|
162
328
|
|
|
163
|
-
async
|
|
164
|
-
return this.
|
|
329
|
+
async listImports(projectId: string, options?: RequestOptions): Promise<{ runs: ImportRun[] }> {
|
|
330
|
+
return this.call('listImports', { projectId }, undefined, undefined, options)
|
|
165
331
|
}
|
|
166
332
|
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
228
|
-
throw
|
|
472
|
+
const failure = (isRecord(parsed) ? parsed : {}) as Partial<ApiFailure>
|
|
473
|
+
throw createError(
|
|
229
474
|
response.status,
|
|
230
|
-
failure.code
|
|
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
|
|
234
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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.
|