@andypai/agent-kanban 0.6.0 → 0.6.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.
- package/README.md +42 -19
- package/package.json +3 -2
- package/src/__tests__/activity.test.ts +12 -0
- package/src/__tests__/api.test.ts +4 -2
- package/src/__tests__/board-utils.test.ts +16 -3
- package/src/__tests__/column-roles.test.ts +34 -0
- package/src/__tests__/commands/board.test.ts +7 -0
- package/src/__tests__/conflict.test.ts +11 -1
- package/src/__tests__/db.test.ts +70 -0
- package/src/__tests__/index.test.ts +55 -0
- package/src/__tests__/jira-cache.test.ts +56 -0
- package/src/__tests__/jira-jql.test.ts +51 -0
- package/src/__tests__/jira-provider-read.test.ts +50 -0
- package/src/__tests__/mcp-server.test.ts +2 -2
- package/src/__tests__/metrics.test.ts +37 -1
- package/src/__tests__/postgres-local-provider.test.ts +90 -1
- package/src/__tests__/server.test.ts +126 -0
- package/src/__tests__/use-cases.test.ts +77 -0
- package/src/__tests__/webhooks.test.ts +91 -22
- package/src/api.ts +58 -36
- package/src/column-roles.ts +52 -0
- package/src/commands/board.ts +4 -4
- package/src/db.ts +145 -114
- package/src/errors.ts +1 -0
- package/src/index.ts +84 -33
- package/src/mcp/core.ts +8 -7
- package/src/metrics.ts +48 -23
- package/src/provider-runtime.ts +9 -2
- package/src/providers/index.ts +4 -1
- package/src/providers/jira-cache.ts +23 -6
- package/src/providers/jira-core.ts +926 -0
- package/src/providers/jira-jql.ts +48 -0
- package/src/providers/jira.ts +111 -759
- package/src/providers/linear-cache.ts +5 -3
- package/src/providers/linear-core.ts +693 -0
- package/src/providers/linear.ts +70 -554
- package/src/providers/postgres-jira-cache.ts +597 -0
- package/src/providers/postgres-jira.ts +15 -1312
- package/src/providers/postgres-linear-cache.ts +500 -0
- package/src/providers/postgres-linear.ts +22 -1088
- package/src/providers/postgres-local.ts +181 -74
- package/src/providers/types.ts +71 -2
- package/src/server.ts +118 -32
- package/src/use-cases.ts +139 -0
- package/src/webhooks.ts +19 -0
- package/ui/dist/assets/index-65LcV0R7.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-CFhtfqCn.js +0 -40
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { Sql } from 'postgres'
|
|
1
|
+
import type { Sql, TransactionSql } from 'postgres'
|
|
2
2
|
|
|
3
3
|
import { ErrorCode, KanbanError } from '../errors'
|
|
4
4
|
import { generateId } from '../id'
|
|
5
|
+
import { selectDoneColumnIds, selectInProgressColumnIds } from '../column-roles'
|
|
5
6
|
import type {
|
|
6
7
|
ActivityEntry,
|
|
7
8
|
BoardBootstrap,
|
|
@@ -26,6 +27,10 @@ import type {
|
|
|
26
27
|
import type { LocalTrackerConfig } from '../tracker-config'
|
|
27
28
|
import { normalizeLabels, parseStoredLabels } from '../labels'
|
|
28
29
|
|
|
30
|
+
// Accepts either the root connection or a scoped transaction handle so the
|
|
31
|
+
// mutation helpers can run inside or outside a `sql.begin(...)` block.
|
|
32
|
+
type Exec = Sql | TransactionSql
|
|
33
|
+
|
|
29
34
|
const DEFAULT_COLUMNS = [
|
|
30
35
|
{ name: 'recurring', position: 0 },
|
|
31
36
|
{ name: 'backlog', position: 1 },
|
|
@@ -269,8 +274,9 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
269
274
|
fieldChanged: string | null,
|
|
270
275
|
oldValue: string | null,
|
|
271
276
|
newValue: string | null,
|
|
277
|
+
exec: Exec = this.sql,
|
|
272
278
|
): Promise<void> {
|
|
273
|
-
await
|
|
279
|
+
await exec`
|
|
274
280
|
INSERT INTO activity_log (id, task_id, action, field_changed, old_value, new_value, timestamp)
|
|
275
281
|
VALUES (${generateId('a')}, ${taskId}, ${action}, ${fieldChanged}, ${oldValue}, ${newValue}, ${nowIso()})
|
|
276
282
|
`
|
|
@@ -360,23 +366,26 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
360
366
|
const column = input.column
|
|
361
367
|
? await this.resolveColumn(input.column)
|
|
362
368
|
: await this.resolveDefaultTaskColumn()
|
|
363
|
-
const [positionRow] = await this.sql<{ next: string | number }[]>`
|
|
364
|
-
SELECT COALESCE(MAX(position), -1) + 1 AS next FROM tasks WHERE column_id = ${column.id}
|
|
365
|
-
`
|
|
366
369
|
const id = generateId('t')
|
|
367
370
|
const timestamp = nowIso()
|
|
368
|
-
await this.sql
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
371
|
+
await this.sql.begin(async (tx) => {
|
|
372
|
+
const [positionRow] = await tx<{ next: string | number }[]>`
|
|
373
|
+
SELECT COALESCE(MAX(position), -1) + 1 AS next FROM tasks WHERE column_id = ${column.id}
|
|
374
|
+
`
|
|
375
|
+
await tx`
|
|
376
|
+
INSERT INTO tasks (
|
|
377
|
+
id, title, description, column_id, position, priority, assignee, project, labels, metadata,
|
|
378
|
+
revision, created_at, updated_at
|
|
379
|
+
)
|
|
380
|
+
VALUES (
|
|
381
|
+
${id}, ${input.title}, ${input.description ?? ''}, ${column.id}, ${Number(positionRow?.next ?? 0)},
|
|
382
|
+
${priority}, ${input.assignee ?? ''}, ${input.project ?? ''}, ${JSON.stringify(labels)}, ${metadata},
|
|
383
|
+
0, ${timestamp}, ${timestamp}
|
|
384
|
+
)
|
|
385
|
+
`
|
|
386
|
+
await this.insertActivity(id, 'created', null, null, input.title, tx)
|
|
387
|
+
await this.enterColumn(id, column.id, tx)
|
|
388
|
+
})
|
|
380
389
|
return this.getTask(id)
|
|
381
390
|
}
|
|
382
391
|
|
|
@@ -403,39 +412,43 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
403
412
|
project: input.project ?? current.project,
|
|
404
413
|
metadata: metadata ?? current.metadata,
|
|
405
414
|
}
|
|
406
|
-
await this.sql
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
415
|
+
await this.sql.begin(async (tx) => {
|
|
416
|
+
await tx`
|
|
417
|
+
UPDATE tasks
|
|
418
|
+
SET title = ${next.title},
|
|
419
|
+
description = ${next.description},
|
|
420
|
+
priority = ${next.priority},
|
|
421
|
+
assignee = ${next.assignee},
|
|
422
|
+
project = ${next.project},
|
|
423
|
+
metadata = ${next.metadata},
|
|
424
|
+
revision = revision + 1,
|
|
425
|
+
updated_at = ${nowIso()}
|
|
426
|
+
WHERE id = ${current.id}
|
|
427
|
+
`
|
|
428
|
+
if (input.title !== undefined && input.title !== current.title) {
|
|
429
|
+
await this.insertActivity(current.id, 'updated', 'title', current.title, input.title, tx)
|
|
430
|
+
}
|
|
431
|
+
if (input.assignee !== undefined && input.assignee !== current.assignee) {
|
|
432
|
+
await this.insertActivity(
|
|
433
|
+
current.id,
|
|
434
|
+
'assigned',
|
|
435
|
+
'assignee',
|
|
436
|
+
current.assignee,
|
|
437
|
+
input.assignee,
|
|
438
|
+
tx,
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
if (input.priority !== undefined && input.priority !== current.priority) {
|
|
442
|
+
await this.insertActivity(
|
|
443
|
+
current.id,
|
|
444
|
+
'prioritized',
|
|
445
|
+
'priority',
|
|
446
|
+
current.priority,
|
|
447
|
+
input.priority,
|
|
448
|
+
tx,
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
})
|
|
439
452
|
return this.getTask(current.id)
|
|
440
453
|
}
|
|
441
454
|
|
|
@@ -443,23 +456,48 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
443
456
|
await this.ready
|
|
444
457
|
const current = await this.requireTask(idOrRef)
|
|
445
458
|
const column = await this.resolveColumn(columnName)
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
459
|
+
const oldColumnId = current.column_id
|
|
460
|
+
|
|
461
|
+
// No-op move to the same column: skip the write so we don't fragment
|
|
462
|
+
// column-time tracking (spurious exit/enter) or re-append the task.
|
|
463
|
+
if (column.id === oldColumnId) return this.getTask(current.id)
|
|
464
|
+
|
|
465
|
+
await this.sql.begin(async (tx) => {
|
|
466
|
+
const [posRow] = await tx<{ next: string | number }[]>`
|
|
467
|
+
SELECT COALESCE(MAX(position), -1) + 1 AS next FROM tasks WHERE column_id = ${column.id}
|
|
468
|
+
`
|
|
469
|
+
await tx`
|
|
470
|
+
UPDATE tasks
|
|
471
|
+
SET column_id = ${column.id},
|
|
472
|
+
position = ${Number(posRow?.next ?? 0)},
|
|
473
|
+
revision = revision + 1,
|
|
474
|
+
updated_at = ${nowIso()}
|
|
475
|
+
WHERE id = ${current.id}
|
|
476
|
+
`
|
|
477
|
+
await this.renumberColumn(oldColumnId, tx)
|
|
478
|
+
await this.exitColumn(current.id, oldColumnId, tx)
|
|
479
|
+
await this.enterColumn(current.id, column.id, tx)
|
|
480
|
+
await this.insertActivity(
|
|
481
|
+
current.id,
|
|
482
|
+
'moved',
|
|
483
|
+
'column',
|
|
484
|
+
current.column_name ?? oldColumnId,
|
|
485
|
+
column.name,
|
|
486
|
+
tx,
|
|
487
|
+
)
|
|
488
|
+
})
|
|
455
489
|
return this.getTask(current.id)
|
|
456
490
|
}
|
|
457
491
|
|
|
458
492
|
async deleteTask(idOrRef: string): Promise<Task> {
|
|
459
493
|
await this.ready
|
|
460
494
|
const task = await this.getTask(idOrRef)
|
|
461
|
-
await this.sql
|
|
462
|
-
|
|
495
|
+
await this.sql.begin(async (tx) => {
|
|
496
|
+
await this.exitColumn(task.id, task.column_id, tx)
|
|
497
|
+
await this.insertActivity(task.id, 'deleted', null, task.title, null, tx)
|
|
498
|
+
await tx`DELETE FROM tasks WHERE id = ${task.id}`
|
|
499
|
+
await this.renumberColumn(task.column_id, tx)
|
|
500
|
+
})
|
|
463
501
|
return task
|
|
464
502
|
}
|
|
465
503
|
|
|
@@ -529,14 +567,20 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
529
567
|
const [total] = await this.sql<
|
|
530
568
|
{ count: string | number }[]
|
|
531
569
|
>`SELECT COUNT(*) AS count FROM tasks`
|
|
532
|
-
|
|
533
|
-
|
|
570
|
+
// Classify columns by role (custom names / KANBAN_DEFAULT_COLUMNS) instead of
|
|
571
|
+
// matching the literal 'done' / 'in-progress'. Kept in lockstep with the
|
|
572
|
+
// SQLite copy in metrics.ts:getBoardMetrics.
|
|
573
|
+
const columns = await this.sql<{ id: string; name: string; position: number }[]>`
|
|
574
|
+
SELECT id, name, position FROM columns ORDER BY position
|
|
534
575
|
`
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
576
|
+
const doneColumnIds = selectDoneColumnIds(columns)
|
|
577
|
+
const inProgressColumnIds = selectInProgressColumnIds(columns)
|
|
578
|
+
const [completed] =
|
|
579
|
+
doneColumnIds.length > 0
|
|
580
|
+
? await this.sql<{ count: string | number }[]>`
|
|
581
|
+
SELECT COUNT(*) AS count FROM tasks WHERE column_id IN ${this.sql(doneColumnIds)}
|
|
538
582
|
`
|
|
539
|
-
|
|
583
|
+
: [{ count: 0 }]
|
|
540
584
|
const tasksByColumn = await this.sql<{ column_name: string; count: string | number }[]>`
|
|
541
585
|
SELECT columns.name AS column_name, COUNT(tasks.id) AS count
|
|
542
586
|
FROM columns
|
|
@@ -551,6 +595,39 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
551
595
|
const projects = await this.discoveredProjects()
|
|
552
596
|
const totalTasks = Number(total?.count ?? 0)
|
|
553
597
|
const completedTasks = Number(completed?.count ?? 0)
|
|
598
|
+
// Average completion time = first tracked entry (creation) -> first time the
|
|
599
|
+
// task entered Done. Using the Done *entry* (not exit) counts tasks resting
|
|
600
|
+
// in Done; MIN() handles tasks that re-enter Done. Cast to timestamptz so the
|
|
601
|
+
// ISO-UTC strings compare correctly regardless of the server timezone. Kept
|
|
602
|
+
// in lockstep with the SQLite copy in metrics.ts:getBoardMetrics.
|
|
603
|
+
const [avgResult] =
|
|
604
|
+
doneColumnIds.length > 0
|
|
605
|
+
? await this.sql<{ avg_hours: string | number | null }[]>`
|
|
606
|
+
SELECT AVG(
|
|
607
|
+
EXTRACT(EPOCH FROM (done_enter.entered_at - first_enter.entered_at)) / 3600
|
|
608
|
+
) AS avg_hours
|
|
609
|
+
FROM (
|
|
610
|
+
SELECT ct.task_id, MIN(ct.entered_at::timestamptz) AS entered_at
|
|
611
|
+
FROM column_time_tracking ct
|
|
612
|
+
WHERE ct.column_id IN ${this.sql(doneColumnIds)}
|
|
613
|
+
GROUP BY ct.task_id
|
|
614
|
+
) done_enter
|
|
615
|
+
JOIN (
|
|
616
|
+
SELECT task_id, MIN(entered_at::timestamptz) AS entered_at
|
|
617
|
+
FROM column_time_tracking GROUP BY task_id
|
|
618
|
+
) first_enter ON first_enter.task_id = done_enter.task_id
|
|
619
|
+
`
|
|
620
|
+
: [{ avg_hours: null }]
|
|
621
|
+
const [weekCount] = await this.sql<{ count: string | number }[]>`
|
|
622
|
+
SELECT COUNT(*) AS count FROM tasks
|
|
623
|
+
WHERE created_at::timestamptz >= NOW() - INTERVAL '7 days'
|
|
624
|
+
`
|
|
625
|
+
const inProgressNames = new Set(
|
|
626
|
+
columns.filter((c) => inProgressColumnIds.includes(c.id)).map((c) => c.name),
|
|
627
|
+
)
|
|
628
|
+
const inProgressCount = tasksByColumn
|
|
629
|
+
.filter((row) => inProgressNames.has(row.column_name))
|
|
630
|
+
.reduce((sum, row) => sum + Number(row.count), 0)
|
|
554
631
|
return {
|
|
555
632
|
tasksByColumn: tasksByColumn.map((row) => ({
|
|
556
633
|
column_name: row.column_name,
|
|
@@ -562,13 +639,10 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
562
639
|
})),
|
|
563
640
|
totalTasks,
|
|
564
641
|
completedTasks,
|
|
565
|
-
avgCompletionHours: null,
|
|
566
|
-
recentActivity: await this.getActivity(
|
|
567
|
-
tasksCreatedThisWeek: 0,
|
|
568
|
-
inProgressCount
|
|
569
|
-
tasksByColumn.find((row) => row.column_name === 'in-progress')?.count === undefined
|
|
570
|
-
? 0
|
|
571
|
-
: Number(tasksByColumn.find((row) => row.column_name === 'in-progress')!.count),
|
|
642
|
+
avgCompletionHours: avgResult?.avg_hours != null ? Number(avgResult.avg_hours) : null,
|
|
643
|
+
recentActivity: await this.getActivity(20),
|
|
644
|
+
tasksCreatedThisWeek: Number(weekCount?.count ?? 0),
|
|
645
|
+
inProgressCount,
|
|
572
646
|
completionPercent: totalTasks === 0 ? 0 : Math.round((completedTasks / totalTasks) * 100),
|
|
573
647
|
assignees,
|
|
574
648
|
projects,
|
|
@@ -589,6 +663,39 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
589
663
|
return rows.map((row) => row.project)
|
|
590
664
|
}
|
|
591
665
|
|
|
666
|
+
private async enterColumn(
|
|
667
|
+
taskId: string,
|
|
668
|
+
columnId: string,
|
|
669
|
+
exec: Exec = this.sql,
|
|
670
|
+
): Promise<void> {
|
|
671
|
+
const id = generateId('ct')
|
|
672
|
+
await exec`
|
|
673
|
+
INSERT INTO column_time_tracking (id, task_id, column_id, entered_at)
|
|
674
|
+
VALUES (${id}, ${taskId}, ${columnId}, ${nowIso()})
|
|
675
|
+
`
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private async exitColumn(taskId: string, columnId: string, exec: Exec = this.sql): Promise<void> {
|
|
679
|
+
await exec`
|
|
680
|
+
UPDATE column_time_tracking
|
|
681
|
+
SET exited_at = ${nowIso()}
|
|
682
|
+
WHERE task_id = ${taskId} AND column_id = ${columnId} AND exited_at IS NULL
|
|
683
|
+
`
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Compact positions to a contiguous 0..n-1 sequence in a single statement.
|
|
687
|
+
private async renumberColumn(columnId: string, exec: Exec = this.sql): Promise<void> {
|
|
688
|
+
await exec`
|
|
689
|
+
UPDATE tasks t
|
|
690
|
+
SET position = s.rn
|
|
691
|
+
FROM (
|
|
692
|
+
SELECT id, ROW_NUMBER() OVER (ORDER BY position, created_at, id) - 1 AS rn
|
|
693
|
+
FROM tasks WHERE column_id = ${columnId}
|
|
694
|
+
) s
|
|
695
|
+
WHERE t.id = s.id AND t.position <> s.rn
|
|
696
|
+
`
|
|
697
|
+
}
|
|
698
|
+
|
|
592
699
|
async getConfig(): Promise<BoardConfig> {
|
|
593
700
|
await this.ready
|
|
594
701
|
return {
|
package/src/providers/types.ts
CHANGED
|
@@ -55,28 +55,97 @@ export interface ProviderSyncStatus {
|
|
|
55
55
|
lastWebhookAt: string | null
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* The provider contract is composed from small, cohesive capability interfaces
|
|
60
|
+
* rather than one monolithic type. `KanbanProvider` is their intersection, so
|
|
61
|
+
* every existing consumer keeps the same surface; the split exists to document
|
|
62
|
+
* which operations belong together and to let internal code depend on just the
|
|
63
|
+
* capability it needs.
|
|
64
|
+
*
|
|
65
|
+
* Note on "unsupported" operations: providers that cannot perform a given
|
|
66
|
+
* operation (e.g. Jira/Linear `deleteTask`, `getMetrics`, `patchConfig`) still
|
|
67
|
+
* implement the method but throw `UNSUPPORTED_OPERATION` at runtime, and the
|
|
68
|
+
* UI hides those affordances via `ProviderCapabilities`. These members are
|
|
69
|
+
* therefore required on the contract — only the cache/webhook members below,
|
|
70
|
+
* which a provider may genuinely not have, are optional.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/** Provider self-identification. */
|
|
74
|
+
export interface ProviderIdentity {
|
|
59
75
|
readonly type: 'local' | 'linear' | 'jira'
|
|
76
|
+
}
|
|
60
77
|
|
|
78
|
+
/** Board/context/config reads that materialize the current board view. */
|
|
79
|
+
export interface BoardReader {
|
|
61
80
|
getContext(): Promise<ProviderContext>
|
|
62
81
|
getBootstrap(): Promise<BoardBootstrap>
|
|
63
82
|
getBoard(): Promise<BoardView>
|
|
64
83
|
listColumns(): Promise<Column[]>
|
|
84
|
+
getConfig(): Promise<BoardConfig>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Task reads. */
|
|
88
|
+
export interface TaskReader {
|
|
65
89
|
listTasks(filters?: TaskListFilters): Promise<Task[]>
|
|
66
90
|
getTask(idOrRef: string): Promise<Task>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Task mutations. */
|
|
94
|
+
export interface TaskWriter {
|
|
67
95
|
createTask(input: CreateTaskInput): Promise<Task>
|
|
68
96
|
updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task>
|
|
69
97
|
moveTask(idOrRef: string, column: string): Promise<Task>
|
|
70
98
|
deleteTask(idOrRef: string): Promise<Task>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Comment reads and writes. */
|
|
102
|
+
export interface CommentCapability {
|
|
71
103
|
listComments(idOrRef: string): Promise<TaskComment[]>
|
|
72
104
|
getComment(idOrRef: string, commentId: string): Promise<TaskComment>
|
|
73
105
|
comment(idOrRef: string, body: string): Promise<TaskComment>
|
|
74
106
|
updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Activity-feed reads. */
|
|
110
|
+
export interface ActivityReader {
|
|
75
111
|
getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]>
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Board-metrics reads. */
|
|
115
|
+
export interface MetricsReader {
|
|
76
116
|
getMetrics(): Promise<BoardMetrics>
|
|
77
|
-
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Board-config mutations. */
|
|
120
|
+
export interface ConfigWriter {
|
|
78
121
|
patchConfig(input: Partial<BoardConfig>): Promise<BoardConfig>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Optional cache-management hooks used by the server's background warmer. Only
|
|
126
|
+
* providers backed by a cache (Linear/Jira) implement these; the local
|
|
127
|
+
* provider omits them and the server guards every call.
|
|
128
|
+
*/
|
|
129
|
+
export interface CacheManaged {
|
|
79
130
|
syncCache?(): Promise<void>
|
|
131
|
+
// Signals that a background warmer owns cache refresh (server mode), so the
|
|
132
|
+
// provider may serve reads from the warm cache without a blocking foreground sync.
|
|
133
|
+
setBackgroundManaged?(managed: boolean): void
|
|
80
134
|
getSyncStatus?(): Promise<ProviderSyncStatus | null>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Optional inbound-webhook handling (Linear/Jira only). */
|
|
138
|
+
export interface WebhookReceiver {
|
|
81
139
|
handleWebhook?(payload: WebhookRequest): Promise<WebhookResult>
|
|
82
140
|
}
|
|
141
|
+
|
|
142
|
+
export type KanbanProvider = ProviderIdentity &
|
|
143
|
+
BoardReader &
|
|
144
|
+
TaskReader &
|
|
145
|
+
TaskWriter &
|
|
146
|
+
CommentCapability &
|
|
147
|
+
ActivityReader &
|
|
148
|
+
MetricsReader &
|
|
149
|
+
ConfigWriter &
|
|
150
|
+
CacheManaged &
|
|
151
|
+
WebhookReceiver
|
package/src/server.ts
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { Buffer } from 'node:buffer'
|
|
4
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
3
5
|
import { handleRequest } from './api'
|
|
4
6
|
import type { ServerWebSocket } from 'bun'
|
|
5
7
|
import type { KanbanProvider } from './providers/types'
|
|
6
8
|
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from './sync-config'
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
// CORS is origin hygiene for cross-origin browser clients, never an auth control.
|
|
11
|
+
// When no allowed origin is configured we emit no CORS headers (same-origin only),
|
|
12
|
+
// which covers the bundled UI (served from this server) and the vite dev proxy.
|
|
13
|
+
function buildCorsHeaders(allowedOrigin?: string): Record<string, string> {
|
|
14
|
+
if (!allowedOrigin) return {}
|
|
15
|
+
return {
|
|
16
|
+
'Access-Control-Allow-Origin': allowedOrigin,
|
|
17
|
+
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
|
|
18
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
19
|
+
Vary: 'Origin',
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function safeEqual(a: string, b: string): boolean {
|
|
24
|
+
const aBuf = Buffer.from(a)
|
|
25
|
+
const bBuf = Buffer.from(b)
|
|
26
|
+
if (aBuf.length !== bBuf.length) return false
|
|
27
|
+
return timingSafeEqual(aBuf, bBuf)
|
|
13
28
|
}
|
|
14
29
|
interface BackgroundSyncState {
|
|
15
30
|
enabled: boolean
|
|
@@ -22,6 +37,16 @@ interface BackgroundSyncState {
|
|
|
22
37
|
|
|
23
38
|
export interface StartServerOptions {
|
|
24
39
|
syncIntervalMs?: number
|
|
40
|
+
/**
|
|
41
|
+
* When set, all `/api/*` routes (reads and mutations) and the `/ws` upgrade
|
|
42
|
+
* require `Authorization: Bearer <token>` (or `?token=` for the WebSocket,
|
|
43
|
+
* which cannot send headers). `/api/health` and `/api/webhooks/*` are exempt:
|
|
44
|
+
* health is a liveness probe and webhooks authenticate with the provider
|
|
45
|
+
* webhook secret instead. When unset, the API is open (localhost default).
|
|
46
|
+
*/
|
|
47
|
+
authToken?: string
|
|
48
|
+
/** Allowed CORS origin; when unset, no CORS headers are emitted (same-origin). */
|
|
49
|
+
allowedOrigin?: string
|
|
25
50
|
}
|
|
26
51
|
|
|
27
52
|
export interface StartedServer {
|
|
@@ -29,22 +54,15 @@ export interface StartedServer {
|
|
|
29
54
|
stop(closeActiveConnections?: boolean): void
|
|
30
55
|
}
|
|
31
56
|
|
|
32
|
-
function
|
|
33
|
-
const
|
|
34
|
-
for (const ws of wsClients) {
|
|
35
|
-
ws.send(msg)
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function applyCorsHeaders(response: Response): void {
|
|
40
|
-
for (const [header, value] of Object.entries(CORS_HEADERS)) {
|
|
57
|
+
function applyCorsHeaders(response: Response, corsHeaders: Record<string, string>): void {
|
|
58
|
+
for (const [header, value] of Object.entries(corsHeaders)) {
|
|
41
59
|
response.headers.set(header, value)
|
|
42
60
|
}
|
|
43
61
|
}
|
|
44
62
|
|
|
45
|
-
function jsonWithCors(body: unknown, status = 200): Response {
|
|
63
|
+
function jsonWithCors(body: unknown, corsHeaders: Record<string, string>, status = 200): Response {
|
|
46
64
|
const response = Response.json(body, { status })
|
|
47
|
-
applyCorsHeaders(response)
|
|
65
|
+
applyCorsHeaders(response, corsHeaders)
|
|
48
66
|
return response
|
|
49
67
|
}
|
|
50
68
|
|
|
@@ -66,6 +84,48 @@ export function startServer(
|
|
|
66
84
|
const syncIntervalMs = opts.syncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
|
|
67
85
|
const syncCache = provider.syncCache?.bind(provider)
|
|
68
86
|
const getSyncStatus = provider.getSyncStatus?.bind(provider)
|
|
87
|
+
const corsHeaders = buildCorsHeaders(opts.allowedOrigin)
|
|
88
|
+
const authToken = opts.authToken
|
|
89
|
+
|
|
90
|
+
// Per-instance so multiple servers (e.g. in tests) don't share or cross-broadcast
|
|
91
|
+
// to each other's sockets.
|
|
92
|
+
const wsClients = new Set<ServerWebSocket<unknown>>()
|
|
93
|
+
const broadcast = (data: unknown): void => {
|
|
94
|
+
const msg = JSON.stringify(data)
|
|
95
|
+
for (const ws of wsClients) {
|
|
96
|
+
// A dead/closing socket must not abort the fan-out to the rest.
|
|
97
|
+
try {
|
|
98
|
+
ws.send(msg)
|
|
99
|
+
} catch {
|
|
100
|
+
wsClients.delete(ws)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isAuthorized = (req: Request, url: URL, allowQueryToken: boolean): boolean => {
|
|
106
|
+
if (!authToken) return true
|
|
107
|
+
const header = req.headers.get('authorization')
|
|
108
|
+
// The `?token=` fallback exists only for the WebSocket handshake, which can't
|
|
109
|
+
// carry an Authorization header. HTTP API routes require the header so tokens
|
|
110
|
+
// don't end up in URLs, logs, or referrers.
|
|
111
|
+
const provided = header?.startsWith('Bearer ')
|
|
112
|
+
? header.slice('Bearer '.length)
|
|
113
|
+
: allowQueryToken
|
|
114
|
+
? url.searchParams.get('token')
|
|
115
|
+
: null
|
|
116
|
+
return provided ? safeEqual(provided, authToken) : false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Health is a public liveness probe; webhooks authenticate with the provider
|
|
120
|
+
// webhook secret, not this token. Everything else under /api plus /ws is gated.
|
|
121
|
+
const pathRequiresAuth = (pathname: string): boolean => {
|
|
122
|
+
if (!authToken) return false
|
|
123
|
+
if (pathname === '/ws') return true
|
|
124
|
+
if (pathname === '/api/health') return false
|
|
125
|
+
if (pathname.startsWith('/api/webhooks/')) return false
|
|
126
|
+
return pathname.startsWith('/api/')
|
|
127
|
+
}
|
|
128
|
+
|
|
69
129
|
const backgroundSync: BackgroundSyncState = {
|
|
70
130
|
enabled: typeof syncCache === 'function',
|
|
71
131
|
inFlight: false,
|
|
@@ -103,6 +163,9 @@ export function startServer(
|
|
|
103
163
|
}
|
|
104
164
|
|
|
105
165
|
if (syncCache) {
|
|
166
|
+
// The background warmer owns cache refresh; let the provider serve reads from
|
|
167
|
+
// the warm cache instead of blocking each request on a provider network sync.
|
|
168
|
+
provider.setBackgroundManaged?.(true)
|
|
106
169
|
void runBackgroundSync('startup').finally(() => {
|
|
107
170
|
scheduleBackgroundSync()
|
|
108
171
|
})
|
|
@@ -110,6 +173,11 @@ export function startServer(
|
|
|
110
173
|
|
|
111
174
|
const server = Bun.serve({
|
|
112
175
|
port,
|
|
176
|
+
// Default is 10s; a cold-start provider sync (e.g. a Jira full reconcile) can
|
|
177
|
+
// exceed that and Bun would otherwise close the connection mid-flight, surfacing
|
|
178
|
+
// as ERR_EMPTY_RESPONSE. Steady-state reads are served from the warm cache and
|
|
179
|
+
// return well under this ceiling; this is just a safety net for slow cold starts.
|
|
180
|
+
idleTimeout: 255,
|
|
113
181
|
websocket: {
|
|
114
182
|
open(ws) {
|
|
115
183
|
wsClients.add(ws)
|
|
@@ -129,7 +197,17 @@ export function startServer(
|
|
|
129
197
|
|
|
130
198
|
// Handle OPTIONS preflight first (before /api routing)
|
|
131
199
|
if (req.method === 'OPTIONS') {
|
|
132
|
-
return new Response(null, { headers:
|
|
200
|
+
return new Response(null, { headers: corsHeaders })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Auth gate: protect all /api/* and /ws except the public health probe and
|
|
204
|
+
// webhook routes (which authenticate with the provider webhook secret).
|
|
205
|
+
if (pathRequiresAuth(pathname) && !isAuthorized(req, url, pathname === '/ws')) {
|
|
206
|
+
return jsonWithCors(
|
|
207
|
+
{ ok: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid API token' } },
|
|
208
|
+
corsHeaders,
|
|
209
|
+
401,
|
|
210
|
+
)
|
|
133
211
|
}
|
|
134
212
|
|
|
135
213
|
// WebSocket upgrade
|
|
@@ -140,10 +218,13 @@ export function startServer(
|
|
|
140
218
|
}
|
|
141
219
|
|
|
142
220
|
if (pathname === '/api/health') {
|
|
143
|
-
return jsonWithCors(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
221
|
+
return jsonWithCors(
|
|
222
|
+
{
|
|
223
|
+
ok: true,
|
|
224
|
+
data: { status: 'running', wsClients: wsClients.size, provider: provider.type },
|
|
225
|
+
},
|
|
226
|
+
corsHeaders,
|
|
227
|
+
)
|
|
147
228
|
}
|
|
148
229
|
|
|
149
230
|
if (pathname === '/api/ready') {
|
|
@@ -157,22 +238,26 @@ export function startServer(
|
|
|
157
238
|
backgroundSync,
|
|
158
239
|
},
|
|
159
240
|
},
|
|
241
|
+
corsHeaders,
|
|
160
242
|
ready ? 200 : 503,
|
|
161
243
|
)
|
|
162
244
|
}
|
|
163
245
|
|
|
164
246
|
if (pathname === '/api/sync-status') {
|
|
165
247
|
const providerSync = (await getSyncStatus?.()) ?? null
|
|
166
|
-
return jsonWithCors(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
248
|
+
return jsonWithCors(
|
|
249
|
+
{
|
|
250
|
+
ok: true,
|
|
251
|
+
data: {
|
|
252
|
+
status: 'running',
|
|
253
|
+
provider: provider.type,
|
|
254
|
+
wsClients: wsClients.size,
|
|
255
|
+
backgroundSync,
|
|
256
|
+
providerSync,
|
|
257
|
+
},
|
|
174
258
|
},
|
|
175
|
-
|
|
259
|
+
corsHeaders,
|
|
260
|
+
)
|
|
176
261
|
}
|
|
177
262
|
|
|
178
263
|
if (pathname.startsWith('/api/')) {
|
|
@@ -180,7 +265,7 @@ export function startServer(
|
|
|
180
265
|
forwardedUrl.pathname = pathname
|
|
181
266
|
const forwardedReq = new Request(forwardedUrl.toString(), req)
|
|
182
267
|
const result = await handleRequest(provider, forwardedReq)
|
|
183
|
-
applyCorsHeaders(result.response)
|
|
268
|
+
applyCorsHeaders(result.response, corsHeaders)
|
|
184
269
|
if (result.mutated && result.response.ok) {
|
|
185
270
|
broadcast(result.event ?? { type: 'refresh' })
|
|
186
271
|
}
|
|
@@ -212,7 +297,8 @@ export function startServer(
|
|
|
212
297
|
clearTimeout(syncTimer)
|
|
213
298
|
syncTimer = null
|
|
214
299
|
}
|
|
215
|
-
|
|
300
|
+
wsClients.clear()
|
|
301
|
+
void server.stop(closeActiveConnections)
|
|
216
302
|
},
|
|
217
303
|
}
|
|
218
304
|
}
|