@andypai/agent-kanban 0.3.6 → 0.4.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/package.json +1 -1
- package/src/__tests__/jira-adf.test.ts +7 -0
- package/src/__tests__/webhook-events.test.ts +73 -0
- package/src/__tests__/webhooks.test.ts +39 -0
- package/src/providers/jira-adf.ts +3 -1
- package/src/providers/postgres-jira.ts +29 -0
- package/src/providers/postgres-linear.ts +29 -0
- package/src/webhook-events.ts +135 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { extractWebhookMeta, webhookEventStatus, webhookEventsEnabled } from '../webhook-events'
|
|
4
|
+
|
|
5
|
+
describe('webhookEventStatus', () => {
|
|
6
|
+
test('handled -> accepted', () => {
|
|
7
|
+
expect(webhookEventStatus({ handled: true })).toBe('accepted')
|
|
8
|
+
})
|
|
9
|
+
test('unhandled -> skipped', () => {
|
|
10
|
+
expect(webhookEventStatus({ handled: false })).toBe('skipped')
|
|
11
|
+
expect(webhookEventStatus({ handled: false, message: 'Unsupported event' })).toBe('skipped')
|
|
12
|
+
})
|
|
13
|
+
test('unauthorized -> error (regardless of handled)', () => {
|
|
14
|
+
expect(webhookEventStatus({ handled: false, unauthorized: true })).toBe('error')
|
|
15
|
+
expect(webhookEventStatus({ handled: true, unauthorized: true })).toBe('error')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('webhookEventsEnabled', () => {
|
|
20
|
+
test('enabled by default / when unset', () => {
|
|
21
|
+
expect(webhookEventsEnabled({})).toBe(true)
|
|
22
|
+
expect(webhookEventsEnabled({ KANBAN_WEBHOOK_EVENTS: 'on' })).toBe(true)
|
|
23
|
+
expect(webhookEventsEnabled({ KANBAN_WEBHOOK_EVENTS: '1' })).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
test('disabled by explicit off values', () => {
|
|
26
|
+
for (const v of ['0', 'false', 'off', 'no', 'OFF', ' false ']) {
|
|
27
|
+
expect(webhookEventsEnabled({ KANBAN_WEBHOOK_EVENTS: v })).toBe(false)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('extractWebhookMeta', () => {
|
|
33
|
+
test('jira: webhookEvent + issue.key', () => {
|
|
34
|
+
const body = JSON.stringify({ webhookEvent: 'jira:issue_updated', issue: { key: 'SMTS-7' } })
|
|
35
|
+
expect(extractWebhookMeta('jira', body)).toEqual({
|
|
36
|
+
eventType: 'jira:issue_updated',
|
|
37
|
+
externalRef: 'SMTS-7',
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
test('jira: tolerates a missing issue', () => {
|
|
41
|
+
expect(
|
|
42
|
+
extractWebhookMeta('jira', JSON.stringify({ webhookEvent: 'jira:issue_deleted' })),
|
|
43
|
+
).toEqual({
|
|
44
|
+
eventType: 'jira:issue_deleted',
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
test('linear: type.action + data.identifier', () => {
|
|
48
|
+
const body = JSON.stringify({
|
|
49
|
+
type: 'Issue',
|
|
50
|
+
action: 'update',
|
|
51
|
+
data: { id: 'uuid-1', identifier: 'SMTS-9' },
|
|
52
|
+
})
|
|
53
|
+
expect(extractWebhookMeta('linear', body)).toEqual({
|
|
54
|
+
eventType: 'Issue.update',
|
|
55
|
+
externalRef: 'SMTS-9',
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
test('linear: falls back to data.id when identifier is absent', () => {
|
|
59
|
+
const body = JSON.stringify({ type: 'Issue', action: 'remove', data: { id: 'uuid-2' } })
|
|
60
|
+
expect(extractWebhookMeta('linear', body)).toEqual({
|
|
61
|
+
eventType: 'Issue.remove',
|
|
62
|
+
externalRef: 'uuid-2',
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
test('invalid / non-object body -> {}', () => {
|
|
66
|
+
expect(extractWebhookMeta('jira', 'not json')).toEqual({})
|
|
67
|
+
expect(extractWebhookMeta('jira', '"a string"')).toEqual({})
|
|
68
|
+
expect(extractWebhookMeta('linear', 'null')).toEqual({})
|
|
69
|
+
})
|
|
70
|
+
test('unknown provider -> {}', () => {
|
|
71
|
+
expect(extractWebhookMeta('local', JSON.stringify({ webhookEvent: 'x' }))).toEqual({})
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -139,6 +139,45 @@ describe('Jira webhook', () => {
|
|
|
139
139
|
expect(tasks.find((t) => t.externalRef === 'ENG-100')?.title).toBe('New issue')
|
|
140
140
|
})
|
|
141
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
|
+
|
|
142
181
|
test('issue_deleted removes the task', async () => {
|
|
143
182
|
const db = new Database(':memory:')
|
|
144
183
|
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
|
}
|
|
@@ -34,6 +34,12 @@ import {
|
|
|
34
34
|
type WebhookRequest,
|
|
35
35
|
type WebhookResult,
|
|
36
36
|
} from '../webhooks'
|
|
37
|
+
import {
|
|
38
|
+
ensureWebhookEventsSchema,
|
|
39
|
+
extractWebhookMeta,
|
|
40
|
+
recordWebhookEvent,
|
|
41
|
+
webhookEventStatus,
|
|
42
|
+
} from '../webhook-events'
|
|
37
43
|
|
|
38
44
|
const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
|
|
39
45
|
|
|
@@ -230,6 +236,7 @@ export class PostgresJiraProvider implements KanbanProvider {
|
|
|
230
236
|
await this.sql`
|
|
231
237
|
CREATE INDEX IF NOT EXISTS jira_activity_created_at_idx ON jira_activity(created_at DESC)
|
|
232
238
|
`
|
|
239
|
+
await ensureWebhookEventsSchema(this.sql)
|
|
233
240
|
}
|
|
234
241
|
|
|
235
242
|
private async setMeta(key: string, value: string): Promise<void> {
|
|
@@ -1127,6 +1134,28 @@ export class PostgresJiraProvider implements KanbanProvider {
|
|
|
1127
1134
|
}
|
|
1128
1135
|
|
|
1129
1136
|
async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
|
|
1137
|
+
const meta = extractWebhookMeta('jira', payload.rawBody)
|
|
1138
|
+
let result: WebhookResult
|
|
1139
|
+
try {
|
|
1140
|
+
result = await this.handleWebhookInner(payload)
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
void recordWebhookEvent(this.sql, {
|
|
1143
|
+
provider: 'jira',
|
|
1144
|
+
...meta,
|
|
1145
|
+
status: 'error',
|
|
1146
|
+
detail: { error: err instanceof Error ? err.message : String(err) },
|
|
1147
|
+
})
|
|
1148
|
+
throw err
|
|
1149
|
+
}
|
|
1150
|
+
void recordWebhookEvent(this.sql, {
|
|
1151
|
+
provider: 'jira',
|
|
1152
|
+
...meta,
|
|
1153
|
+
status: webhookEventStatus(result),
|
|
1154
|
+
})
|
|
1155
|
+
return result
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
private async handleWebhookInner(payload: WebhookRequest): Promise<WebhookResult> {
|
|
1130
1159
|
const secret = process.env['JIRA_WEBHOOK_SECRET']
|
|
1131
1160
|
if (secret) {
|
|
1132
1161
|
const sig = headerLower(payload.headers, 'x-hub-signature')
|
|
@@ -14,6 +14,12 @@ import type {
|
|
|
14
14
|
} from '../types'
|
|
15
15
|
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
16
16
|
import { headerLower, verifyHmacSha256, type WebhookRequest, type WebhookResult } from '../webhooks'
|
|
17
|
+
import {
|
|
18
|
+
ensureWebhookEventsSchema,
|
|
19
|
+
extractWebhookMeta,
|
|
20
|
+
recordWebhookEvent,
|
|
21
|
+
webhookEventStatus,
|
|
22
|
+
} from '../webhook-events'
|
|
17
23
|
import { LINEAR_CAPABILITIES } from './capabilities'
|
|
18
24
|
import { unsupportedOperation } from './errors'
|
|
19
25
|
import { LinearClient, type LinearComment } from './linear-client'
|
|
@@ -255,6 +261,7 @@ export class PostgresLinearProvider implements KanbanProvider {
|
|
|
255
261
|
await this.sql`
|
|
256
262
|
CREATE INDEX IF NOT EXISTS linear_activity_created_at_idx ON linear_activity(created_at DESC)
|
|
257
263
|
`
|
|
264
|
+
await ensureWebhookEventsSchema(this.sql)
|
|
258
265
|
}
|
|
259
266
|
|
|
260
267
|
private async setMeta(key: string, value: string): Promise<void> {
|
|
@@ -979,6 +986,28 @@ export class PostgresLinearProvider implements KanbanProvider {
|
|
|
979
986
|
}
|
|
980
987
|
|
|
981
988
|
async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
|
|
989
|
+
const meta = extractWebhookMeta('linear', payload.rawBody)
|
|
990
|
+
let result: WebhookResult
|
|
991
|
+
try {
|
|
992
|
+
result = await this.handleWebhookInner(payload)
|
|
993
|
+
} catch (err) {
|
|
994
|
+
void recordWebhookEvent(this.sql, {
|
|
995
|
+
provider: 'linear',
|
|
996
|
+
...meta,
|
|
997
|
+
status: 'error',
|
|
998
|
+
detail: { error: err instanceof Error ? err.message : String(err) },
|
|
999
|
+
})
|
|
1000
|
+
throw err
|
|
1001
|
+
}
|
|
1002
|
+
void recordWebhookEvent(this.sql, {
|
|
1003
|
+
provider: 'linear',
|
|
1004
|
+
...meta,
|
|
1005
|
+
status: webhookEventStatus(result),
|
|
1006
|
+
})
|
|
1007
|
+
return result
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
private async handleWebhookInner(payload: WebhookRequest): Promise<WebhookResult> {
|
|
982
1011
|
const secret = process.env['LINEAR_WEBHOOK_SECRET']
|
|
983
1012
|
if (secret) {
|
|
984
1013
|
const sig = headerLower(payload.headers, 'linear-signature')
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `webhook_events` — a small, generically-named receipts table that the Postgres
|
|
3
|
+
* providers append to on every received webhook. It is *not* used by agent-kanban
|
|
4
|
+
* itself; it exists so an external consumer (Garage Band's Studio "Webhooks"
|
|
5
|
+
* panel) can show "did the sidecar receive/process a tracker webhook, and when".
|
|
6
|
+
*
|
|
7
|
+
* Ownership: agent-kanban owns and creates this table; consumers read it
|
|
8
|
+
* read-only. Columns a consumer can rely on:
|
|
9
|
+
* id bigserial — newest-first tie-breaker (required by readers'
|
|
10
|
+
* `ORDER BY received_at DESC, id DESC`)
|
|
11
|
+
* received_at timestamptz — when the webhook hit the sidecar
|
|
12
|
+
* provider text — 'jira' | 'linear'
|
|
13
|
+
* event_type text|null — Jira's `webhookEvent`, or Linear's `type.action`
|
|
14
|
+
* external_ref text|null — tracker key when derivable from the body
|
|
15
|
+
* status text — 'accepted' (handled) | 'skipped' (unhandled) |
|
|
16
|
+
* 'error' (unauthorized or threw)
|
|
17
|
+
* detail jsonb — emit-time-controlled; never raw secrets/payloads
|
|
18
|
+
* (currently only `{ error }` on error rows)
|
|
19
|
+
*
|
|
20
|
+
* Best-effort by design: a receipt write must never fail or slow a webhook, and
|
|
21
|
+
* the whole feature no-ops when `KANBAN_WEBHOOK_EVENTS` is off, so it is safe to
|
|
22
|
+
* ship before any consumer exists.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { JSONValue, Sql } from 'postgres'
|
|
26
|
+
|
|
27
|
+
import type { TrackerProvider } from './tracker-config'
|
|
28
|
+
import type { WebhookResult } from './webhooks'
|
|
29
|
+
|
|
30
|
+
export type WebhookEventStatus = 'accepted' | 'skipped' | 'error'
|
|
31
|
+
|
|
32
|
+
export interface WebhookEventRecord {
|
|
33
|
+
provider: TrackerProvider
|
|
34
|
+
eventType?: string | undefined
|
|
35
|
+
externalRef?: string | undefined
|
|
36
|
+
status: WebhookEventStatus
|
|
37
|
+
detail?: Record<string, unknown> | undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `KANBAN_WEBHOOK_EVENTS` toggles the receipts table; enabled unless explicitly off. */
|
|
41
|
+
export function webhookEventsEnabled(
|
|
42
|
+
env: Record<string, string | undefined> = process.env,
|
|
43
|
+
): boolean {
|
|
44
|
+
const value = env['KANBAN_WEBHOOK_EVENTS']?.trim().toLowerCase()
|
|
45
|
+
return value !== '0' && value !== 'false' && value !== 'off' && value !== 'no'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Idempotent — call from a Postgres provider's schema bootstrap. */
|
|
49
|
+
export async function ensureWebhookEventsSchema(sql: Sql): Promise<void> {
|
|
50
|
+
if (!webhookEventsEnabled()) return
|
|
51
|
+
await sql`
|
|
52
|
+
CREATE TABLE IF NOT EXISTS webhook_events (
|
|
53
|
+
id bigserial PRIMARY KEY,
|
|
54
|
+
received_at timestamptz NOT NULL DEFAULT now(),
|
|
55
|
+
provider text NOT NULL,
|
|
56
|
+
event_type text,
|
|
57
|
+
external_ref text,
|
|
58
|
+
status text NOT NULL,
|
|
59
|
+
detail jsonb NOT NULL DEFAULT '{}'::jsonb
|
|
60
|
+
)
|
|
61
|
+
`
|
|
62
|
+
await sql`
|
|
63
|
+
CREATE INDEX IF NOT EXISTS webhook_events_received_at_idx
|
|
64
|
+
ON webhook_events (received_at DESC, id DESC)
|
|
65
|
+
`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function webhookEventStatus(result: WebhookResult): WebhookEventStatus {
|
|
69
|
+
if (result.unauthorized) return 'error'
|
|
70
|
+
return result.handled ? 'accepted' : 'skipped'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Append a receipt. Swallows every error — a logging miss must never fail the webhook. */
|
|
74
|
+
export async function recordWebhookEvent(sql: Sql, record: WebhookEventRecord): Promise<void> {
|
|
75
|
+
if (!webhookEventsEnabled()) return
|
|
76
|
+
try {
|
|
77
|
+
await sql`
|
|
78
|
+
INSERT INTO webhook_events (provider, event_type, external_ref, status, detail)
|
|
79
|
+
VALUES (
|
|
80
|
+
${record.provider},
|
|
81
|
+
${record.eventType ?? null},
|
|
82
|
+
${record.externalRef ?? null},
|
|
83
|
+
${record.status},
|
|
84
|
+
${sql.json((record.detail ?? {}) as JSONValue)}
|
|
85
|
+
)
|
|
86
|
+
`
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.warn('[webhook-events] failed to record receipt:', err)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Light, provider-shaped peek at the raw body for a receipt's `event_type` / `external_ref`. */
|
|
93
|
+
export function extractWebhookMeta(
|
|
94
|
+
providerType: TrackerProvider,
|
|
95
|
+
rawBody: string,
|
|
96
|
+
): { eventType?: string; externalRef?: string } {
|
|
97
|
+
let parsed: unknown
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(rawBody)
|
|
100
|
+
} catch {
|
|
101
|
+
return {}
|
|
102
|
+
}
|
|
103
|
+
if (typeof parsed !== 'object' || parsed === null) return {}
|
|
104
|
+
const body = parsed as Record<string, unknown>
|
|
105
|
+
|
|
106
|
+
if (providerType === 'jira') {
|
|
107
|
+
const eventType = typeof body['webhookEvent'] === 'string' ? body['webhookEvent'] : undefined
|
|
108
|
+
const externalRef = nestedString(body['issue'], 'key')
|
|
109
|
+
return withDefined({ eventType, externalRef })
|
|
110
|
+
}
|
|
111
|
+
if (providerType === 'linear') {
|
|
112
|
+
const type = typeof body['type'] === 'string' ? body['type'] : undefined
|
|
113
|
+
const action = typeof body['action'] === 'string' ? body['action'] : undefined
|
|
114
|
+
const eventType = type && action ? `${type}.${action}` : (type ?? action)
|
|
115
|
+
const externalRef = nestedString(body['data'], 'identifier') ?? nestedString(body['data'], 'id')
|
|
116
|
+
return withDefined({ eventType, externalRef })
|
|
117
|
+
}
|
|
118
|
+
return {}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function nestedString(container: unknown, key: string): string | undefined {
|
|
122
|
+
if (typeof container !== 'object' || container === null) return undefined
|
|
123
|
+
const value = (container as Record<string, unknown>)[key]
|
|
124
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function withDefined(meta: { eventType?: string | undefined; externalRef?: string | undefined }): {
|
|
128
|
+
eventType?: string
|
|
129
|
+
externalRef?: string
|
|
130
|
+
} {
|
|
131
|
+
return {
|
|
132
|
+
...(meta.eventType ? { eventType: meta.eventType } : {}),
|
|
133
|
+
...(meta.externalRef ? { externalRef: meta.externalRef } : {}),
|
|
134
|
+
}
|
|
135
|
+
}
|