@kernhq/module-tracker 0.1.0 → 0.1.2

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.
@@ -0,0 +1,857 @@
1
+ import { baseContract, Id, PageInput, page, Timestamp, UserId, WorkspaceId } from '@kernhq/contracts'
2
+ import { oc } from '@orpc/contract'
3
+ import { z } from 'zod'
4
+ import {
5
+ Attachment,
6
+ AvailableTransition,
7
+ BulkResult,
8
+ BurndownReport,
9
+ CfdReport,
10
+ Comment,
11
+ Component,
12
+ CreatedVsResolvedReport,
13
+ CreateIssue,
14
+ CreateProject,
15
+ CsvMapping,
16
+ Cycle,
17
+ DateOnly,
18
+ FieldDef,
19
+ FieldScheme,
20
+ HierarchyRules,
21
+ ImportJob,
22
+ ImportSource,
23
+ IntakeForm,
24
+ IntakeSubmission,
25
+ Issue,
26
+ IssueApproval,
27
+ IssueHistoryEntry,
28
+ IssueKey,
29
+ IssueQueryInput,
30
+ IssueQueryResult,
31
+ IssueTemplate,
32
+ KqlFieldInfo,
33
+ KqlParseResult,
34
+ Label,
35
+ Link,
36
+ Milestone,
37
+ Ok,
38
+ Project,
39
+ ProjectMember,
40
+ ProjectRole,
41
+ ProjectTemplate,
42
+ RecurringIssue,
43
+ RelationType,
44
+ RelationView,
45
+ RichDoc,
46
+ StatusHistoryEntry,
47
+ StatusInfo,
48
+ TimeReport,
49
+ Timer,
50
+ TypeScheme,
51
+ UpdateIssue,
52
+ UpdateProject,
53
+ UpsertComponent,
54
+ UpsertCycle,
55
+ UpsertFieldDef,
56
+ UpsertIssueTemplate,
57
+ UpsertLabel,
58
+ UpsertMilestone,
59
+ UpsertRecurringIssue,
60
+ UpsertVersion,
61
+ UpsertView,
62
+ UpsertWorkflow,
63
+ UpsertWorkflowScheme,
64
+ UpsertWorkItemType,
65
+ UpsertWorklog,
66
+ VelocityReport,
67
+ Version,
68
+ View,
69
+ Workflow,
70
+ WorkflowDefinition,
71
+ WorkflowScheme,
72
+ WorkItemType,
73
+ Worklog,
74
+ } from './models.js'
75
+
76
+ const ws = z.object({ workspaceId: WorkspaceId })
77
+ const pr = ws.extend({ projectId: Id })
78
+ const iss = ws.extend({ issueId: Id })
79
+ const t = (...tags: string[]) => ({ tags })
80
+
81
+ /**
82
+ * oRPC contract of the tracker module, mounted at `/api/tracker`.
83
+ * Every procedure is workspace-scoped unless marked public (intake form endpoints).
84
+ */
85
+ export const trackerContract = {
86
+ // ------------------------------------------------------------------ projects
87
+ projects: {
88
+ list: baseContract
89
+ .route({ method: 'GET', path: '/projects', ...t('projects') })
90
+ .input(ws.extend({ includeArchived: z.boolean().default(false) }))
91
+ .output(z.array(Project)),
92
+ get: baseContract
93
+ .route({ method: 'GET', path: '/projects/{projectId}', ...t('projects') })
94
+ .input(pr)
95
+ .output(Project),
96
+ getByKey: baseContract
97
+ .route({ method: 'GET', path: '/projects/key/{key}', ...t('projects') })
98
+ .input(ws.extend({ key: z.string() }))
99
+ .output(Project),
100
+ create: baseContract
101
+ .route({ method: 'POST', path: '/projects', ...t('projects') })
102
+ .input(ws.extend(CreateProject.shape))
103
+ .output(Project),
104
+ update: baseContract
105
+ .route({ method: 'PATCH', path: '/projects/{projectId}', ...t('projects') })
106
+ .input(pr.extend({ patch: UpdateProject }))
107
+ .output(Project),
108
+ archive: baseContract
109
+ .route({ method: 'POST', path: '/projects/{projectId}/archive', ...t('projects') })
110
+ .input(pr.extend({ archived: z.boolean().default(true) }))
111
+ .output(Project),
112
+ delete: baseContract
113
+ .route({ method: 'DELETE', path: '/projects/{projectId}', ...t('projects') })
114
+ .input(pr)
115
+ .output(Ok),
116
+ /** rotate/enable/disable the public intake token */
117
+ setIntake: baseContract
118
+ .route({ method: 'POST', path: '/projects/{projectId}/intake', ...t('projects', 'intake') })
119
+ .input(pr.extend({ enabled: z.boolean(), rotate: z.boolean().default(false) }))
120
+ .output(z.object({ token: z.string().nullable() })),
121
+
122
+ members: {
123
+ list: baseContract
124
+ .route({ method: 'GET', path: '/projects/{projectId}/members', ...t('projects') })
125
+ .input(pr)
126
+ .output(z.array(ProjectMember)),
127
+ add: baseContract
128
+ .route({ method: 'POST', path: '/projects/{projectId}/members', ...t('projects') })
129
+ .input(pr.extend({ userIds: z.array(UserId).min(1).max(200), role: ProjectRole.default('member') }))
130
+ .output(z.array(ProjectMember)),
131
+ remove: baseContract
132
+ .route({ method: 'DELETE', path: '/projects/{projectId}/members/{userId}', ...t('projects') })
133
+ .input(pr.extend({ userId: UserId }))
134
+ .output(Ok),
135
+ setRole: baseContract
136
+ .route({ method: 'PUT', path: '/projects/{projectId}/members/{userId}', ...t('projects') })
137
+ .input(pr.extend({ userId: UserId, role: ProjectRole }))
138
+ .output(ProjectMember),
139
+ },
140
+
141
+ templates: {
142
+ list: baseContract
143
+ .route({ method: 'GET', path: '/project-templates', ...t('projects') })
144
+ .input(ws)
145
+ .output(z.array(ProjectTemplate)),
146
+ /** snapshot an existing project (types, workflows, fields, labels, views) as a template */
147
+ saveFromProject: baseContract
148
+ .route({ method: 'POST', path: '/project-templates', ...t('projects') })
149
+ .input(pr.extend({ name: z.string().min(1).max(120), description: z.string().max(1000).optional() }))
150
+ .output(ProjectTemplate),
151
+ delete: baseContract
152
+ .route({ method: 'DELETE', path: '/project-templates/{id}', ...t('projects') })
153
+ .input(ws.extend({ id: Id }))
154
+ .output(Ok),
155
+ },
156
+ },
157
+
158
+ // ------------------------------------------------------------ work item types
159
+ types: {
160
+ list: baseContract
161
+ .route({ method: 'GET', path: '/types', ...t('types') })
162
+ .input(ws.extend({ projectId: Id.optional(), includeArchived: z.boolean().default(false) }))
163
+ .output(z.array(WorkItemType)),
164
+ create: baseContract
165
+ .route({ method: 'POST', path: '/types', ...t('types') })
166
+ .input(ws.extend(UpsertWorkItemType.shape))
167
+ .output(WorkItemType),
168
+ update: baseContract
169
+ .route({ method: 'PATCH', path: '/types/{id}', ...t('types') })
170
+ .input(ws.extend({ id: Id, patch: UpsertWorkItemType.partial() }))
171
+ .output(WorkItemType),
172
+ archive: baseContract
173
+ .route({ method: 'POST', path: '/types/{id}/archive', ...t('types') })
174
+ .input(ws.extend({ id: Id, archived: z.boolean().default(true) }))
175
+ .output(WorkItemType),
176
+ hierarchyRules: baseContract
177
+ .route({ method: 'GET', path: '/types/hierarchy-rules', ...t('types') })
178
+ .input(ws)
179
+ .output(HierarchyRules),
180
+ setHierarchyRules: baseContract
181
+ .route({ method: 'PUT', path: '/types/hierarchy-rules', ...t('types') })
182
+ .input(ws.extend({ rules: HierarchyRules }))
183
+ .output(HierarchyRules),
184
+ schemes: {
185
+ list: baseContract
186
+ .route({ method: 'GET', path: '/type-schemes', ...t('types') })
187
+ .input(ws)
188
+ .output(z.array(TypeScheme)),
189
+ create: baseContract
190
+ .route({ method: 'POST', path: '/type-schemes', ...t('types') })
191
+ .input(
192
+ ws.extend({
193
+ name: z.string().min(1).max(120),
194
+ typeIds: z.array(Id),
195
+ defaultTypeId: Id.nullable().optional(),
196
+ }),
197
+ )
198
+ .output(TypeScheme),
199
+ update: baseContract
200
+ .route({ method: 'PATCH', path: '/type-schemes/{id}', ...t('types') })
201
+ .input(
202
+ ws.extend({
203
+ id: Id,
204
+ patch: z.object({
205
+ name: z.string().min(1).max(120).optional(),
206
+ typeIds: z.array(Id).optional(),
207
+ defaultTypeId: Id.nullable().optional(),
208
+ }),
209
+ }),
210
+ )
211
+ .output(TypeScheme),
212
+ delete: baseContract
213
+ .route({ method: 'DELETE', path: '/type-schemes/{id}', ...t('types') })
214
+ .input(ws.extend({ id: Id }))
215
+ .output(Ok),
216
+ },
217
+ },
218
+
219
+ // -------------------------------------------------------------- custom fields
220
+ fields: {
221
+ list: baseContract
222
+ .route({ method: 'GET', path: '/fields', ...t('fields') })
223
+ .input(ws.extend({ projectId: Id.optional(), includeArchived: z.boolean().default(false) }))
224
+ .output(z.array(FieldDef)),
225
+ create: baseContract
226
+ .route({ method: 'POST', path: '/fields', ...t('fields') })
227
+ .input(ws.extend(UpsertFieldDef.shape))
228
+ .output(FieldDef),
229
+ update: baseContract
230
+ .route({ method: 'PATCH', path: '/fields/{id}', ...t('fields') })
231
+ .input(ws.extend({ id: Id, patch: UpsertFieldDef.partial().omit({ key: true, type: true }) }))
232
+ .output(FieldDef),
233
+ archive: baseContract
234
+ .route({ method: 'POST', path: '/fields/{id}/archive', ...t('fields') })
235
+ .input(ws.extend({ id: Id, archived: z.boolean().default(true) }))
236
+ .output(FieldDef),
237
+ delete: baseContract
238
+ .route({ method: 'DELETE', path: '/fields/{id}', ...t('fields') })
239
+ .input(ws.extend({ id: Id }))
240
+ .output(Ok),
241
+ schemes: {
242
+ list: baseContract
243
+ .route({ method: 'GET', path: '/field-schemes', ...t('fields') })
244
+ .input(ws)
245
+ .output(z.array(FieldScheme)),
246
+ create: baseContract
247
+ .route({ method: 'POST', path: '/field-schemes', ...t('fields') })
248
+ .input(ws.extend({ name: z.string().min(1).max(120), fieldIds: z.array(Id) }))
249
+ .output(FieldScheme),
250
+ update: baseContract
251
+ .route({ method: 'PATCH', path: '/field-schemes/{id}', ...t('fields') })
252
+ .input(
253
+ ws.extend({
254
+ id: Id,
255
+ patch: z.object({
256
+ name: z.string().min(1).max(120).optional(),
257
+ fieldIds: z.array(Id).optional(),
258
+ }),
259
+ }),
260
+ )
261
+ .output(FieldScheme),
262
+ delete: baseContract
263
+ .route({ method: 'DELETE', path: '/field-schemes/{id}', ...t('fields') })
264
+ .input(ws.extend({ id: Id }))
265
+ .output(Ok),
266
+ },
267
+ },
268
+
269
+ // ------------------------------------------------------------------ workflows
270
+ workflows: {
271
+ list: baseContract
272
+ .route({ method: 'GET', path: '/workflows', ...t('workflows') })
273
+ .input(ws.extend({ projectId: Id.optional(), includeArchived: z.boolean().default(false) }))
274
+ .output(z.array(Workflow)),
275
+ get: baseContract
276
+ .route({ method: 'GET', path: '/workflows/{id}', ...t('workflows') })
277
+ .input(ws.extend({ id: Id }))
278
+ .output(Workflow),
279
+ create: baseContract
280
+ .route({ method: 'POST', path: '/workflows', ...t('workflows') })
281
+ .input(ws.extend(UpsertWorkflow.shape))
282
+ .output(Workflow),
283
+ update: baseContract
284
+ .route({ method: 'PATCH', path: '/workflows/{id}', ...t('workflows') })
285
+ .input(ws.extend({ id: Id, patch: UpsertWorkflow.partial() }))
286
+ .output(Workflow),
287
+ archive: baseContract
288
+ .route({ method: 'POST', path: '/workflows/{id}/archive', ...t('workflows') })
289
+ .input(ws.extend({ id: Id, archived: z.boolean().default(true) }))
290
+ .output(Workflow),
291
+ /** validate a definition without saving (structure + rule configs) */
292
+ validate: baseContract
293
+ .route({ method: 'POST', path: '/workflows/validate', ...t('workflows') })
294
+ .input(ws.extend({ definition: z.unknown() }))
295
+ .output(
296
+ z.object({ ok: z.boolean(), problems: z.array(z.object({ path: z.string(), message: z.string() })) }),
297
+ ),
298
+ /** built-in workflow templates (software, kanban, simple) */
299
+ templates: baseContract
300
+ .route({ method: 'GET', path: '/workflows/templates', ...t('workflows') })
301
+ .output(z.array(z.object({ id: z.string(), name: z.string(), definition: WorkflowDefinition }))),
302
+ /** every status of every workflow a project uses (board columns, KQL autocomplete) */
303
+ statuses: baseContract
304
+ .route({ method: 'GET', path: '/workflows/statuses', ...t('workflows') })
305
+ .input(ws.extend({ projectId: Id.optional() }))
306
+ .output(z.array(StatusInfo)),
307
+ schemes: {
308
+ list: baseContract
309
+ .route({ method: 'GET', path: '/workflow-schemes', ...t('workflows') })
310
+ .input(ws)
311
+ .output(z.array(WorkflowScheme)),
312
+ create: baseContract
313
+ .route({ method: 'POST', path: '/workflow-schemes', ...t('workflows') })
314
+ .input(ws.extend(UpsertWorkflowScheme.shape))
315
+ .output(WorkflowScheme),
316
+ update: baseContract
317
+ .route({ method: 'PATCH', path: '/workflow-schemes/{id}', ...t('workflows') })
318
+ .input(ws.extend({ id: Id, patch: UpsertWorkflowScheme.partial() }))
319
+ .output(WorkflowScheme),
320
+ delete: baseContract
321
+ .route({ method: 'DELETE', path: '/workflow-schemes/{id}', ...t('workflows') })
322
+ .input(ws.extend({ id: Id }))
323
+ .output(Ok),
324
+ },
325
+ },
326
+
327
+ // --------------------------------------------------------------------- issues
328
+ issues: {
329
+ get: baseContract
330
+ .route({ method: 'GET', path: '/issues/{issueId}', ...t('issues') })
331
+ .input(iss)
332
+ .output(Issue),
333
+ getByKey: baseContract
334
+ .route({ method: 'GET', path: '/issues/key/{key}', ...t('issues') })
335
+ .input(ws.extend({ key: IssueKey }))
336
+ .output(Issue),
337
+ getMany: baseContract
338
+ .route({ method: 'POST', path: '/issues/get-many', ...t('issues') })
339
+ .input(ws.extend({ ids: z.array(Id).max(500) }))
340
+ .output(z.array(Issue)),
341
+ /** KQL-powered listing behind every view/board/report */
342
+ query: baseContract
343
+ .route({ method: 'POST', path: '/issues/query', ...t('issues', 'kql') })
344
+ .input(IssueQueryInput)
345
+ .output(IssueQueryResult),
346
+ create: baseContract
347
+ .route({ method: 'POST', path: '/issues', ...t('issues') })
348
+ .input(ws.extend(CreateIssue.shape))
349
+ .output(Issue),
350
+ update: baseContract
351
+ .route({ method: 'PATCH', path: '/issues/{issueId}', ...t('issues') })
352
+ .input(iss.extend({ patch: UpdateIssue }))
353
+ .output(Issue),
354
+ bulkUpdate: baseContract
355
+ .route({ method: 'POST', path: '/issues/bulk', ...t('issues') })
356
+ .input(ws.extend({ ids: z.array(Id).min(1).max(500), patch: UpdateIssue }))
357
+ .output(BulkResult),
358
+ delete: baseContract
359
+ .route({ method: 'DELETE', path: '/issues/{issueId}', ...t('issues') })
360
+ .input(iss)
361
+ .output(Ok),
362
+ archive: baseContract
363
+ .route({ method: 'POST', path: '/issues/{issueId}/archive', ...t('issues') })
364
+ .input(iss.extend({ archived: z.boolean().default(true) }))
365
+ .output(Issue),
366
+ /** move to another project (re-keys the issue and its sub-items) */
367
+ move: baseContract
368
+ .route({ method: 'POST', path: '/issues/{issueId}/move', ...t('issues') })
369
+ .input(iss.extend({ projectId: Id }))
370
+ .output(Issue),
371
+ /** reorder within rank order; place after and/or before the given issues */
372
+ rank: baseContract
373
+ .route({ method: 'POST', path: '/issues/{issueId}/rank', ...t('issues') })
374
+ .input(iss.extend({ afterId: Id.nullable().optional(), beforeId: Id.nullable().optional() }))
375
+ .output(z.object({ id: Id, rank: z.string() })),
376
+ /** field-level history + status history */
377
+ history: baseContract
378
+ .route({ method: 'GET', path: '/issues/{issueId}/history', ...t('issues') })
379
+ .input(iss.extend(PageInput.shape))
380
+ .output(
381
+ z.object({
382
+ items: z.array(IssueHistoryEntry),
383
+ statusHistory: z.array(StatusHistoryEntry),
384
+ nextCursor: z.string().nullable(),
385
+ }),
386
+ ),
387
+ watchers: {
388
+ add: baseContract
389
+ .route({ method: 'POST', path: '/issues/{issueId}/watchers', ...t('issues') })
390
+ .input(iss.extend({ userId: UserId.optional() }))
391
+ .output(z.object({ watcherIds: z.array(UserId) })),
392
+ remove: baseContract
393
+ .route({ method: 'DELETE', path: '/issues/{issueId}/watchers/{userId}', ...t('issues') })
394
+ .input(iss.extend({ userId: UserId }))
395
+ .output(z.object({ watcherIds: z.array(UserId) })),
396
+ },
397
+
398
+ transitions: {
399
+ /** transitions available from the issue's current status, with per-transition blockers */
400
+ available: baseContract
401
+ .route({ method: 'GET', path: '/issues/{issueId}/transitions', ...t('issues', 'workflows') })
402
+ .input(iss)
403
+ .output(z.array(AvailableTransition)),
404
+ apply: baseContract
405
+ .route({
406
+ method: 'POST',
407
+ path: '/issues/{issueId}/transitions/{transitionId}',
408
+ ...t('issues', 'workflows'),
409
+ })
410
+ .input(
411
+ iss.extend({
412
+ transitionId: z.string(),
413
+ fields: z.record(z.string(), z.unknown()).optional(),
414
+ comment: z.string().max(10000).optional(),
415
+ resolution: z.string().max(64).nullable().optional(),
416
+ }),
417
+ )
418
+ .output(
419
+ z.object({
420
+ issue: Issue,
421
+ /** set when the transition is now waiting for approval instead of applied */
422
+ approval: IssueApproval.nullable(),
423
+ }),
424
+ ),
425
+ },
426
+
427
+ approvals: {
428
+ list: baseContract
429
+ .route({ method: 'GET', path: '/issues/{issueId}/approvals', ...t('issues', 'workflows') })
430
+ .input(iss)
431
+ .output(z.array(IssueApproval)),
432
+ decide: baseContract
433
+ .route({
434
+ method: 'POST',
435
+ path: '/issues/{issueId}/approvals/{transitionId}',
436
+ ...t('issues', 'workflows'),
437
+ })
438
+ .input(
439
+ iss.extend({
440
+ transitionId: z.string(),
441
+ decision: z.enum(['approve', 'reject']),
442
+ comment: z.string().max(2000).optional(),
443
+ }),
444
+ )
445
+ .output(
446
+ z.object({
447
+ approval: IssueApproval,
448
+ /** when the approval completed and the transition auto-applied */
449
+ issue: Issue.nullable(),
450
+ }),
451
+ ),
452
+ },
453
+
454
+ comments: {
455
+ list: baseContract
456
+ .route({ method: 'GET', path: '/issues/{issueId}/comments', ...t('comments') })
457
+ .input(iss.extend(PageInput.shape))
458
+ .output(page(Comment)),
459
+ create: baseContract
460
+ .route({ method: 'POST', path: '/issues/{issueId}/comments', ...t('comments') })
461
+ .input(
462
+ iss.extend({
463
+ body: RichDoc,
464
+ parentId: Id.nullable().optional(),
465
+ internal: z.boolean().default(false),
466
+ }),
467
+ )
468
+ .output(Comment),
469
+ update: baseContract
470
+ .route({ method: 'PATCH', path: '/comments/{commentId}', ...t('comments') })
471
+ .input(ws.extend({ commentId: Id, body: RichDoc }))
472
+ .output(Comment),
473
+ delete: baseContract
474
+ .route({ method: 'DELETE', path: '/comments/{commentId}', ...t('comments') })
475
+ .input(ws.extend({ commentId: Id }))
476
+ .output(Ok),
477
+ /** toggle a reaction */
478
+ react: baseContract
479
+ .route({ method: 'POST', path: '/comments/{commentId}/reactions', ...t('comments') })
480
+ .input(ws.extend({ commentId: Id, emoji: z.string().min(1).max(32) }))
481
+ .output(Comment),
482
+ },
483
+
484
+ relations: {
485
+ list: baseContract
486
+ .route({ method: 'GET', path: '/issues/{issueId}/relations', ...t('relations') })
487
+ .input(iss)
488
+ .output(z.array(RelationView)),
489
+ create: baseContract
490
+ .route({ method: 'POST', path: '/issues/{issueId}/relations', ...t('relations') })
491
+ .input(iss.extend({ type: RelationType, targetIssueId: Id }))
492
+ .output(z.array(RelationView)),
493
+ delete: baseContract
494
+ .route({ method: 'DELETE', path: '/relations/{relationId}', ...t('relations') })
495
+ .input(ws.extend({ relationId: Id }))
496
+ .output(Ok),
497
+ },
498
+
499
+ attachments: {
500
+ list: baseContract
501
+ .route({ method: 'GET', path: '/issues/{issueId}/attachments', ...t('attachments') })
502
+ .input(iss)
503
+ .output(z.array(Attachment)),
504
+ /** attach an already-uploaded core file */
505
+ add: baseContract
506
+ .route({ method: 'POST', path: '/issues/{issueId}/attachments', ...t('attachments') })
507
+ .input(iss.extend({ fileIds: z.array(Id).min(1).max(50) }))
508
+ .output(z.array(Attachment)),
509
+ remove: baseContract
510
+ .route({ method: 'DELETE', path: '/attachments/{attachmentId}', ...t('attachments') })
511
+ .input(ws.extend({ attachmentId: Id }))
512
+ .output(Ok),
513
+ },
514
+
515
+ links: {
516
+ list: baseContract
517
+ .route({ method: 'GET', path: '/issues/{issueId}/links', ...t('links') })
518
+ .input(iss)
519
+ .output(z.array(Link)),
520
+ add: baseContract
521
+ .route({ method: 'POST', path: '/issues/{issueId}/links', ...t('links') })
522
+ .input(
523
+ iss.extend({
524
+ url: z.string().url(),
525
+ title: z.string().max(300).optional(),
526
+ kind: z.string().max(32).default('generic'),
527
+ }),
528
+ )
529
+ .output(Link),
530
+ remove: baseContract
531
+ .route({ method: 'DELETE', path: '/links/{linkId}', ...t('links') })
532
+ .input(ws.extend({ linkId: Id }))
533
+ .output(Ok),
534
+ },
535
+
536
+ templates: {
537
+ list: baseContract
538
+ .route({ method: 'GET', path: '/issue-templates', ...t('templates') })
539
+ .input(ws.extend({ projectId: Id.optional() }))
540
+ .output(z.array(IssueTemplate)),
541
+ create: baseContract
542
+ .route({ method: 'POST', path: '/issue-templates', ...t('templates') })
543
+ .input(ws.extend(UpsertIssueTemplate.shape))
544
+ .output(IssueTemplate),
545
+ update: baseContract
546
+ .route({ method: 'PATCH', path: '/issue-templates/{id}', ...t('templates') })
547
+ .input(ws.extend({ id: Id, patch: UpsertIssueTemplate.partial() }))
548
+ .output(IssueTemplate),
549
+ delete: baseContract
550
+ .route({ method: 'DELETE', path: '/issue-templates/{id}', ...t('templates') })
551
+ .input(ws.extend({ id: Id }))
552
+ .output(Ok),
553
+ },
554
+
555
+ recurring: {
556
+ list: baseContract
557
+ .route({ method: 'GET', path: '/recurring', ...t('templates') })
558
+ .input(pr)
559
+ .output(z.array(RecurringIssue)),
560
+ create: baseContract
561
+ .route({ method: 'POST', path: '/recurring', ...t('templates') })
562
+ .input(pr.extend(UpsertRecurringIssue.shape))
563
+ .output(RecurringIssue),
564
+ update: baseContract
565
+ .route({ method: 'PATCH', path: '/recurring/{id}', ...t('templates') })
566
+ .input(ws.extend({ id: Id, patch: UpsertRecurringIssue.partial() }))
567
+ .output(RecurringIssue),
568
+ delete: baseContract
569
+ .route({ method: 'DELETE', path: '/recurring/{id}', ...t('templates') })
570
+ .input(ws.extend({ id: Id }))
571
+ .output(Ok),
572
+ },
573
+ },
574
+
575
+ // ------------------------------------------------------------------------ kql
576
+ kql: {
577
+ /** parse + validate a query; returns AST, errors and autocomplete suggestions */
578
+ parse: baseContract
579
+ .route({ method: 'POST', path: '/kql/parse', ...t('kql') })
580
+ .input(ws.extend({ kql: z.string().max(4000), projectIds: z.array(Id).optional() }))
581
+ .output(KqlParseResult),
582
+ /** searchable fields incl. custom fields, with operators and known values */
583
+ fields: baseContract
584
+ .route({ method: 'GET', path: '/kql/fields', ...t('kql') })
585
+ .input(ws.extend({ projectIds: z.array(Id).optional() }))
586
+ .output(z.array(KqlFieldInfo)),
587
+ },
588
+
589
+ // ------------------------------------------------------------------- planning
590
+ cycles: {
591
+ list: baseContract
592
+ .route({ method: 'GET', path: '/cycles', ...t('cycles') })
593
+ .input(pr.extend({ status: z.enum(['upcoming', 'active', 'completed']).optional() }))
594
+ .output(z.array(Cycle)),
595
+ get: baseContract
596
+ .route({ method: 'GET', path: '/cycles/{id}', ...t('cycles') })
597
+ .input(ws.extend({ id: Id }))
598
+ .output(Cycle),
599
+ create: baseContract
600
+ .route({ method: 'POST', path: '/cycles', ...t('cycles') })
601
+ .input(pr.extend(UpsertCycle.shape))
602
+ .output(Cycle),
603
+ update: baseContract
604
+ .route({ method: 'PATCH', path: '/cycles/{id}', ...t('cycles') })
605
+ .input(ws.extend({ id: Id, patch: UpsertCycle.partial() }))
606
+ .output(Cycle),
607
+ delete: baseContract
608
+ .route({ method: 'DELETE', path: '/cycles/{id}', ...t('cycles') })
609
+ .input(ws.extend({ id: Id }))
610
+ .output(Ok),
611
+ start: baseContract
612
+ .route({ method: 'POST', path: '/cycles/{id}/start', ...t('cycles') })
613
+ .input(ws.extend({ id: Id }))
614
+ .output(Cycle),
615
+ /** complete; unfinished issues move to `rollTo` (next upcoming by default) or the backlog */
616
+ complete: baseContract
617
+ .route({ method: 'POST', path: '/cycles/{id}/complete', ...t('cycles') })
618
+ .input(ws.extend({ id: Id, rollToCycleId: Id.nullable().optional() }))
619
+ .output(Cycle),
620
+ },
621
+
622
+ milestones: {
623
+ list: baseContract
624
+ .route({ method: 'GET', path: '/milestones', ...t('planning') })
625
+ .input(pr)
626
+ .output(z.array(Milestone)),
627
+ create: baseContract
628
+ .route({ method: 'POST', path: '/milestones', ...t('planning') })
629
+ .input(pr.extend(UpsertMilestone.shape))
630
+ .output(Milestone),
631
+ update: baseContract
632
+ .route({ method: 'PATCH', path: '/milestones/{id}', ...t('planning') })
633
+ .input(ws.extend({ id: Id, patch: UpsertMilestone.partial() }))
634
+ .output(Milestone),
635
+ delete: baseContract
636
+ .route({ method: 'DELETE', path: '/milestones/{id}', ...t('planning') })
637
+ .input(ws.extend({ id: Id }))
638
+ .output(Ok),
639
+ },
640
+
641
+ versions: {
642
+ list: baseContract
643
+ .route({ method: 'GET', path: '/versions', ...t('planning') })
644
+ .input(pr)
645
+ .output(z.array(Version)),
646
+ create: baseContract
647
+ .route({ method: 'POST', path: '/versions', ...t('planning') })
648
+ .input(pr.extend(UpsertVersion.shape))
649
+ .output(Version),
650
+ update: baseContract
651
+ .route({ method: 'PATCH', path: '/versions/{id}', ...t('planning') })
652
+ .input(ws.extend({ id: Id, patch: UpsertVersion.partial() }))
653
+ .output(Version),
654
+ delete: baseContract
655
+ .route({ method: 'DELETE', path: '/versions/{id}', ...t('planning') })
656
+ .input(ws.extend({ id: Id }))
657
+ .output(Ok),
658
+ release: baseContract
659
+ .route({ method: 'POST', path: '/versions/{id}/release', ...t('planning') })
660
+ .input(ws.extend({ id: Id, released: z.boolean().default(true) }))
661
+ .output(Version),
662
+ },
663
+
664
+ components: {
665
+ list: baseContract
666
+ .route({ method: 'GET', path: '/components', ...t('planning') })
667
+ .input(pr)
668
+ .output(z.array(Component)),
669
+ create: baseContract
670
+ .route({ method: 'POST', path: '/components', ...t('planning') })
671
+ .input(pr.extend(UpsertComponent.shape))
672
+ .output(Component),
673
+ update: baseContract
674
+ .route({ method: 'PATCH', path: '/components/{id}', ...t('planning') })
675
+ .input(ws.extend({ id: Id, patch: UpsertComponent.partial() }))
676
+ .output(Component),
677
+ delete: baseContract
678
+ .route({ method: 'DELETE', path: '/components/{id}', ...t('planning') })
679
+ .input(ws.extend({ id: Id }))
680
+ .output(Ok),
681
+ },
682
+
683
+ labels: {
684
+ list: baseContract
685
+ .route({ method: 'GET', path: '/labels', ...t('planning') })
686
+ .input(ws.extend({ projectId: Id.optional(), includeArchived: z.boolean().default(false) }))
687
+ .output(z.array(Label)),
688
+ create: baseContract
689
+ .route({ method: 'POST', path: '/labels', ...t('planning') })
690
+ .input(ws.extend(UpsertLabel.shape))
691
+ .output(Label),
692
+ update: baseContract
693
+ .route({ method: 'PATCH', path: '/labels/{id}', ...t('planning') })
694
+ .input(ws.extend({ id: Id, patch: UpsertLabel.partial() }))
695
+ .output(Label),
696
+ delete: baseContract
697
+ .route({ method: 'DELETE', path: '/labels/{id}', ...t('planning') })
698
+ .input(ws.extend({ id: Id }))
699
+ .output(Ok),
700
+ },
701
+
702
+ // ---------------------------------------------------------------------- views
703
+ views: {
704
+ list: baseContract
705
+ .route({ method: 'GET', path: '/views', ...t('views') })
706
+ .input(ws.extend({ projectId: Id.nullable().optional() }))
707
+ .output(z.array(View)),
708
+ get: baseContract
709
+ .route({ method: 'GET', path: '/views/{id}', ...t('views') })
710
+ .input(ws.extend({ id: Id }))
711
+ .output(View),
712
+ create: baseContract
713
+ .route({ method: 'POST', path: '/views', ...t('views') })
714
+ .input(ws.extend(UpsertView.shape))
715
+ .output(View),
716
+ update: baseContract
717
+ .route({ method: 'PATCH', path: '/views/{id}', ...t('views') })
718
+ .input(ws.extend({ id: Id, patch: UpsertView.partial() }))
719
+ .output(View),
720
+ delete: baseContract
721
+ .route({ method: 'DELETE', path: '/views/{id}', ...t('views') })
722
+ .input(ws.extend({ id: Id }))
723
+ .output(Ok),
724
+ pin: baseContract
725
+ .route({ method: 'POST', path: '/views/{id}/pin', ...t('views') })
726
+ .input(ws.extend({ id: Id, pinned: z.boolean() }))
727
+ .output(View),
728
+ },
729
+
730
+ // ------------------------------------------------------------- intake / triage
731
+ intake: {
732
+ /** PUBLIC: intake form metadata by token (no auth) */
733
+ form: oc
734
+ .route({ method: 'GET', path: '/intake/{token}', ...t('intake') })
735
+ .input(z.object({ token: z.string().min(8) }))
736
+ .output(IntakeForm),
737
+ /** PUBLIC: submit the intake form → triage issue (no auth, honeypot + rate-limited) */
738
+ submit: oc
739
+ .route({ method: 'POST', path: '/intake/{token}', ...t('intake') })
740
+ .input(IntakeSubmission)
741
+ .output(z.object({ ok: z.literal(true), issueKey: IssueKey })),
742
+ },
743
+
744
+ triage: {
745
+ /** accept out of triage into the workflow (default: initial non-triage status) */
746
+ accept: baseContract
747
+ .route({ method: 'POST', path: '/triage/{issueId}/accept', ...t('intake') })
748
+ .input(iss.extend({ statusId: z.string().optional() }))
749
+ .output(Issue),
750
+ decline: baseContract
751
+ .route({ method: 'POST', path: '/triage/{issueId}/decline', ...t('intake') })
752
+ .input(iss.extend({ comment: z.string().max(2000).optional() }))
753
+ .output(Issue),
754
+ snooze: baseContract
755
+ .route({ method: 'POST', path: '/triage/{issueId}/snooze', ...t('intake') })
756
+ .input(iss.extend({ until: Timestamp }))
757
+ .output(Issue),
758
+ },
759
+
760
+ // -------------------------------------------------------------------- reports
761
+ reports: {
762
+ burndown: baseContract
763
+ .route({ method: 'GET', path: '/reports/burndown/{cycleId}', ...t('reports') })
764
+ .input(ws.extend({ cycleId: Id }))
765
+ .output(BurndownReport),
766
+ velocity: baseContract
767
+ .route({ method: 'GET', path: '/reports/velocity', ...t('reports') })
768
+ .input(pr.extend({ lastN: z.number().int().min(1).max(24).default(6) }))
769
+ .output(VelocityReport),
770
+ cfd: baseContract
771
+ .route({ method: 'GET', path: '/reports/cfd', ...t('reports') })
772
+ .input(pr.extend({ from: DateOnly, to: DateOnly }))
773
+ .output(CfdReport),
774
+ createdVsResolved: baseContract
775
+ .route({ method: 'GET', path: '/reports/created-vs-resolved', ...t('reports') })
776
+ .input(pr.extend({ from: DateOnly, to: DateOnly }))
777
+ .output(CreatedVsResolvedReport),
778
+ time: baseContract
779
+ .route({ method: 'GET', path: '/reports/time', ...t('reports', 'time') })
780
+ .input(
781
+ ws.extend({
782
+ from: DateOnly,
783
+ to: DateOnly,
784
+ projectId: Id.optional(),
785
+ userId: UserId.optional(),
786
+ billableOnly: z.boolean().default(false),
787
+ }),
788
+ )
789
+ .output(TimeReport),
790
+ },
791
+
792
+ // ---------------------------------------------------------------- time tracking
793
+ worklogs: {
794
+ list: baseContract
795
+ .route({ method: 'GET', path: '/issues/{issueId}/worklogs', ...t('time') })
796
+ .input(iss)
797
+ .output(z.array(Worklog)),
798
+ create: baseContract
799
+ .route({ method: 'POST', path: '/issues/{issueId}/worklogs', ...t('time') })
800
+ .input(iss.extend(UpsertWorklog.shape))
801
+ .output(z.object({ worklog: Worklog, issue: Issue })),
802
+ update: baseContract
803
+ .route({ method: 'PATCH', path: '/worklogs/{id}', ...t('time') })
804
+ .input(ws.extend({ id: Id, patch: UpsertWorklog.partial() }))
805
+ .output(Worklog),
806
+ delete: baseContract
807
+ .route({ method: 'DELETE', path: '/worklogs/{id}', ...t('time') })
808
+ .input(ws.extend({ id: Id }))
809
+ .output(Ok),
810
+ timers: {
811
+ /** start a timer on an issue (stops any running timer of the caller first) */
812
+ start: baseContract
813
+ .route({ method: 'POST', path: '/issues/{issueId}/timer', ...t('time') })
814
+ .input(iss.extend({ note: z.string().max(2000).optional() }))
815
+ .output(Timer),
816
+ /** stop the caller's running timer → worklog */
817
+ stop: baseContract
818
+ .route({ method: 'POST', path: '/timer/stop', ...t('time') })
819
+ .input(ws.extend({ discard: z.boolean().default(false) }))
820
+ .output(z.object({ worklog: Worklog.nullable() })),
821
+ current: baseContract
822
+ .route({ method: 'GET', path: '/timer', ...t('time') })
823
+ .input(ws)
824
+ .output(z.object({ timer: Timer.nullable() })),
825
+ },
826
+ },
827
+
828
+ // -------------------------------------------------------------------- imports
829
+ imports: {
830
+ /** start an import job (CSV / Jira JSON / Linear JSON export); file must be uploaded to core files first */
831
+ start: baseContract
832
+ .route({ method: 'POST', path: '/imports', ...t('imports') })
833
+ .input(
834
+ pr.extend({
835
+ source: ImportSource,
836
+ fileId: Id,
837
+ /** CSV: CsvMapping; jira/linear: source-specific options (all optional) */
838
+ mapping: z.union([CsvMapping, z.record(z.string(), z.unknown())]).default({}),
839
+ }),
840
+ )
841
+ .output(ImportJob),
842
+ get: baseContract
843
+ .route({ method: 'GET', path: '/imports/{id}', ...t('imports') })
844
+ .input(ws.extend({ id: Id }))
845
+ .output(ImportJob),
846
+ list: baseContract
847
+ .route({ method: 'GET', path: '/imports', ...t('imports') })
848
+ .input(pr)
849
+ .output(z.array(ImportJob)),
850
+ cancel: baseContract
851
+ .route({ method: 'POST', path: '/imports/{id}/cancel', ...t('imports') })
852
+ .input(ws.extend({ id: Id }))
853
+ .output(ImportJob),
854
+ },
855
+ }
856
+
857
+ export type TrackerContract = typeof trackerContract