@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/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
+ }
package/src/index.ts CHANGED
@@ -1,10 +1,37 @@
1
1
  export {
2
- type ApiFailure,
3
2
  type ClientOptions,
4
3
  DevPulseClient,
5
4
  DevPulseError,
6
- type Page,
7
- type WorkItem,
5
+ parseRetryAfter,
6
+ type RequestOptions,
7
+ type ResponseInfo,
8
+ type RetryOptions,
8
9
  YCadenceClient,
9
- YCadenceError,
10
10
  } from './client.js'
11
+ export {
12
+ type ApiFailure,
13
+ type ApiIssue,
14
+ createError,
15
+ type ErrorExtras,
16
+ errorClassFor,
17
+ RequestIntercepted,
18
+ YCadenceConflictError,
19
+ YCadenceError,
20
+ YCadenceNetworkError,
21
+ YCadenceNotFoundError,
22
+ YCadencePermissionError,
23
+ YCadencePreconditionError,
24
+ YCadenceRateLimitError,
25
+ YCadenceValidationError,
26
+ } from './errors.js'
27
+ export {
28
+ buildPath,
29
+ type HttpMethod,
30
+ isIdempotentMethod,
31
+ OPERATIONS,
32
+ type Operation,
33
+ type OperationId,
34
+ } from './operations.js'
35
+ export { collect, paginate } from './pagination.js'
36
+ export * from './types.js'
37
+ export { SDK_VERSION } from './version.js'
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Every API operation the client calls, in one table.
3
+ *
4
+ * The client builds each request from this table rather than from a path
5
+ * written inline in a method, so there is one place to compare with the
6
+ * server: `apps/web/lib/api/sdk-contract.test.ts` fails when an entry has no
7
+ * matching route contract (unless it is marked `pending`), and the CLI's
8
+ * reference and `--dry-run` name operations by these ids.
9
+ */
10
+
11
+ export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
12
+
13
+ export interface Operation {
14
+ method: HttpMethod
15
+ /** The route contract's path, with `{param}` placeholders. */
16
+ path: string
17
+ /**
18
+ * Planned by the work management contracts and not served yet. The client
19
+ * still calls it; a 404 from an older server is reported as such.
20
+ */
21
+ pending?: string
22
+ }
23
+
24
+ export const OPERATIONS = {
25
+ health: { method: 'GET', path: '/api/health' },
26
+ me: { method: 'GET', path: '/api/v1/me' },
27
+ listProjects: { method: 'GET', path: '/api/v1/projects' },
28
+
29
+ listWorkItems: { method: 'GET', path: '/api/v1/projects/{projectId}/work-items' },
30
+ createWorkItem: { method: 'POST', path: '/api/v1/projects/{projectId}/work-items' },
31
+ getWorkItem: { method: 'GET', path: '/api/v1/work-items/{workItemId}' },
32
+ updateWorkItem: { method: 'PATCH', path: '/api/v1/work-items/{workItemId}' },
33
+ deleteWorkItem: { method: 'DELETE', path: '/api/v1/work-items/{workItemId}' },
34
+
35
+ listStates: { method: 'GET', path: '/api/v1/projects/{projectId}/states' },
36
+ listLabels: { method: 'GET', path: '/api/v1/projects/{projectId}/labels' },
37
+ createLabel: { method: 'POST', path: '/api/v1/projects/{projectId}/labels' },
38
+ listTypes: { method: 'GET', path: '/api/v1/projects/{projectId}/types' },
39
+
40
+ listCycles: { method: 'GET', path: '/api/v1/projects/{projectId}/cycles' },
41
+ createCycle: { method: 'POST', path: '/api/v1/projects/{projectId}/cycles' },
42
+ getCycleProgress: { method: 'GET', path: '/api/v1/cycles/{cycleId}/progress' },
43
+ addWorkItemsToCycle: { method: 'POST', path: '/api/v1/cycles/{cycleId}/work-items' },
44
+
45
+ listViews: { method: 'GET', path: '/api/v1/projects/{projectId}/views' },
46
+ createView: { method: 'POST', path: '/api/v1/projects/{projectId}/views' },
47
+
48
+ listIntake: { method: 'GET', path: '/api/v1/projects/{projectId}/intake' },
49
+ submitIntake: { method: 'POST', path: '/api/v1/projects/{projectId}/intake' },
50
+ triageIntake: { method: 'POST', path: '/api/v1/intake-items/{intakeItemId}/triage' },
51
+
52
+ previewImport: { method: 'POST', path: '/api/v1/projects/{projectId}/imports/preview' },
53
+ startImport: { method: 'POST', path: '/api/v1/projects/{projectId}/imports' },
54
+ listImports: { method: 'GET', path: '/api/v1/projects/{projectId}/imports' },
55
+ getImport: { method: 'GET', path: '/api/v1/imports/{runId}' },
56
+ } as const satisfies Record<string, Operation>
57
+
58
+ export type OperationId = keyof typeof OPERATIONS
59
+
60
+ /** GET and HEAD are safe to repeat; anything else needs an idempotency key first. */
61
+ export function isIdempotentMethod(method: string): boolean {
62
+ return method === 'GET' || method === 'HEAD'
63
+ }
64
+
65
+ /** Fill `{param}` placeholders, encoding each value. */
66
+ export function buildPath(template: string, params: Record<string, string> = {}): string {
67
+ return template.replace(/\{(\w+)\}/g, (_match, name: string) => {
68
+ const value = params[name]
69
+ if (value === undefined || value === '') {
70
+ throw new TypeError(`Missing path parameter "${name}" for ${template}.`)
71
+ }
72
+ return encodeURIComponent(value)
73
+ })
74
+ }
@@ -0,0 +1,36 @@
1
+ import type { Page } from './types'
2
+
3
+ /**
4
+ * Follow `nextCursor` to the end, one page at a time.
5
+ *
6
+ * Lazy: a caller that breaks out early pays for the pages it read and no more.
7
+ */
8
+ export async function* paginate<T>(
9
+ fetchPage: (cursor: string | undefined) => Promise<Page<T>>,
10
+ options: { cursor?: string; maxItems?: number } = {}
11
+ ): AsyncGenerator<T> {
12
+ let cursor = options.cursor
13
+ let yielded = 0
14
+ do {
15
+ const page = await fetchPage(cursor)
16
+ for (const item of page.items) {
17
+ if (options.maxItems !== undefined && yielded >= options.maxItems) return
18
+ yielded += 1
19
+ yield item
20
+ }
21
+ cursor = page.nextCursor ?? undefined
22
+ } while (cursor !== undefined)
23
+ }
24
+
25
+ /** Every item from {@link paginate}, as an array, up to `maxItems`. */
26
+ export async function collect<T>(
27
+ iterable: AsyncIterable<T>,
28
+ maxItems = Number.POSITIVE_INFINITY
29
+ ): Promise<T[]> {
30
+ const items: T[] = []
31
+ for await (const item of iterable) {
32
+ if (items.length >= maxItems) break
33
+ items.push(item)
34
+ }
35
+ return items
36
+ }
package/src/types.ts ADDED
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Request and response shapes of the v1 API, as they arrive over JSON.
3
+ *
4
+ * The routes define these with zod in `apps/web` (`lib/api/contracts.ts` and
5
+ * the route files). The SDK is published without dependencies, so it cannot
6
+ * import those schemas; instead each response type has a field list below that
7
+ * the compiler checks against the type, and `apps/web/lib/api/sdk-contract.test.ts`
8
+ * checks against the route's zod schema. A field added on one side and not the
9
+ * other fails that test rather than reaching a user.
10
+ *
11
+ * Dates are ISO strings: that is what JSON carries.
12
+ */
13
+
14
+ export type Priority = 'urgent' | 'high' | 'medium' | 'low' | 'none'
15
+ export const PRIORITIES: readonly Priority[] = ['urgent', 'high', 'medium', 'low', 'none']
16
+
17
+ export type StateGroup = 'backlog' | 'unstarted' | 'started' | 'completed' | 'cancelled' | 'triage'
18
+
19
+ export interface WorkItem {
20
+ id: string
21
+ key: string
22
+ sequenceId: number
23
+ name: string
24
+ priority: Priority
25
+ stateId: string | null
26
+ assigneeIds: string[]
27
+ labelIds: string[]
28
+ parentId: string | null
29
+ sortOrder: string
30
+ createdAt: string
31
+ /**
32
+ * Returned by `GET /work-items/{id}` (always, from Slice 2A on); not yet by
33
+ * lists or writes.
34
+ */
35
+ projectId?: string
36
+ /** Not returned yet. Sent back as `If-Match` once the server versions items. */
37
+ version?: number
38
+ }
39
+
40
+ export interface WorkItemDetail extends WorkItem {
41
+ /** Optional only so the SDK still reads a server older than Slice 2A. */
42
+ projectId?: string
43
+ description: string | null
44
+ startDate: string | null
45
+ targetDate: string | null
46
+ archivedAt: string | null
47
+ updatedAt: string
48
+ }
49
+
50
+ export interface Page<T> {
51
+ items: T[]
52
+ nextCursor: string | null
53
+ }
54
+
55
+ export interface CreateWorkItemInput {
56
+ name: string
57
+ description?: string
58
+ stateId?: string
59
+ priority?: Priority
60
+ assigneeIds?: string[]
61
+ labelIds?: string[]
62
+ parentId?: string
63
+ /** ISO date. */
64
+ startDate?: string
65
+ targetDate?: string
66
+ }
67
+
68
+ /**
69
+ * The fields PATCH accepts today. The server strips anything else, so sending
70
+ * an unlisted field would look like success and change nothing.
71
+ */
72
+ export interface UpdateWorkItemInput {
73
+ name?: string
74
+ description?: string
75
+ stateId?: string
76
+ priority?: Priority
77
+ parentId?: string | null
78
+ startDate?: string | null
79
+ targetDate?: string | null
80
+ }
81
+
82
+ export interface ListWorkItemsOptions {
83
+ /** A @devpulse/query expression; sent JSON-encoded as `filter`. */
84
+ filter?: unknown
85
+ limit?: number
86
+ cursor?: string
87
+ /** A state id. */
88
+ state?: string
89
+ priority?: Priority
90
+ }
91
+
92
+ export interface State {
93
+ id: string
94
+ name: string
95
+ group: StateGroup | string
96
+ color: string
97
+ description: string | null
98
+ /** Fractional-index sort key; the list arrives already ordered by it. */
99
+ sequence: string
100
+ isDefault: boolean
101
+ }
102
+
103
+ export interface Label {
104
+ id: string
105
+ name: string
106
+ color: string
107
+ description: string | null
108
+ sortOrder: string
109
+ }
110
+
111
+ export interface Cycle {
112
+ id: string
113
+ name: string
114
+ description: string | null
115
+ startDate: string | null
116
+ endDate: string | null
117
+ status: 'draft' | 'upcoming' | 'active' | 'completed'
118
+ sortOrder: string
119
+ completedAt: string | null
120
+ archivedAt: string | null
121
+ }
122
+
123
+ export interface CycleProgress {
124
+ progress: unknown
125
+ burndown: unknown
126
+ frozen: boolean
127
+ }
128
+
129
+ export interface View {
130
+ id: string
131
+ name: string
132
+ description: string | null
133
+ filter: unknown
134
+ display: unknown
135
+ access: 'private' | 'public'
136
+ ownerId: string | null
137
+ sortOrder: string
138
+ }
139
+
140
+ export interface CreateViewInput {
141
+ name: string
142
+ description?: string
143
+ where?: unknown
144
+ display?: Record<string, unknown>
145
+ access?: 'private' | 'public'
146
+ }
147
+
148
+ export type IntakeStatus = 'pending' | 'accepted' | 'declined' | 'snoozed' | 'duplicate'
149
+
150
+ export interface IntakeItem {
151
+ id: string
152
+ workItemId: string
153
+ status: IntakeStatus
154
+ source: string
155
+ sourceEmail: string | null
156
+ snoozedUntil: string | null
157
+ submittedAt: string
158
+ }
159
+
160
+ export interface SubmitIntakeInput {
161
+ title: string
162
+ description?: string
163
+ source?: 'in_app' | 'form' | 'email' | 'api' | 'integration'
164
+ sourceEmail?: string
165
+ extra?: Record<string, unknown>
166
+ }
167
+
168
+ export interface SubmitIntakeResult {
169
+ intakeItemId: string
170
+ workItemId: string
171
+ sequenceId: number
172
+ }
173
+
174
+ export interface TriageInput {
175
+ status: 'accepted' | 'declined' | 'snoozed' | 'duplicate'
176
+ /** ISO date; required when snoozing. */
177
+ snoozedUntil?: string
178
+ /** Required when marking a duplicate. */
179
+ duplicateOfId?: string
180
+ }
181
+
182
+ export interface TriageResult {
183
+ status: string
184
+ movedToStateId: string | null
185
+ }
186
+
187
+ export interface ImportPreview {
188
+ source: string
189
+ total: number
190
+ states: { name: string; suggestedStateId: string | null }[]
191
+ labels: { name: string; exists: boolean }[]
192
+ assignees: { value: string; matched: boolean }[]
193
+ skipped: { row: number; reason: string }[]
194
+ unmappedPriorities: string[]
195
+ /**
196
+ * False when re-importing would create duplicates rather than update — a CSV
197
+ * with no id column. Worth showing before the import, not after.
198
+ */
199
+ idempotent: boolean
200
+ }
201
+
202
+ export interface ImportRun {
203
+ id: string
204
+ source: string
205
+ status: string
206
+ totalItems: number
207
+ createdCount: number
208
+ updatedCount: number
209
+ cursor: number
210
+ skipped: { row: number; reason: string }[]
211
+ unmatched: { kind: string; value: string; item: string }[]
212
+ error: string | null
213
+ startedAt?: string | null
214
+ finishedAt?: string | null
215
+ createdAt?: string
216
+ }
217
+
218
+ export type ImportSource = 'csv' | 'github' | 'jira' | 'linear'
219
+
220
+ export interface ImportInput {
221
+ source: ImportSource
222
+ /** CSV text, or the parsed JSON export for the API sources. */
223
+ data: unknown
224
+ repository?: string
225
+ baseUrl?: string
226
+ /** Source state name to state id. Only for `startImport`. */
227
+ stateMap?: Record<string, string>
228
+ }
229
+
230
+ export interface Health {
231
+ status: string
232
+ checks: Record<string, string>
233
+ }
234
+
235
+ /** `GET /api/v1/me`: who the credential is. Never includes the key. */
236
+ export interface Me {
237
+ principal: {
238
+ kind: 'personal_key' | 'service_key' | string
239
+ scopeType?: 'project' | 'organization' | 'personal' | string
240
+ apiKeyId: string
241
+ keyPrefix: string
242
+ userId: string | null
243
+ email: string | null
244
+ organizationRole?: string | null
245
+ }
246
+ organization: { id: string; slug: string; name: string }
247
+ project: { id: string; identifier: string; name: string } | null
248
+ scopes: string[]
249
+ expiresAt: string | null
250
+ }
251
+
252
+ /** `GET /api/v1/projects`. */
253
+ export interface Project {
254
+ id: string
255
+ identifier: string
256
+ name: string
257
+ archivedAt: string | null
258
+ }
259
+
260
+ /**
261
+ * Field lists, checked by the compiler to name every key of the type (and no
262
+ * others), and by the contract test to match the route's response schema.
263
+ */
264
+ type FieldsOf<T> = { [K in keyof Required<T>]: true }
265
+
266
+ export const WORK_ITEM_FIELDS: FieldsOf<WorkItem> = {
267
+ id: true,
268
+ key: true,
269
+ sequenceId: true,
270
+ name: true,
271
+ priority: true,
272
+ stateId: true,
273
+ assigneeIds: true,
274
+ labelIds: true,
275
+ parentId: true,
276
+ sortOrder: true,
277
+ createdAt: true,
278
+ projectId: true,
279
+ version: true,
280
+ }
281
+
282
+ export const WORK_ITEM_DETAIL_FIELDS: FieldsOf<WorkItemDetail> = {
283
+ ...WORK_ITEM_FIELDS,
284
+ description: true,
285
+ startDate: true,
286
+ targetDate: true,
287
+ archivedAt: true,
288
+ updatedAt: true,
289
+ }
290
+
291
+ export const STATE_FIELDS: FieldsOf<State> = {
292
+ id: true,
293
+ name: true,
294
+ group: true,
295
+ color: true,
296
+ description: true,
297
+ sequence: true,
298
+ isDefault: true,
299
+ }
300
+
301
+ export const LABEL_FIELDS: FieldsOf<Label> = {
302
+ id: true,
303
+ name: true,
304
+ color: true,
305
+ description: true,
306
+ sortOrder: true,
307
+ }
308
+
309
+ export const CYCLE_FIELDS: FieldsOf<Cycle> = {
310
+ id: true,
311
+ name: true,
312
+ description: true,
313
+ startDate: true,
314
+ endDate: true,
315
+ status: true,
316
+ sortOrder: true,
317
+ completedAt: true,
318
+ archivedAt: true,
319
+ }
320
+
321
+ export const VIEW_FIELDS: FieldsOf<View> = {
322
+ id: true,
323
+ name: true,
324
+ description: true,
325
+ filter: true,
326
+ display: true,
327
+ access: true,
328
+ ownerId: true,
329
+ sortOrder: true,
330
+ }
331
+
332
+ export const INTAKE_ITEM_FIELDS: FieldsOf<IntakeItem> = {
333
+ id: true,
334
+ workItemId: true,
335
+ status: true,
336
+ source: true,
337
+ sourceEmail: true,
338
+ snoozedUntil: true,
339
+ submittedAt: true,
340
+ }
341
+
342
+ export const IMPORT_RUN_FIELDS: FieldsOf<ImportRun> = {
343
+ id: true,
344
+ source: true,
345
+ status: true,
346
+ totalItems: true,
347
+ createdCount: true,
348
+ updatedCount: true,
349
+ cursor: true,
350
+ skipped: true,
351
+ unmatched: true,
352
+ error: true,
353
+ startedAt: true,
354
+ finishedAt: true,
355
+ createdAt: true,
356
+ }
357
+
358
+ /**
359
+ * Fields the SDK declares ahead of the server (the work management contracts
360
+ * add them). The contract test allows them to be missing from today's schemas.
361
+ */
362
+ export const PENDING_FIELDS: Record<string, readonly string[]> = {
363
+ workItem: ['projectId', 'version'],
364
+ }
package/src/version.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The published version, for `User-Agent`.
3
+ *
4
+ * Replaced at build time (`bun build --define`) from package.json, so the
5
+ * Version PR that bumps package.json needs no second edit. Unbuilt source —
6
+ * tests, the workspace — reports `0.0.0-dev`.
7
+ */
8
+ declare const __YCADENCE_SDK_VERSION__: string | undefined
9
+
10
+ export const SDK_VERSION: string =
11
+ typeof __YCADENCE_SDK_VERSION__ === 'string' ? __YCADENCE_SDK_VERSION__ : '0.0.0-dev'