@andypai/agent-kanban 0.2.0 → 0.3.1
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 +120 -24
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +16 -10
- package/src/__tests__/api.test.ts +99 -3
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +7 -14
- package/src/__tests__/commands/bulk.test.ts +3 -3
- package/src/__tests__/commands/column.test.ts +4 -4
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/db.test.ts +2 -2
- package/src/__tests__/id.test.ts +1 -1
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +180 -0
- package/src/__tests__/jira-cache.test.ts +304 -0
- package/src/__tests__/jira-client.test.ts +169 -0
- package/src/__tests__/jira-provider-comment.test.ts +281 -0
- package/src/__tests__/jira-provider-mutations.test.ts +771 -0
- package/src/__tests__/jira-provider-read.test.ts +594 -0
- package/src/__tests__/jira-wiring.test.ts +187 -0
- package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
- package/src/__tests__/linear-provider-comment.test.ts +243 -0
- package/src/__tests__/linear-provider-sync.test.ts +488 -0
- package/src/__tests__/local-provider-comment.test.ts +60 -0
- package/src/__tests__/mcp-core.test.ts +164 -0
- package/src/__tests__/mcp-server.test.ts +252 -0
- package/src/__tests__/metrics.test.ts +2 -2
- package/src/__tests__/output.test.ts +1 -1
- package/src/__tests__/provider-capabilities.test.ts +40 -0
- package/src/__tests__/server.test.ts +291 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +2 -12
- package/src/api.ts +156 -21
- package/src/commands/board.ts +4 -14
- package/src/commands/bulk.ts +4 -4
- package/src/commands/column.ts +4 -4
- package/src/commands/mcp.ts +87 -0
- package/src/config.ts +1 -1
- package/src/db.ts +118 -6
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +83 -35
- package/src/mcp/core.ts +193 -0
- package/src/mcp/errors.ts +109 -0
- package/src/mcp/index.ts +13 -0
- package/src/mcp/server.ts +512 -0
- package/src/mcp/types.ts +72 -0
- package/src/metrics.ts +1 -1
- package/src/output.ts +1 -1
- package/src/providers/capabilities.ts +22 -17
- package/src/providers/errors.ts +1 -1
- package/src/providers/index.ts +36 -6
- package/src/providers/jira-adf.ts +275 -0
- package/src/providers/jira-cache.ts +625 -0
- package/src/providers/jira-client.ts +390 -0
- package/src/providers/jira.ts +773 -0
- package/src/providers/linear-cache.ts +250 -71
- package/src/providers/linear-client.ts +255 -15
- package/src/providers/linear.ts +338 -20
- package/src/providers/local.ts +74 -23
- package/src/providers/types.ts +19 -3
- package/src/server.ts +141 -13
- package/src/tunnel.ts +79 -0
- package/src/types.ts +19 -2
- package/src/webhooks.ts +36 -0
- package/ui/dist/assets/index-DBnoKL_k.css +1 -0
- package/ui/dist/assets/index-qNVJ6clH.js +40 -0
- package/ui/dist/index.html +2 -2
- package/src/__tests__/commands/task.test.ts +0 -144
- package/src/commands/task.ts +0 -117
- package/src/fixtures.ts +0 -128
- package/ui/dist/assets/index-B8f9NB4z.css +0 -1
- package/ui/dist/assets/index-zWp-rB7b.js +0 -40
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { Client } from '@modelcontextprotocol/sdk/client'
|
|
4
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
5
|
+
import { addTask, initSchema, seedDefaultColumns } from '../db'
|
|
6
|
+
import { createTrackerCore, createTrackerMcpServer, TrackerMcpError } from '../mcp/index'
|
|
7
|
+
import { LocalProvider } from '../providers/local'
|
|
8
|
+
|
|
9
|
+
interface TestScope {
|
|
10
|
+
actor: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let db: Database
|
|
14
|
+
let provider: LocalProvider
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
db = new Database(':memory:')
|
|
18
|
+
db.run('PRAGMA foreign_keys = ON')
|
|
19
|
+
initSchema(db)
|
|
20
|
+
seedDefaultColumns(db)
|
|
21
|
+
provider = new LocalProvider(db, ':memory:')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
db.close()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function initializeBody() {
|
|
29
|
+
return {
|
|
30
|
+
jsonrpc: '2.0',
|
|
31
|
+
id: 1,
|
|
32
|
+
method: 'initialize',
|
|
33
|
+
params: {
|
|
34
|
+
protocolVersion: '2025-03-26',
|
|
35
|
+
capabilities: {},
|
|
36
|
+
clientInfo: {
|
|
37
|
+
name: 'agent-kanban-test-client',
|
|
38
|
+
version: '1.0.0',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function startTrackerServer(
|
|
45
|
+
policyOverrides: Partial<Parameters<typeof createTrackerCore<TestScope>>[0]['policy']> = {},
|
|
46
|
+
) {
|
|
47
|
+
const core = createTrackerCore<TestScope>({
|
|
48
|
+
provider,
|
|
49
|
+
policy: {
|
|
50
|
+
canReadTicket() {},
|
|
51
|
+
canPostComment() {},
|
|
52
|
+
canUpdateComment() {},
|
|
53
|
+
canMoveTicket() {},
|
|
54
|
+
...policyOverrides,
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const tracker = createTrackerMcpServer({
|
|
59
|
+
core,
|
|
60
|
+
auth: async ({ headers }) => {
|
|
61
|
+
const authHeader = headers.get('authorization')
|
|
62
|
+
if (authHeader !== 'Bearer good-token') {
|
|
63
|
+
throw new TrackerMcpError({
|
|
64
|
+
code: 'auth_failed',
|
|
65
|
+
publicMessage: 'unauthenticated',
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
return { actor: 'tester' }
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const httpServer = Bun.serve({
|
|
73
|
+
port: 0,
|
|
74
|
+
fetch(request) {
|
|
75
|
+
return tracker.fetch(request)
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const url = new URL(`http://127.0.0.1:${httpServer.port}/mcp`)
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
core,
|
|
83
|
+
tracker,
|
|
84
|
+
httpServer,
|
|
85
|
+
url,
|
|
86
|
+
async close() {
|
|
87
|
+
await tracker.close()
|
|
88
|
+
httpServer.stop(true)
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('createTrackerMcpServer', () => {
|
|
94
|
+
test('serves tools over Streamable HTTP and round-trips a tool call through auth, policy, and provider', async () => {
|
|
95
|
+
const task = addTask(db, 'MCP task')
|
|
96
|
+
const runtime = startTrackerServer()
|
|
97
|
+
const transport = new StreamableHTTPClientTransport(runtime.url, {
|
|
98
|
+
requestInit: { headers: { Authorization: 'Bearer good-token' } },
|
|
99
|
+
})
|
|
100
|
+
const client = new Client({ name: 'test-client', version: '1.0.0' })
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await client.connect(transport)
|
|
104
|
+
const tools = await client.listTools()
|
|
105
|
+
expect(tools.tools.map((tool) => tool.name)).toEqual([
|
|
106
|
+
'getTicket',
|
|
107
|
+
'listComments',
|
|
108
|
+
'getBoard',
|
|
109
|
+
'postComment',
|
|
110
|
+
'updateComment',
|
|
111
|
+
'moveTicket',
|
|
112
|
+
])
|
|
113
|
+
|
|
114
|
+
const result = await client.callTool({
|
|
115
|
+
name: 'getTicket',
|
|
116
|
+
arguments: { ticketId: task.id },
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
expect(
|
|
120
|
+
(result.structuredContent as { result: { id: string; title: string } }).result,
|
|
121
|
+
).toEqual(
|
|
122
|
+
expect.objectContaining({
|
|
123
|
+
id: task.id,
|
|
124
|
+
title: 'MCP task',
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
} finally {
|
|
128
|
+
await client.close()
|
|
129
|
+
await runtime.close()
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('round-trips updateComment end-to-end, handing the existing comment to the policy', async () => {
|
|
134
|
+
const task = addTask(db, 'MCP task')
|
|
135
|
+
const created = await provider.comment(task.id, 'original body')
|
|
136
|
+
const seenExisting: { id?: string; body?: string } = {}
|
|
137
|
+
const runtime = startTrackerServer({
|
|
138
|
+
canUpdateComment(_scope, _ticketId, comment) {
|
|
139
|
+
seenExisting.id = comment.id
|
|
140
|
+
seenExisting.body = comment.body
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
const transport = new StreamableHTTPClientTransport(runtime.url, {
|
|
144
|
+
requestInit: { headers: { Authorization: 'Bearer good-token' } },
|
|
145
|
+
})
|
|
146
|
+
const client = new Client({ name: 'test-client', version: '1.0.0' })
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
await client.connect(transport)
|
|
150
|
+
const result = await client.callTool({
|
|
151
|
+
name: 'updateComment',
|
|
152
|
+
arguments: { ticketId: task.id, commentId: created.id, body: 'rewritten' },
|
|
153
|
+
})
|
|
154
|
+
expect((result.structuredContent as { result: { id: string; body: string } }).result).toEqual(
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
id: created.id,
|
|
157
|
+
body: 'rewritten',
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
expect(seenExisting).toEqual({ id: created.id, body: 'original body' })
|
|
161
|
+
} finally {
|
|
162
|
+
await client.close()
|
|
163
|
+
await runtime.close()
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('returns HTTP 401 before session creation when auth fails', async () => {
|
|
168
|
+
const runtime = startTrackerServer()
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const response = await fetch(runtime.url, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: { 'Content-Type': 'application/json' },
|
|
174
|
+
body: JSON.stringify(initializeBody()),
|
|
175
|
+
})
|
|
176
|
+
const body = (await response.json()) as {
|
|
177
|
+
error: { code: number; message: string }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
expect(response.status).toBe(401)
|
|
181
|
+
expect(body.error).toEqual({
|
|
182
|
+
code: -32001,
|
|
183
|
+
message: 'unauthenticated',
|
|
184
|
+
})
|
|
185
|
+
} finally {
|
|
186
|
+
await runtime.close()
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('surfaces policy denial as a JSON-RPC error during tool calls', async () => {
|
|
191
|
+
const task = addTask(db, 'MCP task')
|
|
192
|
+
const runtime = startTrackerServer({
|
|
193
|
+
canMoveTicket() {
|
|
194
|
+
throw new TrackerMcpError({
|
|
195
|
+
code: 'policy_denied',
|
|
196
|
+
publicMessage: 'forbidden_column',
|
|
197
|
+
})
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
const transport = new StreamableHTTPClientTransport(runtime.url, {
|
|
201
|
+
requestInit: { headers: { Authorization: 'Bearer good-token' } },
|
|
202
|
+
})
|
|
203
|
+
const client = new Client({ name: 'test-client', version: '1.0.0' })
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await client.connect(transport)
|
|
207
|
+
try {
|
|
208
|
+
await client.callTool({
|
|
209
|
+
name: 'moveTicket',
|
|
210
|
+
arguments: { ticketId: task.id, column: 'done' },
|
|
211
|
+
})
|
|
212
|
+
throw new Error('Expected moveTicket to fail')
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const mcpError = error as { code?: number; message?: string }
|
|
215
|
+
expect(mcpError.code).toBe(-32002)
|
|
216
|
+
expect(mcpError.message).toContain('forbidden_column')
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
await client.close()
|
|
220
|
+
await runtime.close()
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('rejects new requests after close()', async () => {
|
|
225
|
+
const runtime = startTrackerServer()
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
await runtime.tracker.selfPing()
|
|
229
|
+
await runtime.tracker.close()
|
|
230
|
+
|
|
231
|
+
const response = await fetch(runtime.url, {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: {
|
|
234
|
+
Authorization: 'Bearer good-token',
|
|
235
|
+
'Content-Type': 'application/json',
|
|
236
|
+
},
|
|
237
|
+
body: JSON.stringify(initializeBody()),
|
|
238
|
+
})
|
|
239
|
+
const body = (await response.json()) as {
|
|
240
|
+
error: { code: number; message: string }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
expect(response.status).toBe(503)
|
|
244
|
+
expect(body.error).toEqual({
|
|
245
|
+
code: -32000,
|
|
246
|
+
message: 'Tracker MCP server is closed',
|
|
247
|
+
})
|
|
248
|
+
} finally {
|
|
249
|
+
runtime.httpServer.stop(true)
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test, beforeEach } from 'bun:test'
|
|
2
2
|
import { Database } from 'bun:sqlite'
|
|
3
|
-
import { initSchema, seedDefaultColumns, addTask } from '../db
|
|
4
|
-
import { getBoardMetrics } from '../metrics
|
|
3
|
+
import { initSchema, seedDefaultColumns, addTask } from '../db'
|
|
4
|
+
import { getBoardMetrics } from '../metrics'
|
|
5
5
|
|
|
6
6
|
let db: Database
|
|
7
7
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
JIRA_CAPABILITIES,
|
|
5
|
+
LINEAR_CAPABILITIES,
|
|
6
|
+
LOCAL_CAPABILITIES,
|
|
7
|
+
} from '../providers/capabilities'
|
|
8
|
+
|
|
9
|
+
describe('provider capabilities', () => {
|
|
10
|
+
test('remote providers share the same read/write baseline', () => {
|
|
11
|
+
expect(LINEAR_CAPABILITIES).toEqual(JIRA_CAPABILITIES)
|
|
12
|
+
expect(LINEAR_CAPABILITIES).toEqual({
|
|
13
|
+
taskCreate: true,
|
|
14
|
+
taskUpdate: true,
|
|
15
|
+
taskMove: true,
|
|
16
|
+
taskDelete: false,
|
|
17
|
+
comment: true,
|
|
18
|
+
activity: false,
|
|
19
|
+
metrics: false,
|
|
20
|
+
columnCrud: false,
|
|
21
|
+
bulk: false,
|
|
22
|
+
configEdit: false,
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('local provider exposes the full local board surface', () => {
|
|
27
|
+
expect(LOCAL_CAPABILITIES).toEqual({
|
|
28
|
+
taskCreate: true,
|
|
29
|
+
taskUpdate: true,
|
|
30
|
+
taskMove: true,
|
|
31
|
+
taskDelete: true,
|
|
32
|
+
comment: true,
|
|
33
|
+
activity: true,
|
|
34
|
+
metrics: true,
|
|
35
|
+
columnCrud: true,
|
|
36
|
+
bulk: true,
|
|
37
|
+
configEdit: true,
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { BoardBootstrap, BoardConfig, BoardMetrics, BoardView, Column, Task } from '../types'
|
|
3
|
+
import { startServer, type StartedServer } from '../server'
|
|
4
|
+
import type {
|
|
5
|
+
CreateTaskInput,
|
|
6
|
+
KanbanProvider,
|
|
7
|
+
ProviderContext,
|
|
8
|
+
ProviderSyncStatus,
|
|
9
|
+
TaskListFilters,
|
|
10
|
+
UpdateTaskInput,
|
|
11
|
+
} from '../providers/types'
|
|
12
|
+
|
|
13
|
+
const emptyBoard: BoardView = { columns: [] }
|
|
14
|
+
const emptyConfig: BoardConfig = { members: [], projects: [], provider: 'local' }
|
|
15
|
+
const noopTask = (): Task => ({
|
|
16
|
+
id: 't1',
|
|
17
|
+
providerId: 't1',
|
|
18
|
+
externalRef: 't1',
|
|
19
|
+
url: null,
|
|
20
|
+
title: 'Task',
|
|
21
|
+
description: '',
|
|
22
|
+
column_id: 'backlog',
|
|
23
|
+
position: 0,
|
|
24
|
+
priority: 'medium',
|
|
25
|
+
assignee: '',
|
|
26
|
+
assignees: [],
|
|
27
|
+
labels: [],
|
|
28
|
+
comment_count: 0,
|
|
29
|
+
project: '',
|
|
30
|
+
metadata: '{}',
|
|
31
|
+
created_at: '2026-04-22T00:00:00.000Z',
|
|
32
|
+
updated_at: '2026-04-22T00:00:00.000Z',
|
|
33
|
+
version: '1',
|
|
34
|
+
source_updated_at: null,
|
|
35
|
+
})
|
|
36
|
+
const noopMetrics = (): BoardMetrics => ({
|
|
37
|
+
tasksByColumn: [],
|
|
38
|
+
tasksByPriority: [],
|
|
39
|
+
totalTasks: 0,
|
|
40
|
+
completedTasks: 0,
|
|
41
|
+
avgCompletionHours: null,
|
|
42
|
+
recentActivity: [],
|
|
43
|
+
tasksCreatedThisWeek: 0,
|
|
44
|
+
inProgressCount: 0,
|
|
45
|
+
completionPercent: 0,
|
|
46
|
+
assignees: [],
|
|
47
|
+
projects: [],
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
function makeProvider(overrides: Partial<KanbanProvider> = {}): KanbanProvider {
|
|
51
|
+
const provider: KanbanProvider = {
|
|
52
|
+
type: 'local',
|
|
53
|
+
async getContext(): Promise<ProviderContext> {
|
|
54
|
+
return {
|
|
55
|
+
provider: provider.type,
|
|
56
|
+
capabilities: {
|
|
57
|
+
taskCreate: true,
|
|
58
|
+
taskUpdate: true,
|
|
59
|
+
taskMove: true,
|
|
60
|
+
taskDelete: true,
|
|
61
|
+
comment: true,
|
|
62
|
+
activity: true,
|
|
63
|
+
metrics: true,
|
|
64
|
+
columnCrud: true,
|
|
65
|
+
bulk: true,
|
|
66
|
+
configEdit: true,
|
|
67
|
+
},
|
|
68
|
+
team: null,
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
async getBootstrap(): Promise<BoardBootstrap> {
|
|
72
|
+
return {
|
|
73
|
+
provider: provider.type,
|
|
74
|
+
capabilities: (await provider.getContext()).capabilities,
|
|
75
|
+
board: emptyBoard,
|
|
76
|
+
config: emptyConfig,
|
|
77
|
+
metrics: null,
|
|
78
|
+
activity: [],
|
|
79
|
+
team: null,
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
async getBoard(): Promise<BoardView> {
|
|
83
|
+
return emptyBoard
|
|
84
|
+
},
|
|
85
|
+
async listColumns(): Promise<Column[]> {
|
|
86
|
+
return []
|
|
87
|
+
},
|
|
88
|
+
async listTasks(_filters?: TaskListFilters): Promise<Task[]> {
|
|
89
|
+
return []
|
|
90
|
+
},
|
|
91
|
+
async getTask(_idOrRef: string): Promise<Task> {
|
|
92
|
+
return noopTask()
|
|
93
|
+
},
|
|
94
|
+
async createTask(_input: CreateTaskInput): Promise<Task> {
|
|
95
|
+
return noopTask()
|
|
96
|
+
},
|
|
97
|
+
async updateTask(_idOrRef: string, _input: UpdateTaskInput): Promise<Task> {
|
|
98
|
+
return noopTask()
|
|
99
|
+
},
|
|
100
|
+
async moveTask(_idOrRef: string, _column: string): Promise<Task> {
|
|
101
|
+
return noopTask()
|
|
102
|
+
},
|
|
103
|
+
async deleteTask(_idOrRef: string): Promise<Task> {
|
|
104
|
+
return noopTask()
|
|
105
|
+
},
|
|
106
|
+
async listComments(): Promise<[]> {
|
|
107
|
+
return []
|
|
108
|
+
},
|
|
109
|
+
async getComment() {
|
|
110
|
+
return {
|
|
111
|
+
id: 'c1',
|
|
112
|
+
task_id: 't1',
|
|
113
|
+
body: '',
|
|
114
|
+
author: null,
|
|
115
|
+
created_at: '2026-04-22T00:00:00.000Z',
|
|
116
|
+
updated_at: '2026-04-22T00:00:00.000Z',
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
async comment() {
|
|
120
|
+
return {
|
|
121
|
+
id: 'c1',
|
|
122
|
+
task_id: 't1',
|
|
123
|
+
body: '',
|
|
124
|
+
author: null,
|
|
125
|
+
created_at: '2026-04-22T00:00:00.000Z',
|
|
126
|
+
updated_at: '2026-04-22T00:00:00.000Z',
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
async updateComment() {
|
|
130
|
+
return {
|
|
131
|
+
id: 'c1',
|
|
132
|
+
task_id: 't1',
|
|
133
|
+
body: '',
|
|
134
|
+
author: null,
|
|
135
|
+
created_at: '2026-04-22T00:00:00.000Z',
|
|
136
|
+
updated_at: '2026-04-22T00:00:00.000Z',
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
async getActivity() {
|
|
140
|
+
return []
|
|
141
|
+
},
|
|
142
|
+
async getMetrics(): Promise<BoardMetrics> {
|
|
143
|
+
return noopMetrics()
|
|
144
|
+
},
|
|
145
|
+
async getConfig(): Promise<BoardConfig> {
|
|
146
|
+
return emptyConfig
|
|
147
|
+
},
|
|
148
|
+
async patchConfig(): Promise<BoardConfig> {
|
|
149
|
+
return emptyConfig
|
|
150
|
+
},
|
|
151
|
+
...overrides,
|
|
152
|
+
}
|
|
153
|
+
return provider
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sleep(ms: number): Promise<void> {
|
|
157
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const runtimes: StartedServer[] = []
|
|
161
|
+
|
|
162
|
+
afterEach(() => {
|
|
163
|
+
while (runtimes.length > 0) {
|
|
164
|
+
runtimes.pop()?.stop(true)
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('startServer', () => {
|
|
169
|
+
test('health is cheap and does not call getContext', async () => {
|
|
170
|
+
let getContextCalls = 0
|
|
171
|
+
const runtime = startServer(
|
|
172
|
+
makeProvider({
|
|
173
|
+
async getContext() {
|
|
174
|
+
getContextCalls += 1
|
|
175
|
+
return {
|
|
176
|
+
provider: 'local',
|
|
177
|
+
capabilities: {
|
|
178
|
+
taskCreate: true,
|
|
179
|
+
taskUpdate: true,
|
|
180
|
+
taskMove: true,
|
|
181
|
+
taskDelete: true,
|
|
182
|
+
comment: true,
|
|
183
|
+
activity: true,
|
|
184
|
+
metrics: true,
|
|
185
|
+
columnCrud: true,
|
|
186
|
+
bulk: true,
|
|
187
|
+
configEdit: true,
|
|
188
|
+
},
|
|
189
|
+
team: null,
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
0,
|
|
194
|
+
)
|
|
195
|
+
runtimes.push(runtime)
|
|
196
|
+
|
|
197
|
+
const response = await fetch(`http://127.0.0.1:${runtime.port}/api/health`)
|
|
198
|
+
const body = (await response.json()) as {
|
|
199
|
+
ok: boolean
|
|
200
|
+
data: { provider: string; status: string; wsClients: number }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
expect(response.status).toBe(200)
|
|
204
|
+
expect(body.ok).toBe(true)
|
|
205
|
+
expect(body.data.provider).toBe('local')
|
|
206
|
+
expect(getContextCalls).toBe(0)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('ready stays false until the first background sync succeeds', async () => {
|
|
210
|
+
let resolveSync!: () => void
|
|
211
|
+
const runtime = startServer(
|
|
212
|
+
makeProvider({
|
|
213
|
+
type: 'linear',
|
|
214
|
+
async syncCache() {
|
|
215
|
+
await new Promise<void>((resolve) => {
|
|
216
|
+
resolveSync = resolve
|
|
217
|
+
})
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
0,
|
|
221
|
+
{ syncIntervalMs: 20 },
|
|
222
|
+
)
|
|
223
|
+
runtimes.push(runtime)
|
|
224
|
+
|
|
225
|
+
await sleep(5)
|
|
226
|
+
let response = await fetch(`http://127.0.0.1:${runtime.port}/api/ready`)
|
|
227
|
+
expect(response.status).toBe(503)
|
|
228
|
+
|
|
229
|
+
resolveSync()
|
|
230
|
+
await sleep(5)
|
|
231
|
+
response = await fetch(`http://127.0.0.1:${runtime.port}/api/ready`)
|
|
232
|
+
expect(response.status).toBe(200)
|
|
233
|
+
const body = (await response.json()) as {
|
|
234
|
+
ok: boolean
|
|
235
|
+
data: { ready: boolean; backgroundSync: { warm: boolean } }
|
|
236
|
+
}
|
|
237
|
+
expect(body.ok).toBe(true)
|
|
238
|
+
expect(body.data.ready).toBe(true)
|
|
239
|
+
expect(body.data.backgroundSync.warm).toBe(true)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('sync-status reports provider sync metadata and background scheduler state', async () => {
|
|
243
|
+
let syncCalls = 0
|
|
244
|
+
let providerSync: ProviderSyncStatus = {
|
|
245
|
+
lastSyncAt: null,
|
|
246
|
+
lastFullSyncAt: null,
|
|
247
|
+
lastWebhookAt: null,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const runtime = startServer(
|
|
251
|
+
makeProvider({
|
|
252
|
+
type: 'jira',
|
|
253
|
+
async syncCache() {
|
|
254
|
+
syncCalls += 1
|
|
255
|
+
const now = new Date().toISOString()
|
|
256
|
+
providerSync = {
|
|
257
|
+
lastSyncAt: now,
|
|
258
|
+
lastFullSyncAt: syncCalls === 1 ? now : providerSync.lastFullSyncAt,
|
|
259
|
+
lastWebhookAt: providerSync.lastWebhookAt,
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
async getSyncStatus() {
|
|
263
|
+
return providerSync
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
0,
|
|
267
|
+
{ syncIntervalMs: 20 },
|
|
268
|
+
)
|
|
269
|
+
runtimes.push(runtime)
|
|
270
|
+
|
|
271
|
+
await sleep(55)
|
|
272
|
+
const response = await fetch(`http://127.0.0.1:${runtime.port}/api/sync-status`)
|
|
273
|
+
const body = (await response.json()) as {
|
|
274
|
+
ok: boolean
|
|
275
|
+
data: {
|
|
276
|
+
provider: string
|
|
277
|
+
backgroundSync: { enabled: boolean; warm: boolean; lastSuccessAt: string | null }
|
|
278
|
+
providerSync: ProviderSyncStatus
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
expect(response.status).toBe(200)
|
|
283
|
+
expect(body.ok).toBe(true)
|
|
284
|
+
expect(body.data.provider).toBe('jira')
|
|
285
|
+
expect(body.data.backgroundSync.enabled).toBe(true)
|
|
286
|
+
expect(body.data.backgroundSync.warm).toBe(true)
|
|
287
|
+
expect(body.data.backgroundSync.lastSuccessAt).not.toBeNull()
|
|
288
|
+
expect(body.data.providerSync.lastSyncAt).not.toBeNull()
|
|
289
|
+
expect(syncCalls).toBeGreaterThanOrEqual(2)
|
|
290
|
+
})
|
|
291
|
+
})
|