@andypai/agent-kanban 0.3.7 → 0.5.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.5.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": {
@@ -41,12 +41,18 @@ describe('handleRequest', () => {
41
41
  const req = new Request('http://localhost/api/tasks', {
42
42
  method: 'POST',
43
43
  headers: { 'Content-Type': 'application/json' },
44
- body: JSON.stringify({ title: 'Created via API' }),
44
+ body: JSON.stringify({ title: 'Created via API', labels: ['garage-smoke', 'api-smoke'] }),
45
45
  })
46
46
  const result = await handleRequest(provider, req)
47
+ const body = (await result.response.json()) as {
48
+ ok: boolean
49
+ data: { labels: string[] }
50
+ }
47
51
 
48
52
  expect(result.response.status).toBe(200)
49
53
  expect(result.mutated).toBe(true)
54
+ expect(body.ok).toBe(true)
55
+ expect(body.data.labels).toEqual(['garage-smoke', 'api-smoke'])
50
56
  })
51
57
 
52
58
  test('marks successful task delete as mutated', async () => {
@@ -79,7 +79,7 @@ describe('schema', () => {
79
79
  expect(listColumns(db)).toHaveLength(5)
80
80
  })
81
81
 
82
- test('migrateSchema adds project column and index to legacy tasks table', () => {
82
+ test('migrateSchema adds project and labels columns to legacy tasks table', () => {
83
83
  const legacy = new Database(':memory:')
84
84
  legacy.run(
85
85
  `CREATE TABLE tasks (
@@ -102,6 +102,7 @@ describe('schema', () => {
102
102
  const indexes = legacy.query('PRAGMA index_list(tasks)').all() as { name: string }[]
103
103
 
104
104
  expect(columns.some((c) => c.name === 'project')).toBe(true)
105
+ expect(columns.some((c) => c.name === 'labels')).toBe(true)
105
106
  expect(indexes.some((i) => i.name === 'idx_tasks_project')).toBe(true)
106
107
  })
107
108
 
@@ -165,6 +165,10 @@ describe('run', () => {
165
165
  'alice',
166
166
  '--project',
167
167
  'Platform',
168
+ '--label',
169
+ 'garage-smoke',
170
+ '--label',
171
+ 'garage-owner-local,smoke-run',
168
172
  '-m',
169
173
  '{"sprint":5}',
170
174
  ]),
@@ -174,6 +178,7 @@ describe('run', () => {
174
178
  expect(created.priority).toBe('high')
175
179
  expect(created.assignee).toBe('alice')
176
180
  expect(created.project).toBe('Platform')
181
+ expect(created.labels).toEqual(['garage-smoke', 'garage-owner-local', 'smoke-run'])
177
182
  expect(created.metadata).toBe('{"sprint":5}')
178
183
 
179
184
  const listed = expectOk<Task[]>(
@@ -352,6 +352,7 @@ describe('JiraProvider mutations', () => {
352
352
  description: 'hello\n- item',
353
353
  priority: 'high',
354
354
  assignee: 'Alice',
355
+ labels: ['garage-smoke', 'garage-owner-local'],
355
356
  })
356
357
 
357
358
  expect(task.externalRef).toBe('ENG-10')
@@ -365,6 +366,7 @@ describe('JiraProvider mutations', () => {
365
366
  expect((body.fields.priority as { name: string }).name).toBe('High')
366
367
  expect((body.fields.assignee as { accountId: string }).accountId).toBe('a-1')
367
368
  expect((body.fields.project as { key: string }).key).toBe('ENG')
369
+ expect(body.fields.labels).toEqual(['garage-smoke', 'garage-owner-local'])
368
370
  const desc = body.fields.description as {
369
371
  version: number
370
372
  type: string
@@ -133,15 +133,32 @@ describe('LinearProvider sync', () => {
133
133
  lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
134
134
  })
135
135
 
136
- let createIssueTeamId: string | null = null
136
+ const createIssueInput: { current?: { labelIds?: string[]; teamId?: string } } = {}
137
137
  globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
138
138
  const body = JSON.parse(String(init?.body)) as {
139
139
  query: string
140
- variables: { input?: { teamId?: string } }
140
+ variables: { input?: { labelIds?: string[]; teamId?: string } }
141
+ }
142
+
143
+ if (body.query.includes('query IssueLabels')) {
144
+ return new Response(
145
+ JSON.stringify({
146
+ data: {
147
+ issueLabels: {
148
+ nodes: [
149
+ { id: 'label-smoke', name: 'garage-smoke' },
150
+ { id: 'label-owner', name: 'garage-owner-local' },
151
+ ],
152
+ pageInfo: { hasNextPage: false, endCursor: null },
153
+ },
154
+ },
155
+ }),
156
+ { status: 200, headers: { 'content-type': 'application/json' } },
157
+ )
141
158
  }
142
159
 
143
160
  if (body.query.includes('mutation CreateIssue')) {
144
- createIssueTeamId = body.variables.input?.teamId ?? null
161
+ createIssueInput.current = body.variables.input
145
162
  return new Response(
146
163
  JSON.stringify({
147
164
  data: {
@@ -159,7 +176,12 @@ describe('LinearProvider sync', () => {
159
176
  assignee: null,
160
177
  project: null,
161
178
  state: { id: 'state-1', name: 'Todo', position: 0 },
162
- labels: { nodes: [] },
179
+ labels: {
180
+ nodes: [
181
+ { id: 'label-smoke', name: 'garage-smoke' },
182
+ { id: 'label-owner', name: 'garage-owner-local' },
183
+ ],
184
+ },
163
185
  comments: {
164
186
  nodes: [],
165
187
  pageInfo: { hasNextPage: false, endCursor: null },
@@ -176,10 +198,15 @@ describe('LinearProvider sync', () => {
176
198
  }) as unknown as typeof fetch
177
199
 
178
200
  const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
179
- const created = await provider.createTask({ title: 'Hello' })
201
+ const created = await provider.createTask({
202
+ title: 'Hello',
203
+ labels: ['garage-smoke', 'garage-owner-local'],
204
+ })
180
205
 
181
- expect(String(createIssueTeamId)).toBe('3ca24047-e954-44e8-b266-c7182410befb')
206
+ expect(createIssueInput.current?.teamId).toBe('3ca24047-e954-44e8-b266-c7182410befb')
207
+ expect(createIssueInput.current?.labelIds).toEqual(['label-smoke', 'label-owner'])
182
208
  expect(created.externalRef).toBe('R2P-1')
209
+ expect(created.labels).toEqual(['garage-smoke', 'garage-owner-local'])
183
210
  })
184
211
 
185
212
  test('periodic full sync prunes cached issues missing from upstream', async () => {
@@ -3,6 +3,7 @@ import postgres from 'postgres'
3
3
 
4
4
  import { run } from '../index'
5
5
  import type { Task } from '../types'
6
+ import { PostgresJiraProvider } from '../providers/postgres-jira'
6
7
 
7
8
  const databaseUrl = process.env['KANBAN_PG_TEST_URL'] ?? process.env['DATABASE_URL']
8
9
  const pgTest = databaseUrl ? test : test.skip
@@ -43,6 +44,7 @@ function makeIssue(
43
44
  id: string
44
45
  key: string
45
46
  summary: string
47
+ labels: string[]
46
48
  updated: string
47
49
  }> = {},
48
50
  ): Record<string, unknown> {
@@ -56,7 +58,7 @@ function makeIssue(
56
58
  issuetype: { id: '10000', name: 'Task' },
57
59
  priority: { id: '2', name: 'High' },
58
60
  assignee: { accountId: 'a1', displayName: 'Alice' },
59
- labels: ['garage'],
61
+ labels: overrides.labels ?? ['garage'],
60
62
  comment: { total: 0 },
61
63
  created: '2026-01-01T00:00:00Z',
62
64
  updated: overrides.updated ?? '2026-01-02T00:00:00Z',
@@ -114,12 +116,13 @@ function standardRoutes(): StubRoute[] {
114
116
  match: (url) => url.endsWith('/rest/api/3/issue'),
115
117
  handler: async (_url, init) => {
116
118
  const body = JSON.parse(String(init?.body ?? '{}')) as {
117
- fields?: { summary?: string }
119
+ fields?: { labels?: string[]; summary?: string }
118
120
  }
119
121
  const issue = makeIssue({
120
122
  id: '10002',
121
123
  key: 'ENG-2',
122
124
  summary: body.fields?.summary ?? 'Created issue',
125
+ labels: body.fields?.labels,
123
126
  updated: '2026-01-03T00:00:00Z',
124
127
  })
125
128
  issues.push(issue)
@@ -303,6 +306,34 @@ describe('postgres jira provider', () => {
303
306
  expect(comments).toHaveLength(1)
304
307
  })
305
308
 
309
+ pgTest('passes labels when creating Jira tasks through Postgres storage', async () => {
310
+ expect(sql).not.toBeNull()
311
+ if (!sql) throw new Error('expected postgres test connection')
312
+ const stub = jiraFetchStub(standardRoutes())
313
+ globalThis.fetch = stub.fn
314
+ const provider = new PostgresJiraProvider(sql, {
315
+ baseUrl: 'https://example.atlassian.net',
316
+ email: 'user@example.com',
317
+ apiToken: 'token',
318
+ projectKey: 'ENG',
319
+ })
320
+
321
+ const created = await provider.createTask({
322
+ title: 'Created with labels',
323
+ labels: ['garage-smoke', 'garage-owner-local'],
324
+ })
325
+
326
+ expect(created.labels).toEqual(['garage-smoke', 'garage-owner-local'])
327
+ const postCall = stub.calls.find(
328
+ (call) => call.init?.method === 'POST' && call.url.endsWith('/rest/api/3/issue'),
329
+ )
330
+ expect(postCall).toBeDefined()
331
+ const body = JSON.parse(String(postCall?.init?.body ?? '{}')) as {
332
+ fields?: Record<string, unknown>
333
+ }
334
+ expect(body.fields?.labels).toEqual(['garage-smoke', 'garage-owner-local'])
335
+ })
336
+
306
337
  pgTest('moves Jira tasks through Postgres storage', async () => {
307
338
  const moved = expectOk<Task>(await run(['task', 'move', 'ENG-1', 'Done']))
308
339
 
@@ -3,6 +3,7 @@ import postgres from 'postgres'
3
3
 
4
4
  import { run } from '../index'
5
5
  import type { Task, TaskComment } from '../types'
6
+ import { PostgresLinearProvider } from '../providers/postgres-linear'
6
7
 
7
8
  const databaseUrl = process.env['KANBAN_PG_TEST_URL'] ?? process.env['DATABASE_URL']
8
9
  const pgTest = databaseUrl ? test : test.skip
@@ -31,6 +32,8 @@ type StubComment = {
31
32
  user: { id: string; name: string; displayName: string }
32
33
  }
33
34
 
35
+ type LinearStubCall = { query: string; variables: Record<string, unknown> }
36
+
34
37
  function expectOk<T>(result: Awaited<ReturnType<typeof run>>): T {
35
38
  expect(result.exitCode).toBe(0)
36
39
  expect(result.output.ok).toBe(true)
@@ -64,7 +67,7 @@ function makeIssue(overrides: Partial<StubIssue> = {}): StubIssue {
64
67
  }
65
68
  }
66
69
 
67
- function linearFetchStub(): typeof fetch {
70
+ function linearFetchStub(calls: LinearStubCall[] = []): typeof fetch {
68
71
  const commentsByIssue = new Map<string, StubComment[]>()
69
72
  const issues: StubIssue[] = [makeIssue()]
70
73
  const states = [
@@ -83,6 +86,12 @@ function linearFetchStub(): typeof fetch {
83
86
  { id: 'proj-1', name: 'Garage', url: 'https://linear.app/project/garage', state: 'started' },
84
87
  ],
85
88
  }
89
+ const labels = {
90
+ nodes: [
91
+ { id: 'label-smoke', name: 'garage-smoke' },
92
+ { id: 'label-owner', name: 'garage-owner-local' },
93
+ ],
94
+ }
86
95
 
87
96
  return (async (_input: string | URL | Request, init?: RequestInit) => {
88
97
  const body = JSON.parse(String(init?.body ?? '{}')) as {
@@ -91,6 +100,7 @@ function linearFetchStub(): typeof fetch {
91
100
  }
92
101
  const query = body.query
93
102
  const variables = body.variables ?? {}
103
+ calls.push({ query, variables })
94
104
 
95
105
  if (query.includes('query TeamSnapshot')) {
96
106
  return Response.json({ data: { team } })
@@ -101,6 +111,16 @@ function linearFetchStub(): typeof fetch {
101
111
  if (query.includes('query Projects')) {
102
112
  return Response.json({ data: { projects } })
103
113
  }
114
+ if (query.includes('query IssueLabels')) {
115
+ return Response.json({
116
+ data: {
117
+ issueLabels: {
118
+ ...labels,
119
+ pageInfo: { hasNextPage: false, endCursor: null },
120
+ },
121
+ },
122
+ })
123
+ }
104
124
  if (query.includes('query Issues')) {
105
125
  return Response.json({
106
126
  data: {
@@ -119,8 +139,10 @@ function linearFetchStub(): typeof fetch {
119
139
  stateId?: string
120
140
  assigneeId?: string
121
141
  projectId?: string
142
+ labelIds?: string[]
122
143
  }
123
144
  const state = states.find((candidate) => candidate.id === input.stateId) ?? states[0]!
145
+ const issueLabels = labels.nodes.filter((label) => input.labelIds?.includes(label.id))
124
146
  const issue = makeIssue({
125
147
  id: 'lin-2',
126
148
  identifier: 'GB-2',
@@ -130,6 +152,7 @@ function linearFetchStub(): typeof fetch {
130
152
  state,
131
153
  assignee: users.nodes.find((user) => user.id === input.assigneeId) ?? null,
132
154
  project: projects.nodes.find((project) => project.id === input.projectId) ?? null,
155
+ labels: { nodes: issueLabels },
133
156
  updatedAt: '2026-01-03T00:00:00.000Z',
134
157
  })
135
158
  issues.push(issue)
@@ -306,4 +329,26 @@ describe('postgres linear provider', () => {
306
329
  const comments = expectOk<TaskComment[]>(await run(['comment', 'list', 'GB-2']))
307
330
  expect(comments).toHaveLength(1)
308
331
  })
332
+
333
+ pgTest('passes labels when creating Linear tasks through Postgres storage', async () => {
334
+ expect(sql).not.toBeNull()
335
+ if (!sql) throw new Error('expected postgres test connection')
336
+
337
+ const calls: LinearStubCall[] = []
338
+ globalThis.fetch = linearFetchStub(calls)
339
+ const provider = new PostgresLinearProvider(sql, 'GB', 'linear-key')
340
+
341
+ const created = await provider.createTask({
342
+ title: 'Created with labels',
343
+ labels: ['garage-smoke', 'garage-owner-local'],
344
+ })
345
+
346
+ expect(created.labels).toEqual(['garage-smoke', 'garage-owner-local'])
347
+ const createCall = calls.find((call) => call.query.includes('mutation CreateIssue'))
348
+ expect(createCall).toBeDefined()
349
+ expect((createCall?.variables.input as { labelIds?: string[] })?.labelIds).toEqual([
350
+ 'label-smoke',
351
+ 'label-owner',
352
+ ])
353
+ })
309
354
  })
@@ -78,6 +78,10 @@ describe('postgres local provider', () => {
78
78
  'garage',
79
79
  '--project',
80
80
  'Dispatch',
81
+ '--label',
82
+ 'garage-smoke',
83
+ '--label',
84
+ 'postgres-local',
81
85
  '-m',
82
86
  '{"storage":"postgres"}',
83
87
  ]),
@@ -85,6 +89,7 @@ describe('postgres local provider', () => {
85
89
 
86
90
  expect(created.title).toBe('Postgres-backed task')
87
91
  expect(created.column_name).toBe('recurring')
92
+ expect(created.labels).toEqual(['garage-smoke', 'postgres-local'])
88
93
  expect(created.version).toBe('0')
89
94
 
90
95
  const listed = expectOk<Task[]>(await run(['task', 'list', '-c', 'recurring']))
@@ -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
+ })
package/src/api.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { KanbanError, ErrorCode } from './errors'
2
2
  import type { BoardConfig, CliOutput, Task } from './types'
3
3
  import type { CreateTaskInput, UpdateTaskInput, KanbanProvider } from './providers/types'
4
+ import { normalizeLabels } from './labels'
4
5
 
5
6
  export type WsEvent =
6
7
  | { type: 'task:upsert'; task: Task; columnName: string }
@@ -146,6 +147,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
146
147
  priority: body.priority,
147
148
  assignee: body.assignee,
148
149
  project: body.project,
150
+ labels: normalizeLabels(body.labels),
149
151
  metadata: body.metadata,
150
152
  })
151
153
  return { ok: true, data: created }
package/src/db.ts CHANGED
@@ -6,6 +6,7 @@ import { generateId } from './id'
6
6
  import { ErrorCode, KanbanError } from './errors'
7
7
  import type { BoardView, Column, Priority, Task, TaskComment, TaskWithColumn } from './types'
8
8
  import { logActivity, enterColumn, exitColumn } from './activity'
9
+ import { normalizeLabels, parseStoredLabels } from './labels'
9
10
 
10
11
  const DEFAULT_COLUMNS = [
11
12
  { name: 'recurring', position: 0 },
@@ -63,6 +64,7 @@ export function initSchema(db: Database): void {
63
64
  priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low','medium','high','urgent')),
64
65
  assignee TEXT NOT NULL DEFAULT '',
65
66
  project TEXT NOT NULL DEFAULT '',
67
+ labels TEXT NOT NULL DEFAULT '[]',
66
68
  metadata TEXT NOT NULL DEFAULT '{}',
67
69
  revision INTEGER NOT NULL DEFAULT 0,
68
70
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -124,6 +126,10 @@ export function migrateSchema(db: Database): void {
124
126
  if (!hasProject) {
125
127
  db.run("ALTER TABLE tasks ADD COLUMN project TEXT NOT NULL DEFAULT ''")
126
128
  }
129
+ const hasLabels = columns.some((c) => c.name === 'labels')
130
+ if (!hasLabels) {
131
+ db.run("ALTER TABLE tasks ADD COLUMN labels TEXT NOT NULL DEFAULT '[]'")
132
+ }
127
133
  const hasRevision = columns.some((c) => c.name === 'revision')
128
134
  if (!hasRevision) {
129
135
  db.run('ALTER TABLE tasks ADD COLUMN revision INTEGER NOT NULL DEFAULT 0')
@@ -276,6 +282,7 @@ export function addTask(
276
282
  priority?: Priority
277
283
  assignee?: string
278
284
  project?: string
285
+ labels?: string[]
279
286
  metadata?: string
280
287
  } = {},
281
288
  ): TaskWithColumn {
@@ -297,13 +304,14 @@ export function addTask(
297
304
  }
298
305
 
299
306
  const id = generateId('t')
307
+ const labels = normalizeLabels(opts.labels)
300
308
  const maxPos = db
301
309
  .query('SELECT COALESCE(MAX(position), -1) + 1 as next FROM tasks WHERE column_id = $col')
302
310
  .get({ $col: column.id }) as { next: number }
303
311
 
304
312
  db.query(
305
- `INSERT INTO tasks (id, title, description, column_id, position, priority, assignee, project, metadata)
306
- VALUES ($id, $title, $desc, $col, $pos, $pri, $assignee, $project, $meta)`,
313
+ `INSERT INTO tasks (id, title, description, column_id, position, priority, assignee, project, labels, metadata)
314
+ VALUES ($id, $title, $desc, $col, $pos, $pri, $assignee, $project, $labels, $meta)`,
307
315
  ).run({
308
316
  $id: id,
309
317
  $title: title,
@@ -313,6 +321,7 @@ export function addTask(
313
321
  $pri: opts.priority ?? 'medium',
314
322
  $assignee: opts.assignee ?? '',
315
323
  $project: opts.project ?? '',
324
+ $labels: JSON.stringify(labels),
316
325
  $meta: opts.metadata ?? '{}',
317
326
  })
318
327
  logActivity(db, id, 'created', { new_value: title })
@@ -330,7 +339,7 @@ export function getTask(db: Database, id: string): TaskWithColumn {
330
339
  if (!task) {
331
340
  throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${id}'`)
332
341
  }
333
- return task
342
+ return hydrateTask(task)
334
343
  }
335
344
 
336
345
  export function listTasks(
@@ -384,7 +393,8 @@ export function listTasks(
384
393
  JOIN columns c ON t.column_id = c.id
385
394
  ${where} ORDER BY ${orderBy} ${limitClause}`,
386
395
  )
387
- .all(params as Record<string, string>) as TaskWithColumn[]
396
+ .all(params as Record<string, string>)
397
+ .map((task) => hydrateTask(task as TaskWithColumn))
388
398
  }
389
399
 
390
400
  export function updateTask(
@@ -630,11 +640,18 @@ export function getBoardView(db: Database): BoardView {
630
640
  ...col,
631
641
  tasks: db
632
642
  .query('SELECT * FROM tasks WHERE column_id = $col ORDER BY position')
633
- .all({ $col: col.id }) as Task[],
643
+ .all({ $col: col.id })
644
+ .map((task) => hydrateTask(task as Task)),
634
645
  })),
635
646
  }
636
647
  }
637
648
 
649
+ function hydrateTask<T extends { labels?: unknown }>(
650
+ task: T,
651
+ ): Omit<T, 'labels'> & { labels: string[] } {
652
+ return { ...task, labels: parseStoredLabels(task.labels) }
653
+ }
654
+
638
655
  // --- Bulk ---
639
656
 
640
657
  export function bulkMoveAll(
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import { openKanbanRuntime } from './provider-runtime'
15
15
  import { trackerConfigFromEnv } from './tracker-config'
16
16
  import type { KanbanProvider } from './providers/types'
17
17
  import { resolvePollingSyncIntervalMs } from './sync-config'
18
+ import { normalizeLabels } from './labels'
18
19
 
19
20
  interface ParsedArgs {
20
21
  values: Record<string, unknown>
@@ -34,6 +35,8 @@ function parseCliArgs(argv: string[]): ParsedArgs {
34
35
  a: { type: 'string' },
35
36
  m: { type: 'string' },
36
37
  l: { type: 'string' },
38
+ label: { type: 'string', multiple: true },
39
+ labels: { type: 'string', multiple: true },
37
40
  sort: { type: 'string' },
38
41
  title: { type: 'string' },
39
42
  position: { type: 'string' },
@@ -68,6 +71,7 @@ async function routeTask(
68
71
  priority: values.p as Priority | undefined,
69
72
  assignee: values.a as string | undefined,
70
73
  project: values.project as string | undefined,
74
+ labels: normalizeLabels([values.label, values.labels]),
71
75
  metadata: values.m as string | undefined,
72
76
  }),
73
77
  )
@@ -388,7 +392,7 @@ Commands:
388
392
  board view View full board (default)
389
393
  board reset Reset board to defaults
390
394
 
391
- task add <title> Add a task [-d desc] [-c column] [-p priority] [-a assignee] [--project name] [-m json]
395
+ task add <title> Add a task [-d desc] [-c column] [-p priority] [-a assignee] [--project name] [--label name] [-m json]
392
396
  task list List tasks [-c column] [-p priority] [-a assignee] [--project name] [-l limit] [--sort field]
393
397
  task view <id> View task details
394
398
  task update <id> Update task [--title] [-d] [-p] [-a] [--project name] [-m]
package/src/labels.ts ADDED
@@ -0,0 +1,34 @@
1
+ export function normalizeLabels(input: unknown): string[] {
2
+ const labels: string[] = []
3
+ const seen = new Set<string>()
4
+
5
+ collectLabels(input, labels, seen)
6
+
7
+ return labels
8
+ }
9
+
10
+ export function parseStoredLabels(raw: unknown): string[] {
11
+ if (typeof raw !== 'string') return normalizeLabels(raw)
12
+
13
+ try {
14
+ return normalizeLabels(JSON.parse(raw))
15
+ } catch {
16
+ return normalizeLabels(raw)
17
+ }
18
+ }
19
+
20
+ function collectLabels(input: unknown, labels: string[], seen: Set<string>): void {
21
+ if (Array.isArray(input)) {
22
+ for (const item of input) collectLabels(item, labels, seen)
23
+ return
24
+ }
25
+
26
+ if (typeof input !== 'string') return
27
+
28
+ for (const part of input.split(',')) {
29
+ const label = part.trim()
30
+ if (!label || seen.has(label)) continue
31
+ seen.add(label)
32
+ labels.push(label)
33
+ }
34
+ }
@@ -388,3 +388,7 @@ export class JiraClient {
388
388
  })
389
389
  }
390
390
  }
391
+
392
+ export function normalizeJiraLabels(labels: string[] | undefined): string[] {
393
+ return (labels ?? []).map((label) => label.trim()).filter(Boolean)
394
+ }
@@ -20,7 +20,7 @@ import {
20
20
  import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
21
21
  import { JIRA_CAPABILITIES } from './capabilities'
22
22
  import { providerUpstreamError, unsupportedOperation } from './errors'
23
- import { JiraClient, type JiraComment, type JiraIssue } from './jira-client'
23
+ import { JiraClient, normalizeJiraLabels, type JiraComment, type JiraIssue } from './jira-client'
24
24
  import {
25
25
  adjustJiraIssueCommentCount,
26
26
  decodeColumnStatusIds,
@@ -488,6 +488,8 @@ export class JiraProvider implements KanbanProvider {
488
488
  accountId: this.resolveAssigneeAccountId(input.assignee),
489
489
  }
490
490
  }
491
+ const labels = normalizeJiraLabels(input.labels)
492
+ if (labels.length > 0) fields['labels'] = labels
491
493
  // Column at create-time is intentionally unsupported in Jira mode: new
492
494
  // issues land in the project workflow's default start state. Use
493
495
  // `moveTask` after create to change status.
@@ -43,6 +43,11 @@ export interface LinearComment {
43
43
  user?: { id: string; name?: string | null; displayName?: string | null } | null
44
44
  }
45
45
 
46
+ export interface LinearIssueLabel {
47
+ id: string
48
+ name: string
49
+ }
50
+
46
51
  interface LinearIssueNode {
47
52
  id: string
48
53
  identifier: string
@@ -232,6 +237,40 @@ export class LinearClient {
232
237
  return data.projects.nodes
233
238
  }
234
239
 
240
+ async listIssueLabels(): Promise<LinearIssueLabel[]> {
241
+ let after: string | null = null
242
+ const labels: LinearIssueLabel[] = []
243
+
244
+ do {
245
+ const data: {
246
+ issueLabels: {
247
+ nodes: LinearIssueLabel[]
248
+ pageInfo: PageInfo
249
+ }
250
+ } = await this.query(
251
+ `
252
+ query IssueLabels($after: String) {
253
+ issueLabels(first: 100, after: $after) {
254
+ nodes {
255
+ id
256
+ name
257
+ }
258
+ pageInfo {
259
+ hasNextPage
260
+ endCursor
261
+ }
262
+ }
263
+ }
264
+ `,
265
+ { after },
266
+ )
267
+ labels.push(...data.issueLabels.nodes)
268
+ after = data.issueLabels.pageInfo.hasNextPage ? data.issueLabels.pageInfo.endCursor : null
269
+ } while (after)
270
+
271
+ return labels
272
+ }
273
+
235
274
  async listIssues(teamId: string, updatedAfter?: string): Promise<LinearIssue[]> {
236
275
  let after: string | null = null
237
276
  const issues: LinearIssue[] = []
@@ -311,6 +350,7 @@ export class LinearClient {
311
350
  priority?: number
312
351
  assigneeId?: string
313
352
  projectId?: string
353
+ labelIds?: string[]
314
354
  }): Promise<{ success: boolean; issue: LinearIssue | null }> {
315
355
  const data = await this.query<{
316
356
  issueCreate: { success: boolean; issue: LinearIssueNode | null }
@@ -349,6 +389,7 @@ export class LinearClient {
349
389
  priority: input.priority,
350
390
  assigneeId: input.assigneeId,
351
391
  projectId: input.projectId,
392
+ labelIds: input.labelIds,
352
393
  },
353
394
  },
354
395
  )
@@ -567,3 +608,57 @@ export class LinearClient {
567
608
  }
568
609
  }
569
610
  }
611
+
612
+ export async function resolveLabelIdsForCreate(
613
+ client: LinearClient,
614
+ inputLabels: string[] | undefined,
615
+ ): Promise<string[] | undefined> {
616
+ if (!inputLabels?.some((label) => label.trim())) return undefined
617
+ return resolveLinearLabelIds(inputLabels, await client.listIssueLabels())
618
+ }
619
+
620
+ export function resolveLinearLabelIds(
621
+ inputLabels: string[] | undefined,
622
+ availableLabels: LinearIssueLabel[],
623
+ ): string[] | undefined {
624
+ const requested = dedupeLabelQueries(inputLabels)
625
+ if (requested.length === 0) return undefined
626
+
627
+ const byName = new Map(
628
+ availableLabels.map((label) => [label.name.trim().toLowerCase(), label.id]),
629
+ )
630
+ const knownIds = new Set(availableLabels.map((label) => label.id))
631
+ const ids: string[] = []
632
+ const missing: string[] = []
633
+
634
+ for (const label of requested) {
635
+ const id = byName.get(label.toLowerCase()) ?? (knownIds.has(label) ? label : undefined)
636
+ if (!id) {
637
+ missing.push(label)
638
+ continue
639
+ }
640
+ if (!ids.includes(id)) ids.push(id)
641
+ }
642
+
643
+ if (missing.length > 0) {
644
+ providerUpstreamError(
645
+ `Linear label${missing.length === 1 ? '' : 's'} not found: ${missing.join(', ')}`,
646
+ )
647
+ }
648
+
649
+ return ids.length > 0 ? ids : undefined
650
+ }
651
+
652
+ function dedupeLabelQueries(labels: string[] | undefined): string[] {
653
+ const out: string[] = []
654
+ const seen = new Set<string>()
655
+ for (const label of labels ?? []) {
656
+ const trimmed = label.trim()
657
+ if (!trimmed) continue
658
+ const key = trimmed.toLowerCase()
659
+ if (seen.has(key)) continue
660
+ seen.add(key)
661
+ out.push(trimmed)
662
+ }
663
+ return out
664
+ }
@@ -31,7 +31,7 @@ import {
31
31
  upsertUsers,
32
32
  type LinearActivityRow,
33
33
  } from './linear-cache'
34
- import { LinearClient, type LinearComment } from './linear-client'
34
+ import { LinearClient, resolveLabelIdsForCreate, type LinearComment } from './linear-client'
35
35
  import { unsupportedOperation } from './errors'
36
36
  import type {
37
37
  CreateTaskInput,
@@ -349,6 +349,7 @@ export class LinearProvider implements KanbanProvider {
349
349
  async createTask(input: CreateTaskInput) {
350
350
  await this.sync()
351
351
  const state = input.column ? this.resolveState(input.column) : undefined
352
+ const labelIds = await resolveLabelIdsForCreate(this.client, input.labels)
352
353
  const result = await this.client.createIssue({
353
354
  teamId: this.resolvedTeamId(),
354
355
  stateId: state?.id,
@@ -357,6 +358,7 @@ export class LinearProvider implements KanbanProvider {
357
358
  priority: toLinearPriority(input.priority),
358
359
  assigneeId: this.resolveAssigneeId(input.assignee),
359
360
  projectId: this.resolveProjectId(input.project),
361
+ labelIds,
360
362
  })
361
363
  if (!result.success || !result.issue) {
362
364
  throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue creation failed')
@@ -56,13 +56,14 @@ export class LocalProvider implements KanbanProvider {
56
56
  private enrichTask(task: Task, commentCount?: number): Task {
57
57
  const revision = task.revision ?? 0
58
58
  const assignees = task.assignee ? [task.assignee] : []
59
+ const labels = Array.isArray(task.labels) ? task.labels : []
59
60
  return {
60
61
  ...task,
61
62
  providerId: task.id,
62
63
  externalRef: task.id,
63
64
  url: null,
64
65
  assignees,
65
- labels: [],
66
+ labels,
66
67
  comment_count: commentCount ?? countComments(this.db, task.id),
67
68
  version: String(revision),
68
69
  source_updated_at: null,
@@ -16,7 +16,7 @@ import type {
16
16
  import { JIRA_CAPABILITIES } from './capabilities'
17
17
  import { decodeColumnStatusIds, type JiraActivityRow, type JiraColumnRow } from './jira-cache'
18
18
  import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
19
- import { JiraClient, type JiraComment, type JiraIssue } from './jira-client'
19
+ import { JiraClient, normalizeJiraLabels, type JiraComment, type JiraIssue } from './jira-client'
20
20
  import type { JiraProviderConfig } from './jira'
21
21
  import { providerUpstreamError, unsupportedOperation } from './errors'
22
22
  import type {
@@ -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> {
@@ -941,6 +948,8 @@ export class PostgresJiraProvider implements KanbanProvider {
941
948
  if (input.assignee) {
942
949
  fields['assignee'] = { accountId: await this.resolveAssigneeAccountId(input.assignee) }
943
950
  }
951
+ const labels = normalizeJiraLabels(input.labels)
952
+ if (labels.length > 0) fields['labels'] = labels
944
953
  const created = await this.client.createIssue({ fields })
945
954
  await this.sync(true)
946
955
  const fresh = await this.getCachedTask(created.key)
@@ -1127,6 +1136,28 @@ export class PostgresJiraProvider implements KanbanProvider {
1127
1136
  }
1128
1137
 
1129
1138
  async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
1139
+ const meta = extractWebhookMeta('jira', payload.rawBody)
1140
+ let result: WebhookResult
1141
+ try {
1142
+ result = await this.handleWebhookInner(payload)
1143
+ } catch (err) {
1144
+ void recordWebhookEvent(this.sql, {
1145
+ provider: 'jira',
1146
+ ...meta,
1147
+ status: 'error',
1148
+ detail: { error: err instanceof Error ? err.message : String(err) },
1149
+ })
1150
+ throw err
1151
+ }
1152
+ void recordWebhookEvent(this.sql, {
1153
+ provider: 'jira',
1154
+ ...meta,
1155
+ status: webhookEventStatus(result),
1156
+ })
1157
+ return result
1158
+ }
1159
+
1160
+ private async handleWebhookInner(payload: WebhookRequest): Promise<WebhookResult> {
1130
1161
  const secret = process.env['JIRA_WEBHOOK_SECRET']
1131
1162
  if (secret) {
1132
1163
  const sig = headerLower(payload.headers, 'x-hub-signature')
@@ -14,9 +14,15 @@ 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
- import { LinearClient, type LinearComment } from './linear-client'
25
+ import { LinearClient, resolveLabelIdsForCreate, type LinearComment } from './linear-client'
20
26
  import type {
21
27
  CreateTaskInput,
22
28
  KanbanProvider,
@@ -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> {
@@ -815,6 +822,7 @@ export class PostgresLinearProvider implements KanbanProvider {
815
822
  async createTask(input: CreateTaskInput): Promise<Task> {
816
823
  await this.sync()
817
824
  const state = input.column ? await this.resolveState(input.column) : undefined
825
+ const labelIds = await resolveLabelIdsForCreate(this.client, input.labels)
818
826
  const result = await this.client.createIssue({
819
827
  teamId: await this.resolvedTeamId(),
820
828
  stateId: state?.id,
@@ -823,6 +831,7 @@ export class PostgresLinearProvider implements KanbanProvider {
823
831
  priority: toLinearPriority(input.priority),
824
832
  assigneeId: await this.resolveAssigneeId(input.assignee),
825
833
  projectId: await this.resolveProjectId(input.project),
834
+ labelIds,
826
835
  })
827
836
  if (!result.success || !result.issue) {
828
837
  throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue creation failed')
@@ -979,6 +988,28 @@ export class PostgresLinearProvider implements KanbanProvider {
979
988
  }
980
989
 
981
990
  async handleWebhook(payload: WebhookRequest): Promise<WebhookResult> {
991
+ const meta = extractWebhookMeta('linear', payload.rawBody)
992
+ let result: WebhookResult
993
+ try {
994
+ result = await this.handleWebhookInner(payload)
995
+ } catch (err) {
996
+ void recordWebhookEvent(this.sql, {
997
+ provider: 'linear',
998
+ ...meta,
999
+ status: 'error',
1000
+ detail: { error: err instanceof Error ? err.message : String(err) },
1001
+ })
1002
+ throw err
1003
+ }
1004
+ void recordWebhookEvent(this.sql, {
1005
+ provider: 'linear',
1006
+ ...meta,
1007
+ status: webhookEventStatus(result),
1008
+ })
1009
+ return result
1010
+ }
1011
+
1012
+ private async handleWebhookInner(payload: WebhookRequest): Promise<WebhookResult> {
982
1013
  const secret = process.env['LINEAR_WEBHOOK_SECRET']
983
1014
  if (secret) {
984
1015
  const sig = headerLower(payload.headers, 'linear-signature')
@@ -24,6 +24,7 @@ import type {
24
24
  UpdateTaskInput,
25
25
  } from './types'
26
26
  import type { LocalTrackerConfig } from '../tracker-config'
27
+ import { normalizeLabels, parseStoredLabels } from '../labels'
27
28
 
28
29
  const DEFAULT_COLUMNS = [
29
30
  { name: 'recurring', position: 0 },
@@ -43,6 +44,7 @@ interface TaskRow {
43
44
  priority: Priority
44
45
  assignee: string
45
46
  project: string
47
+ labels: string
46
48
  metadata: string
47
49
  revision: number
48
50
  created_at: string
@@ -126,6 +128,7 @@ export class PostgresLocalProvider implements KanbanProvider {
126
128
  priority TEXT NOT NULL DEFAULT 'medium',
127
129
  assignee TEXT NOT NULL DEFAULT '',
128
130
  project TEXT NOT NULL DEFAULT '',
131
+ labels TEXT NOT NULL DEFAULT '[]',
129
132
  metadata TEXT NOT NULL DEFAULT '{}',
130
133
  revision INTEGER NOT NULL DEFAULT 0,
131
134
  created_at TEXT NOT NULL,
@@ -162,6 +165,7 @@ export class PostgresLocalProvider implements KanbanProvider {
162
165
  exited_at TEXT
163
166
  )
164
167
  `
168
+ await this.sql`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS labels TEXT NOT NULL DEFAULT '[]'`
165
169
  await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_column_id ON tasks(column_id)`
166
170
  await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority)`
167
171
  await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)`
@@ -194,7 +198,7 @@ export class PostgresLocalProvider implements KanbanProvider {
194
198
  externalRef: row.id,
195
199
  url: null,
196
200
  assignees: row.assignee ? [row.assignee] : [],
197
- labels: [],
201
+ labels: parseStoredLabels(row.labels),
198
202
  comment_count: commentCount,
199
203
  version: String(row.revision ?? 0),
200
204
  source_updated_at: null,
@@ -352,6 +356,7 @@ export class PostgresLocalProvider implements KanbanProvider {
352
356
  const priority = input.priority ?? 'medium'
353
357
  assertPriority(priority)
354
358
  const metadata = parseMetadata(input.metadata)
359
+ const labels = normalizeLabels(input.labels)
355
360
  const column = input.column
356
361
  ? await this.resolveColumn(input.column)
357
362
  : await this.resolveDefaultTaskColumn()
@@ -362,12 +367,12 @@ export class PostgresLocalProvider implements KanbanProvider {
362
367
  const timestamp = nowIso()
363
368
  await this.sql`
364
369
  INSERT INTO tasks (
365
- id, title, description, column_id, position, priority, assignee, project, metadata,
370
+ id, title, description, column_id, position, priority, assignee, project, labels, metadata,
366
371
  revision, created_at, updated_at
367
372
  )
368
373
  VALUES (
369
374
  ${id}, ${input.title}, ${input.description ?? ''}, ${column.id}, ${Number(positionRow?.next ?? 0)},
370
- ${priority}, ${input.assignee ?? ''}, ${input.project ?? ''}, ${metadata},
375
+ ${priority}, ${input.assignee ?? ''}, ${input.project ?? ''}, ${JSON.stringify(labels)}, ${metadata},
371
376
  0, ${timestamp}, ${timestamp}
372
377
  )
373
378
  `
@@ -29,6 +29,7 @@ export interface CreateTaskInput {
29
29
  priority?: Priority
30
30
  assignee?: string
31
31
  project?: string
32
+ labels?: string[]
32
33
  metadata?: string
33
34
  }
34
35
 
@@ -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
+ }