@andypai/agent-kanban 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -22
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +15 -9
- package/src/__tests__/api.test.ts +96 -0
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +6 -13
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +168 -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 +493 -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__/server.test.ts +298 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +1 -11
- package/src/api.ts +154 -19
- package/src/commands/board.ts +1 -11
- package/src/commands/mcp.ts +87 -0
- package/src/db.ts +115 -3
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +72 -18
- 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/providers/capabilities.ts +15 -0
- package/src/providers/index.ts +31 -1
- 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 +778 -0
- package/src/providers/linear-cache.ts +249 -70
- package/src/providers/linear-client.ts +256 -13
- package/src/providers/linear.ts +337 -14
- package/src/providers/local.ts +68 -17
- package/src/providers/types.ts +18 -2
- package/src/server.ts +139 -11
- package/src/tunnel.ts +79 -0
- package/src/types.ts +18 -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,604 @@
|
|
|
1
|
+
import { beforeEach, afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { createHmac } from 'node:crypto'
|
|
4
|
+
import { verifyHmacSha256 } from '../webhooks.ts'
|
|
5
|
+
import { JiraProvider, type JiraProviderConfig } from '../providers/jira.ts'
|
|
6
|
+
import { JiraClient } from '../providers/jira-client.ts'
|
|
7
|
+
import { LinearProvider } from '../providers/linear.ts'
|
|
8
|
+
import {
|
|
9
|
+
getCachedActivity,
|
|
10
|
+
getCachedTasks as getCachedJiraTasks,
|
|
11
|
+
initJiraCacheSchema,
|
|
12
|
+
saveJiraSyncMeta,
|
|
13
|
+
saveTeamInfo,
|
|
14
|
+
replaceJiraColumns,
|
|
15
|
+
upsertJiraIssues,
|
|
16
|
+
} from '../providers/jira-cache.ts'
|
|
17
|
+
import {
|
|
18
|
+
getCachedTasks as getCachedLinearTasks,
|
|
19
|
+
initLinearCacheSchema,
|
|
20
|
+
loadSyncMeta,
|
|
21
|
+
replaceStates,
|
|
22
|
+
saveSyncMeta,
|
|
23
|
+
upsertIssues,
|
|
24
|
+
} from '../providers/linear-cache.ts'
|
|
25
|
+
|
|
26
|
+
function hmac(secret: string, body: string): string {
|
|
27
|
+
return createHmac('sha256', secret).update(body).digest('hex')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
31
|
+
return new Response(JSON.stringify(body), {
|
|
32
|
+
status,
|
|
33
|
+
headers: { 'content-type': 'application/json' },
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('verifyHmacSha256', () => {
|
|
38
|
+
test('accepts matching signature', () => {
|
|
39
|
+
const body = '{"hello":"world"}'
|
|
40
|
+
const sig = hmac('s3cr3t', body)
|
|
41
|
+
expect(verifyHmacSha256('s3cr3t', body, sig)).toBe(true)
|
|
42
|
+
expect(verifyHmacSha256('s3cr3t', body, `sha256=${sig}`)).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('rejects tampered body', () => {
|
|
46
|
+
const sig = hmac('s3cr3t', 'a')
|
|
47
|
+
expect(verifyHmacSha256('s3cr3t', 'b', sig)).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('rejects wrong secret', () => {
|
|
51
|
+
const sig = hmac('s3cr3t', 'a')
|
|
52
|
+
expect(verifyHmacSha256('other', 'a', sig)).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('rejects missing signature', () => {
|
|
56
|
+
expect(verifyHmacSha256('s3cr3t', 'a', undefined)).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const jiraConfig: JiraProviderConfig = {
|
|
61
|
+
baseUrl: 'https://example.atlassian.net',
|
|
62
|
+
email: 'u@example.com',
|
|
63
|
+
apiToken: 'tok',
|
|
64
|
+
projectKey: 'ENG',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function seedJira(db: Database): void {
|
|
68
|
+
initJiraCacheSchema(db)
|
|
69
|
+
saveJiraSyncMeta(db, {
|
|
70
|
+
projectKey: 'ENG',
|
|
71
|
+
boardId: null,
|
|
72
|
+
lastSyncAt: '2025-01-01T00:00:00.000Z',
|
|
73
|
+
lastIssueUpdatedAt: '2025-01-01T00:00:00.000Z',
|
|
74
|
+
})
|
|
75
|
+
saveTeamInfo(db, { id: '1', key: 'ENG', name: 'Engineering' })
|
|
76
|
+
replaceJiraColumns(db, [
|
|
77
|
+
{ id: 'status:1', name: 'To Do', position: 0, statusIds: ['1'], source: 'status' },
|
|
78
|
+
{ id: 'status:2', name: 'Done', position: 1, statusIds: ['2'], source: 'status' },
|
|
79
|
+
])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('Jira webhook', () => {
|
|
83
|
+
let originalSecret: string | undefined
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
originalSecret = process.env['JIRA_WEBHOOK_SECRET']
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
if (originalSecret === undefined) delete process.env['JIRA_WEBHOOK_SECRET']
|
|
91
|
+
else process.env['JIRA_WEBHOOK_SECRET'] = originalSecret
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('issue_created upserts the task', async () => {
|
|
95
|
+
const db = new Database(':memory:')
|
|
96
|
+
seedJira(db)
|
|
97
|
+
delete process.env['JIRA_WEBHOOK_SECRET']
|
|
98
|
+
const client = new JiraClient({
|
|
99
|
+
baseUrl: jiraConfig.baseUrl,
|
|
100
|
+
email: jiraConfig.email,
|
|
101
|
+
apiToken: jiraConfig.apiToken,
|
|
102
|
+
})
|
|
103
|
+
const provider = new JiraProvider(db, jiraConfig, client)
|
|
104
|
+
const body = JSON.stringify({
|
|
105
|
+
webhookEvent: 'jira:issue_created',
|
|
106
|
+
issue: {
|
|
107
|
+
id: '100',
|
|
108
|
+
key: 'ENG-100',
|
|
109
|
+
fields: {
|
|
110
|
+
summary: 'New issue',
|
|
111
|
+
status: { id: '1', name: 'To Do' },
|
|
112
|
+
issuetype: { id: '10000', name: 'Task' },
|
|
113
|
+
assignee: null,
|
|
114
|
+
labels: ['alpha'],
|
|
115
|
+
comment: { total: 0 },
|
|
116
|
+
created: '2025-02-01T00:00:00.000Z',
|
|
117
|
+
updated: '2025-02-01T00:00:00.000Z',
|
|
118
|
+
project: { id: '1', key: 'ENG' },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
const result = await provider.handleWebhook({ headers: {}, rawBody: body })
|
|
123
|
+
expect(result.handled).toBe(true)
|
|
124
|
+
const tasks = getCachedJiraTasks(db)
|
|
125
|
+
expect(tasks.find((t) => t.externalRef === 'ENG-100')?.title).toBe('New issue')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('issue_deleted removes the task', async () => {
|
|
129
|
+
const db = new Database(':memory:')
|
|
130
|
+
seedJira(db)
|
|
131
|
+
upsertJiraIssues(db, [
|
|
132
|
+
{
|
|
133
|
+
id: '200',
|
|
134
|
+
key: 'ENG-200',
|
|
135
|
+
summary: 'Doomed',
|
|
136
|
+
descriptionText: '',
|
|
137
|
+
statusId: '1',
|
|
138
|
+
projectKey: 'ENG',
|
|
139
|
+
createdAt: '2025-02-01',
|
|
140
|
+
updatedAt: '2025-02-01',
|
|
141
|
+
},
|
|
142
|
+
])
|
|
143
|
+
delete process.env['JIRA_WEBHOOK_SECRET']
|
|
144
|
+
const client = new JiraClient({
|
|
145
|
+
baseUrl: jiraConfig.baseUrl,
|
|
146
|
+
email: jiraConfig.email,
|
|
147
|
+
apiToken: jiraConfig.apiToken,
|
|
148
|
+
})
|
|
149
|
+
const provider = new JiraProvider(db, jiraConfig, client)
|
|
150
|
+
const body = JSON.stringify({
|
|
151
|
+
webhookEvent: 'jira:issue_deleted',
|
|
152
|
+
issue: {
|
|
153
|
+
id: '200',
|
|
154
|
+
key: 'ENG-200',
|
|
155
|
+
fields: {
|
|
156
|
+
summary: '',
|
|
157
|
+
status: { id: '1', name: 'To Do' },
|
|
158
|
+
issuetype: { id: '', name: '' },
|
|
159
|
+
created: '',
|
|
160
|
+
updated: '',
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
const result = await provider.handleWebhook({ headers: {}, rawBody: body })
|
|
165
|
+
expect(result.handled).toBe(true)
|
|
166
|
+
expect(getCachedJiraTasks(db).find((t) => t.externalRef === 'ENG-200')).toBeUndefined()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('rejects bad signature when secret is configured', async () => {
|
|
170
|
+
const db = new Database(':memory:')
|
|
171
|
+
seedJira(db)
|
|
172
|
+
process.env['JIRA_WEBHOOK_SECRET'] = 'topsecret'
|
|
173
|
+
const client = new JiraClient({
|
|
174
|
+
baseUrl: jiraConfig.baseUrl,
|
|
175
|
+
email: jiraConfig.email,
|
|
176
|
+
apiToken: jiraConfig.apiToken,
|
|
177
|
+
})
|
|
178
|
+
const provider = new JiraProvider(db, jiraConfig, client)
|
|
179
|
+
const body = JSON.stringify({
|
|
180
|
+
webhookEvent: 'jira:issue_created',
|
|
181
|
+
issue: { id: '300', key: 'ENG-300', fields: {} },
|
|
182
|
+
})
|
|
183
|
+
const result = await provider.handleWebhook({
|
|
184
|
+
headers: { 'x-hub-signature-256': 'sha256=deadbeef' },
|
|
185
|
+
rawBody: body,
|
|
186
|
+
})
|
|
187
|
+
expect(result.unauthorized).toBe(true)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('accepts valid signature when secret is configured', async () => {
|
|
191
|
+
const db = new Database(':memory:')
|
|
192
|
+
seedJira(db)
|
|
193
|
+
process.env['JIRA_WEBHOOK_SECRET'] = 'topsecret'
|
|
194
|
+
const client = new JiraClient({
|
|
195
|
+
baseUrl: jiraConfig.baseUrl,
|
|
196
|
+
email: jiraConfig.email,
|
|
197
|
+
apiToken: jiraConfig.apiToken,
|
|
198
|
+
})
|
|
199
|
+
const provider = new JiraProvider(db, jiraConfig, client)
|
|
200
|
+
const body = JSON.stringify({
|
|
201
|
+
webhookEvent: 'jira:issue_created',
|
|
202
|
+
issue: {
|
|
203
|
+
id: '400',
|
|
204
|
+
key: 'ENG-400',
|
|
205
|
+
fields: {
|
|
206
|
+
summary: 'Signed',
|
|
207
|
+
status: { id: '1', name: 'To Do' },
|
|
208
|
+
issuetype: { id: 't', name: 'Task' },
|
|
209
|
+
project: { id: '1', key: 'ENG' },
|
|
210
|
+
created: '2025-02-01',
|
|
211
|
+
updated: '2025-02-01',
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
const sig = hmac('topsecret', body)
|
|
216
|
+
const result = await provider.handleWebhook({
|
|
217
|
+
headers: { 'x-hub-signature-256': sig },
|
|
218
|
+
rawBody: body,
|
|
219
|
+
})
|
|
220
|
+
expect(result.handled).toBe(true)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('ignores issue updates from other projects', async () => {
|
|
224
|
+
const db = new Database(':memory:')
|
|
225
|
+
seedJira(db)
|
|
226
|
+
delete process.env['JIRA_WEBHOOK_SECRET']
|
|
227
|
+
const client = new JiraClient({
|
|
228
|
+
baseUrl: jiraConfig.baseUrl,
|
|
229
|
+
email: jiraConfig.email,
|
|
230
|
+
apiToken: jiraConfig.apiToken,
|
|
231
|
+
})
|
|
232
|
+
const provider = new JiraProvider(db, jiraConfig, client)
|
|
233
|
+
const body = JSON.stringify({
|
|
234
|
+
webhookEvent: 'jira:issue_updated',
|
|
235
|
+
issue: {
|
|
236
|
+
id: '500',
|
|
237
|
+
key: 'OPS-500',
|
|
238
|
+
fields: {
|
|
239
|
+
summary: 'Wrong project',
|
|
240
|
+
status: { id: '1', name: 'To Do' },
|
|
241
|
+
issuetype: { id: 't', name: 'Task' },
|
|
242
|
+
labels: [],
|
|
243
|
+
comment: { total: 0 },
|
|
244
|
+
created: '2025-02-01',
|
|
245
|
+
updated: '2025-02-01',
|
|
246
|
+
project: { id: '2', key: 'OPS' },
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
const result = await provider.handleWebhook({ headers: {}, rawBody: body })
|
|
252
|
+
|
|
253
|
+
expect(result.handled).toBe(false)
|
|
254
|
+
expect(result.message).toContain('Ignoring issue from project')
|
|
255
|
+
expect(getCachedJiraTasks(db).find((task) => task.externalRef === 'OPS-500')).toBeUndefined()
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('issue_updated backfills activity immediately', async () => {
|
|
259
|
+
const db = new Database(':memory:')
|
|
260
|
+
seedJira(db)
|
|
261
|
+
delete process.env['JIRA_WEBHOOK_SECRET']
|
|
262
|
+
const originalFetch = globalThis.fetch
|
|
263
|
+
globalThis.fetch = (async (input) => {
|
|
264
|
+
const url =
|
|
265
|
+
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
|
266
|
+
if (url.includes('/rest/api/3/issue/600/changelog')) {
|
|
267
|
+
return jsonResponse({
|
|
268
|
+
startAt: 0,
|
|
269
|
+
maxResults: 100,
|
|
270
|
+
total: 1,
|
|
271
|
+
isLast: true,
|
|
272
|
+
values: [
|
|
273
|
+
{
|
|
274
|
+
id: 'hist-1',
|
|
275
|
+
created: '2025-02-01T00:01:00.000Z',
|
|
276
|
+
items: [{ field: 'status', from: '1', to: '2' }],
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
return new Response(`route not stubbed: ${url}`, { status: 500 })
|
|
282
|
+
}) as typeof fetch
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const client = new JiraClient({
|
|
286
|
+
baseUrl: jiraConfig.baseUrl,
|
|
287
|
+
email: jiraConfig.email,
|
|
288
|
+
apiToken: jiraConfig.apiToken,
|
|
289
|
+
})
|
|
290
|
+
const provider = new JiraProvider(db, jiraConfig, client)
|
|
291
|
+
const body = JSON.stringify({
|
|
292
|
+
webhookEvent: 'jira:issue_updated',
|
|
293
|
+
issue: {
|
|
294
|
+
id: '600',
|
|
295
|
+
key: 'ENG-600',
|
|
296
|
+
fields: {
|
|
297
|
+
summary: 'Moved issue',
|
|
298
|
+
status: { id: '2', name: 'Done' },
|
|
299
|
+
issuetype: { id: 't', name: 'Task' },
|
|
300
|
+
labels: [],
|
|
301
|
+
comment: { total: 0 },
|
|
302
|
+
created: '2025-02-01T00:00:00.000Z',
|
|
303
|
+
updated: '2025-02-01T00:01:00.000Z',
|
|
304
|
+
project: { id: '1', key: 'ENG' },
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const result = await provider.handleWebhook({ headers: {}, rawBody: body })
|
|
310
|
+
|
|
311
|
+
expect(result.handled).toBe(true)
|
|
312
|
+
expect(getCachedJiraTasks(db).find((task) => task.externalRef === 'ENG-600')?.column_id).toBe(
|
|
313
|
+
'2',
|
|
314
|
+
)
|
|
315
|
+
expect(getCachedActivity(db, { issueId: '600' })).toEqual([
|
|
316
|
+
{
|
|
317
|
+
issue_id: '600',
|
|
318
|
+
history_id: 'hist-1',
|
|
319
|
+
item_field: 'status',
|
|
320
|
+
from_value: '1',
|
|
321
|
+
to_value: '2',
|
|
322
|
+
created_at: '2025-02-01T00:01:00.000Z',
|
|
323
|
+
},
|
|
324
|
+
])
|
|
325
|
+
} finally {
|
|
326
|
+
globalThis.fetch = originalFetch
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
function seedLinear(db: Database): void {
|
|
332
|
+
initLinearCacheSchema(db)
|
|
333
|
+
saveSyncMeta(db, {
|
|
334
|
+
team: { id: 'tid', key: 'DX', name: 'DX' },
|
|
335
|
+
lastSyncAt: '2025-01-01T00:00:00.000Z',
|
|
336
|
+
lastIssueUpdatedAt: '2025-01-01T00:00:00.000Z',
|
|
337
|
+
})
|
|
338
|
+
replaceStates(db, [
|
|
339
|
+
{ id: 's1', name: 'Todo', position: 0 },
|
|
340
|
+
{ id: 's2', name: 'Done', position: 1 },
|
|
341
|
+
])
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
describe('Linear webhook', () => {
|
|
345
|
+
let originalSecret: string | undefined
|
|
346
|
+
let originalFetch: typeof fetch
|
|
347
|
+
|
|
348
|
+
beforeEach(() => {
|
|
349
|
+
originalSecret = process.env['LINEAR_WEBHOOK_SECRET']
|
|
350
|
+
originalFetch = globalThis.fetch
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
afterEach(() => {
|
|
354
|
+
if (originalSecret === undefined) delete process.env['LINEAR_WEBHOOK_SECRET']
|
|
355
|
+
else process.env['LINEAR_WEBHOOK_SECRET'] = originalSecret
|
|
356
|
+
globalThis.fetch = originalFetch
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test('Issue.create upserts the task', async () => {
|
|
360
|
+
const db = new Database(':memory:')
|
|
361
|
+
seedLinear(db)
|
|
362
|
+
delete process.env['LINEAR_WEBHOOK_SECRET']
|
|
363
|
+
const provider = new LinearProvider(db, 'tid', 'key')
|
|
364
|
+
const body = JSON.stringify({
|
|
365
|
+
action: 'create',
|
|
366
|
+
type: 'Issue',
|
|
367
|
+
data: {
|
|
368
|
+
id: 'i1',
|
|
369
|
+
identifier: 'DX-1',
|
|
370
|
+
title: 'Linear issue',
|
|
371
|
+
description: 'body',
|
|
372
|
+
priority: 2,
|
|
373
|
+
url: 'https://linear.app/x/i/DX-1',
|
|
374
|
+
createdAt: '2025-02-01T00:00:00.000Z',
|
|
375
|
+
updatedAt: '2025-02-01T00:00:00.000Z',
|
|
376
|
+
state: { id: 's1', name: 'Todo', position: 0 },
|
|
377
|
+
team: { id: 'tid', key: 'DX' },
|
|
378
|
+
labels: [{ id: 'l1', name: 'bug' }],
|
|
379
|
+
commentCount: 1,
|
|
380
|
+
},
|
|
381
|
+
})
|
|
382
|
+
const result = await provider.handleWebhook({ headers: {}, rawBody: body })
|
|
383
|
+
expect(result.handled).toBe(true)
|
|
384
|
+
const tasks = getCachedLinearTasks(db)
|
|
385
|
+
const issue = tasks.find((t) => t.externalRef === 'DX-1')
|
|
386
|
+
expect(issue?.title).toBe('Linear issue')
|
|
387
|
+
expect(issue?.labels).toEqual(['bug'])
|
|
388
|
+
expect(issue?.comment_count).toBe(1)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
test('Issue.remove deletes cache row', async () => {
|
|
392
|
+
const db = new Database(':memory:')
|
|
393
|
+
seedLinear(db)
|
|
394
|
+
upsertIssues(db, [
|
|
395
|
+
{
|
|
396
|
+
id: 'ix',
|
|
397
|
+
identifier: 'DX-9',
|
|
398
|
+
title: 'Bye',
|
|
399
|
+
priority: 0,
|
|
400
|
+
stateId: 's1',
|
|
401
|
+
stateName: 'Todo',
|
|
402
|
+
statePosition: 0,
|
|
403
|
+
createdAt: '2025-01',
|
|
404
|
+
updatedAt: '2025-01',
|
|
405
|
+
},
|
|
406
|
+
])
|
|
407
|
+
delete process.env['LINEAR_WEBHOOK_SECRET']
|
|
408
|
+
const provider = new LinearProvider(db, 'tid', 'key')
|
|
409
|
+
const body = JSON.stringify({
|
|
410
|
+
action: 'remove',
|
|
411
|
+
type: 'Issue',
|
|
412
|
+
data: { id: 'ix', identifier: 'DX-9' },
|
|
413
|
+
})
|
|
414
|
+
const result = await provider.handleWebhook({ headers: {}, rawBody: body })
|
|
415
|
+
expect(result.handled).toBe(true)
|
|
416
|
+
expect(getCachedLinearTasks(db).find((t) => t.externalRef === 'DX-9')).toBeUndefined()
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
test('ignores create/update events from another team', async () => {
|
|
420
|
+
const db = new Database(':memory:')
|
|
421
|
+
seedLinear(db)
|
|
422
|
+
delete process.env['LINEAR_WEBHOOK_SECRET']
|
|
423
|
+
const provider = new LinearProvider(db, 'tid', 'key')
|
|
424
|
+
const body = JSON.stringify({
|
|
425
|
+
action: 'update',
|
|
426
|
+
type: 'Issue',
|
|
427
|
+
data: {
|
|
428
|
+
id: 'other-1',
|
|
429
|
+
identifier: 'OPS-1',
|
|
430
|
+
title: 'Wrong team',
|
|
431
|
+
createdAt: '2025-02-01T00:00:00.000Z',
|
|
432
|
+
updatedAt: '2025-02-01T00:00:00.000Z',
|
|
433
|
+
state: { id: 's1', name: 'Todo', position: 0 },
|
|
434
|
+
team: { id: 'other-team', key: 'OPS' },
|
|
435
|
+
},
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
const result = await provider.handleWebhook({ headers: {}, rawBody: body })
|
|
439
|
+
|
|
440
|
+
expect(result.handled).toBe(false)
|
|
441
|
+
expect(result.message).toContain("Ignoring issue from team 'other-team'")
|
|
442
|
+
expect(getCachedLinearTasks(db).find((task) => task.externalRef === 'OPS-1')).toBeUndefined()
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
test('falls back to issue-team lookup when the webhook payload omits team info', async () => {
|
|
446
|
+
const db = new Database(':memory:')
|
|
447
|
+
seedLinear(db)
|
|
448
|
+
delete process.env['LINEAR_WEBHOOK_SECRET']
|
|
449
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
450
|
+
const body = JSON.parse(String(init?.body)) as {
|
|
451
|
+
query: string
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (body.query.includes('query IssueTeam')) {
|
|
455
|
+
return new Response(
|
|
456
|
+
JSON.stringify({
|
|
457
|
+
data: {
|
|
458
|
+
issue: {
|
|
459
|
+
team: {
|
|
460
|
+
id: 'other-team',
|
|
461
|
+
key: 'OPS',
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
}),
|
|
466
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return new Response(`Unexpected query: ${body.query}`, { status: 500 })
|
|
471
|
+
}) as unknown as typeof fetch
|
|
472
|
+
|
|
473
|
+
const provider = new LinearProvider(db, 'tid', 'key')
|
|
474
|
+
const body = JSON.stringify({
|
|
475
|
+
action: 'update',
|
|
476
|
+
type: 'Issue',
|
|
477
|
+
data: {
|
|
478
|
+
id: 'other-2',
|
|
479
|
+
identifier: 'OPS-2',
|
|
480
|
+
title: 'Wrong team via fallback',
|
|
481
|
+
createdAt: '2025-02-01T00:00:00.000Z',
|
|
482
|
+
updatedAt: '2025-02-01T00:00:00.000Z',
|
|
483
|
+
state: { id: 's1', name: 'Todo', position: 0 },
|
|
484
|
+
},
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
const result = await provider.handleWebhook({ headers: {}, rawBody: body })
|
|
488
|
+
|
|
489
|
+
expect(result.handled).toBe(false)
|
|
490
|
+
expect(result.message).toContain("Ignoring issue from team 'OPS'")
|
|
491
|
+
expect(getCachedLinearTasks(db).find((task) => task.externalRef === 'OPS-2')).toBeUndefined()
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
test('webhook updates preserve cached comment_count when the payload omits it', async () => {
|
|
495
|
+
const db = new Database(':memory:')
|
|
496
|
+
seedLinear(db)
|
|
497
|
+
upsertIssues(db, [
|
|
498
|
+
{
|
|
499
|
+
id: 'i1',
|
|
500
|
+
identifier: 'DX-1',
|
|
501
|
+
title: 'Existing issue',
|
|
502
|
+
priority: 0,
|
|
503
|
+
stateId: 's1',
|
|
504
|
+
stateName: 'Todo',
|
|
505
|
+
statePosition: 0,
|
|
506
|
+
commentCount: 4,
|
|
507
|
+
createdAt: '2025-02-01T00:00:00.000Z',
|
|
508
|
+
updatedAt: '2025-02-01T00:00:00.000Z',
|
|
509
|
+
},
|
|
510
|
+
])
|
|
511
|
+
delete process.env['LINEAR_WEBHOOK_SECRET']
|
|
512
|
+
const provider = new LinearProvider(db, 'tid', 'key')
|
|
513
|
+
const body = JSON.stringify({
|
|
514
|
+
action: 'update',
|
|
515
|
+
type: 'Issue',
|
|
516
|
+
data: {
|
|
517
|
+
id: 'i1',
|
|
518
|
+
identifier: 'DX-1',
|
|
519
|
+
title: 'Existing issue, updated',
|
|
520
|
+
createdAt: '2025-02-01T00:00:00.000Z',
|
|
521
|
+
updatedAt: '2025-02-02T00:00:00.000Z',
|
|
522
|
+
state: { id: 's1', name: 'Todo', position: 0 },
|
|
523
|
+
team: { id: 'tid', key: 'DX' },
|
|
524
|
+
},
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
const result = await provider.handleWebhook({ headers: {}, rawBody: body })
|
|
528
|
+
|
|
529
|
+
expect(result.handled).toBe(true)
|
|
530
|
+
expect(
|
|
531
|
+
getCachedLinearTasks(db).find((task) => task.externalRef === 'DX-1')?.comment_count,
|
|
532
|
+
).toBe(4)
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test('create event stamps lastWebhookAt without clobbering team/lastSyncAt', async () => {
|
|
536
|
+
const db = new Database(':memory:')
|
|
537
|
+
seedLinear(db)
|
|
538
|
+
delete process.env['LINEAR_WEBHOOK_SECRET']
|
|
539
|
+
const provider = new LinearProvider(db, 'tid', 'key')
|
|
540
|
+
const body = JSON.stringify({
|
|
541
|
+
action: 'create',
|
|
542
|
+
type: 'Issue',
|
|
543
|
+
data: {
|
|
544
|
+
id: 'i2',
|
|
545
|
+
identifier: 'DX-2',
|
|
546
|
+
title: 'Webhook-driven',
|
|
547
|
+
createdAt: '2025-02-01T00:00:00.000Z',
|
|
548
|
+
updatedAt: '2025-02-01T00:00:00.000Z',
|
|
549
|
+
state: { id: 's1', name: 'Todo', position: 0 },
|
|
550
|
+
team: { id: 'tid', key: 'DX' },
|
|
551
|
+
},
|
|
552
|
+
})
|
|
553
|
+
const before = Date.now()
|
|
554
|
+
await provider.handleWebhook({ headers: {}, rawBody: body })
|
|
555
|
+
const meta = loadSyncMeta(db)
|
|
556
|
+
expect(meta.lastWebhookAt).not.toBeNull()
|
|
557
|
+
expect(new Date(meta.lastWebhookAt!).getTime()).toBeGreaterThanOrEqual(before)
|
|
558
|
+
expect(meta.team?.id).toBe('tid')
|
|
559
|
+
expect(meta.lastSyncAt).toBe('2025-01-01T00:00:00.000Z')
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
test('linear partial saveSyncMeta preserves omitted keys; null clears', () => {
|
|
563
|
+
const db = new Database(':memory:')
|
|
564
|
+
initLinearCacheSchema(db)
|
|
565
|
+
saveSyncMeta(db, {
|
|
566
|
+
team: { id: 't', key: 'K', name: 'N' },
|
|
567
|
+
lastSyncAt: '2025-01-01T00:00:00.000Z',
|
|
568
|
+
})
|
|
569
|
+
saveSyncMeta(db, { lastWebhookAt: '2025-01-02T00:00:00.000Z' })
|
|
570
|
+
let meta = loadSyncMeta(db)
|
|
571
|
+
expect(meta.team?.id).toBe('t')
|
|
572
|
+
expect(meta.lastSyncAt).toBe('2025-01-01T00:00:00.000Z')
|
|
573
|
+
expect(meta.lastWebhookAt).toBe('2025-01-02T00:00:00.000Z')
|
|
574
|
+
|
|
575
|
+
saveSyncMeta(db, { team: null })
|
|
576
|
+
meta = loadSyncMeta(db)
|
|
577
|
+
expect(meta.team).toBeNull()
|
|
578
|
+
expect(meta.lastSyncAt).toBe('2025-01-01T00:00:00.000Z')
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
test('rejects invalid linear-signature', async () => {
|
|
582
|
+
const db = new Database(':memory:')
|
|
583
|
+
seedLinear(db)
|
|
584
|
+
process.env['LINEAR_WEBHOOK_SECRET'] = 'hushhush'
|
|
585
|
+
const provider = new LinearProvider(db, 'tid', 'key')
|
|
586
|
+
const body = JSON.stringify({
|
|
587
|
+
action: 'update',
|
|
588
|
+
type: 'Issue',
|
|
589
|
+
data: {
|
|
590
|
+
id: 'i1',
|
|
591
|
+
identifier: 'DX-1',
|
|
592
|
+
title: 'x',
|
|
593
|
+
createdAt: '',
|
|
594
|
+
updatedAt: '',
|
|
595
|
+
state: { id: 's1', name: 'Todo', position: 0 },
|
|
596
|
+
},
|
|
597
|
+
})
|
|
598
|
+
const result = await provider.handleWebhook({
|
|
599
|
+
headers: { 'linear-signature': 'bogus' },
|
|
600
|
+
rawBody: body,
|
|
601
|
+
})
|
|
602
|
+
expect(result.unauthorized).toBe(true)
|
|
603
|
+
})
|
|
604
|
+
})
|
package/src/activity.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Database } from 'bun:sqlite'
|
|
2
2
|
import { generateId } from './id.ts'
|
|
3
|
-
import type { ActivityEntry, ActivityAction
|
|
3
|
+
import type { ActivityEntry, ActivityAction } from './types.ts'
|
|
4
4
|
|
|
5
5
|
export function logActivity(
|
|
6
6
|
db: Database,
|
|
@@ -41,10 +41,6 @@ export function listActivity(
|
|
|
41
41
|
.all(params as Record<string, string>) as ActivityEntry[]
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
export function getTaskActivity(db: Database, taskId: string): ActivityEntry[] {
|
|
45
|
-
return listActivity(db, { taskId })
|
|
46
|
-
}
|
|
47
|
-
|
|
48
44
|
export function enterColumn(db: Database, taskId: string, columnId: string): void {
|
|
49
45
|
db.query(
|
|
50
46
|
`INSERT INTO column_time_tracking (id, task_id, column_id)
|
|
@@ -65,9 +61,3 @@ export function exitColumn(db: Database, taskId: string, columnId: string): void
|
|
|
65
61
|
$col: columnId,
|
|
66
62
|
})
|
|
67
63
|
}
|
|
68
|
-
|
|
69
|
-
export function getColumnTimeEntries(db: Database, taskId: string): ColumnTimeEntry[] {
|
|
70
|
-
return db
|
|
71
|
-
.query(`SELECT * FROM column_time_tracking WHERE task_id = $task_id ORDER BY entered_at`)
|
|
72
|
-
.all({ $task_id: taskId }) as ColumnTimeEntry[]
|
|
73
|
-
}
|