@andypai/agent-kanban 0.3.7 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andypai/agent-kanban",
3
- "version": "0.3.7",
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": {
@@ -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
+ })
@@ -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
+ }