@andypai/agent-kanban 0.3.5 → 0.3.7
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.",
|
|
5
5
|
"homepage": "https://github.com/abpai/agent-kanban#readme",
|
|
6
6
|
"repository": {
|
|
@@ -181,6 +181,13 @@ describe('plainTextToAdf / adfToPlainText', () => {
|
|
|
181
181
|
expect(adfToPlainText(doc)).toBe('before\n\nafter')
|
|
182
182
|
})
|
|
183
183
|
|
|
184
|
+
test('Jira webhook plain string descriptions pass through unchanged', () => {
|
|
185
|
+
const description =
|
|
186
|
+
'*Repo:* https://github.com/abpai/smoke-test\n\nPlease make one minimal change.'
|
|
187
|
+
|
|
188
|
+
expect(adfToPlainText(description)).toBe(description)
|
|
189
|
+
})
|
|
190
|
+
|
|
184
191
|
test('strong + link marks survive on read as markdown', () => {
|
|
185
192
|
const doc: AdfDocument = {
|
|
186
193
|
version: 1,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { beforeEach, afterEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import { Database } from 'bun:sqlite'
|
|
3
3
|
import { createHmac } from 'node:crypto'
|
|
4
|
-
import { verifyHmacSha256 } from '../webhooks'
|
|
4
|
+
import { verifyHmacSha256, verifySha256HmacSignatureHeader } from '../webhooks'
|
|
5
5
|
import { JiraProvider, type JiraProviderConfig } from '../providers/jira'
|
|
6
6
|
import { JiraClient } from '../providers/jira-client'
|
|
7
7
|
import { LinearProvider } from '../providers/linear'
|
|
@@ -57,6 +57,20 @@ describe('verifyHmacSha256', () => {
|
|
|
57
57
|
})
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
+
describe('verifySha256HmacSignatureHeader', () => {
|
|
61
|
+
test('accepts Jira WebSub-style sha256 signatures', () => {
|
|
62
|
+
const body = '{"hello":"world"}'
|
|
63
|
+
const sig = hmac('s3cr3t', body)
|
|
64
|
+
expect(verifySha256HmacSignatureHeader('s3cr3t', body, `sha256=${sig}`)).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('rejects missing method prefix', () => {
|
|
68
|
+
const body = '{"hello":"world"}'
|
|
69
|
+
const sig = hmac('s3cr3t', body)
|
|
70
|
+
expect(verifySha256HmacSignatureHeader('s3cr3t', body, sig)).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
60
74
|
const jiraConfig: JiraProviderConfig = {
|
|
61
75
|
baseUrl: 'https://example.atlassian.net',
|
|
62
76
|
email: 'u@example.com',
|
|
@@ -125,6 +139,45 @@ describe('Jira webhook', () => {
|
|
|
125
139
|
expect(tasks.find((t) => t.externalRef === 'ENG-100')?.title).toBe('New issue')
|
|
126
140
|
})
|
|
127
141
|
|
|
142
|
+
test('issue_created accepts Jira webhook string descriptions', async () => {
|
|
143
|
+
const db = new Database(':memory:')
|
|
144
|
+
seedJira(db)
|
|
145
|
+
delete process.env['JIRA_WEBHOOK_SECRET']
|
|
146
|
+
const client = new JiraClient({
|
|
147
|
+
baseUrl: jiraConfig.baseUrl,
|
|
148
|
+
email: jiraConfig.email,
|
|
149
|
+
apiToken: jiraConfig.apiToken,
|
|
150
|
+
})
|
|
151
|
+
const provider = new JiraProvider(db, jiraConfig, client)
|
|
152
|
+
const description = '*Repo:* https://github.com/abpai/smoke-test\n\nString webhook body.'
|
|
153
|
+
const body = JSON.stringify({
|
|
154
|
+
webhookEvent: 'jira:issue_created',
|
|
155
|
+
issue: {
|
|
156
|
+
id: '101',
|
|
157
|
+
key: 'ENG-101',
|
|
158
|
+
fields: {
|
|
159
|
+
summary: 'String description issue',
|
|
160
|
+
description,
|
|
161
|
+
status: { id: '1', name: 'To Do' },
|
|
162
|
+
issuetype: { id: '10000', name: 'Task' },
|
|
163
|
+
assignee: null,
|
|
164
|
+
labels: ['alpha'],
|
|
165
|
+
comment: { total: 0 },
|
|
166
|
+
created: '2025-02-01T00:00:00.000Z',
|
|
167
|
+
updated: '2025-02-01T00:00:00.000Z',
|
|
168
|
+
project: { id: '1', key: 'ENG' },
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const result = await provider.handleWebhook({ headers: {}, rawBody: body })
|
|
174
|
+
|
|
175
|
+
expect(result.handled).toBe(true)
|
|
176
|
+
expect(getCachedJiraTasks(db).find((t) => t.externalRef === 'ENG-101')?.description).toBe(
|
|
177
|
+
description,
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
|
|
128
181
|
test('issue_deleted removes the task', async () => {
|
|
129
182
|
const db = new Database(':memory:')
|
|
130
183
|
seedJira(db)
|
|
@@ -181,7 +234,7 @@ describe('Jira webhook', () => {
|
|
|
181
234
|
issue: { id: '300', key: 'ENG-300', fields: {} },
|
|
182
235
|
})
|
|
183
236
|
const result = await provider.handleWebhook({
|
|
184
|
-
headers: { 'x-hub-signature
|
|
237
|
+
headers: { 'x-hub-signature': 'sha256=deadbeef' },
|
|
185
238
|
rawBody: body,
|
|
186
239
|
})
|
|
187
240
|
expect(result.unauthorized).toBe(true)
|
|
@@ -214,12 +267,34 @@ describe('Jira webhook', () => {
|
|
|
214
267
|
})
|
|
215
268
|
const sig = hmac('topsecret', body)
|
|
216
269
|
const result = await provider.handleWebhook({
|
|
217
|
-
headers: { 'x-hub-signature
|
|
270
|
+
headers: { 'x-hub-signature': `sha256=${sig}` },
|
|
218
271
|
rawBody: body,
|
|
219
272
|
})
|
|
220
273
|
expect(result.handled).toBe(true)
|
|
221
274
|
})
|
|
222
275
|
|
|
276
|
+
test('rejects the old custom x-hub-signature-256 header when secret is configured', async () => {
|
|
277
|
+
const db = new Database(':memory:')
|
|
278
|
+
seedJira(db)
|
|
279
|
+
process.env['JIRA_WEBHOOK_SECRET'] = 'topsecret'
|
|
280
|
+
const client = new JiraClient({
|
|
281
|
+
baseUrl: jiraConfig.baseUrl,
|
|
282
|
+
email: jiraConfig.email,
|
|
283
|
+
apiToken: jiraConfig.apiToken,
|
|
284
|
+
})
|
|
285
|
+
const provider = new JiraProvider(db, jiraConfig, client)
|
|
286
|
+
const body = JSON.stringify({
|
|
287
|
+
webhookEvent: 'jira:issue_created',
|
|
288
|
+
issue: { id: '401', key: 'ENG-401', fields: {} },
|
|
289
|
+
})
|
|
290
|
+
const sig = hmac('topsecret', body)
|
|
291
|
+
const result = await provider.handleWebhook({
|
|
292
|
+
headers: { 'x-hub-signature-256': `sha256=${sig}` },
|
|
293
|
+
rawBody: body,
|
|
294
|
+
})
|
|
295
|
+
expect(result.unauthorized).toBe(true)
|
|
296
|
+
})
|
|
297
|
+
|
|
223
298
|
test('ignores issue updates from other projects', async () => {
|
|
224
299
|
const db = new Database(':memory:')
|
|
225
300
|
seedJira(db)
|
|
@@ -434,6 +434,8 @@ function renderBlocks(nodes: AdfBlockNode[]): string {
|
|
|
434
434
|
return out.join('\n\n')
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
-
export function adfToPlainText(doc: AdfDocument): string {
|
|
437
|
+
export function adfToPlainText(doc: AdfDocument | string | null | undefined): string {
|
|
438
|
+
if (typeof doc === 'string') return doc
|
|
439
|
+
if (!doc || !Array.isArray(doc.content)) return ''
|
|
438
440
|
return renderBlocks(doc.content)
|
|
439
441
|
}
|
package/src/providers/jira.ts
CHANGED
|
@@ -11,7 +11,12 @@ import type {
|
|
|
11
11
|
TaskComment,
|
|
12
12
|
Task,
|
|
13
13
|
} from '../types'
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
headerLower,
|
|
16
|
+
verifySha256HmacSignatureHeader,
|
|
17
|
+
type WebhookRequest,
|
|
18
|
+
type WebhookResult,
|
|
19
|
+
} from '../webhooks'
|
|
15
20
|
import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
|
|
16
21
|
import { JIRA_CAPABILITIES } from './capabilities'
|
|
17
22
|
import { providerUpstreamError, unsupportedOperation } from './errors'
|
|
@@ -712,8 +717,8 @@ export class JiraProvider implements KanbanProvider {
|
|
|
712
717
|
async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
|
|
713
718
|
const secret = process.env['JIRA_WEBHOOK_SECRET']
|
|
714
719
|
if (secret) {
|
|
715
|
-
const sig = headerLower(payload.headers, 'x-hub-signature
|
|
716
|
-
if (!
|
|
720
|
+
const sig = headerLower(payload.headers, 'x-hub-signature')
|
|
721
|
+
if (!verifySha256HmacSignatureHeader(secret, payload.rawBody, sig)) {
|
|
717
722
|
return { handled: false, unauthorized: true, message: 'Invalid signature' }
|
|
718
723
|
}
|
|
719
724
|
}
|
|
@@ -28,7 +28,12 @@ import type {
|
|
|
28
28
|
UpdateTaskInput,
|
|
29
29
|
} from './types'
|
|
30
30
|
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
headerLower,
|
|
33
|
+
verifySha256HmacSignatureHeader,
|
|
34
|
+
type WebhookRequest,
|
|
35
|
+
type WebhookResult,
|
|
36
|
+
} from '../webhooks'
|
|
32
37
|
|
|
33
38
|
const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
|
|
34
39
|
|
|
@@ -1124,8 +1129,8 @@ export class PostgresJiraProvider implements KanbanProvider {
|
|
|
1124
1129
|
async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
|
|
1125
1130
|
const secret = process.env['JIRA_WEBHOOK_SECRET']
|
|
1126
1131
|
if (secret) {
|
|
1127
|
-
const sig = headerLower(payload.headers, 'x-hub-signature
|
|
1128
|
-
if (!
|
|
1132
|
+
const sig = headerLower(payload.headers, 'x-hub-signature')
|
|
1133
|
+
if (!verifySha256HmacSignatureHeader(secret, payload.rawBody, sig)) {
|
|
1129
1134
|
return { handled: false, unauthorized: true, message: 'Invalid signature' }
|
|
1130
1135
|
}
|
|
1131
1136
|
}
|
package/src/webhooks.ts
CHANGED
|
@@ -27,6 +27,20 @@ export function verifyHmacSha256(
|
|
|
27
27
|
return timingSafeEqual(macBuf, expBuf)
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export function verifySha256HmacSignatureHeader(
|
|
31
|
+
secret: string,
|
|
32
|
+
rawBody: string,
|
|
33
|
+
providedSignature: string | undefined | null,
|
|
34
|
+
): boolean {
|
|
35
|
+
if (!providedSignature) return false
|
|
36
|
+
const eq = providedSignature.indexOf('=')
|
|
37
|
+
if (eq === -1) return false
|
|
38
|
+
const method = providedSignature.slice(0, eq).toLowerCase()
|
|
39
|
+
const signature = providedSignature.slice(eq + 1)
|
|
40
|
+
if (method !== 'sha256') return false
|
|
41
|
+
return verifyHmacSha256(secret, rawBody, signature)
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
export function headerLower(headers: Record<string, string>, name: string): string | undefined {
|
|
31
45
|
const target = name.toLowerCase()
|
|
32
46
|
for (const [k, v] of Object.entries(headers)) {
|