@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 +1 -1
- package/src/__tests__/api.test.ts +7 -1
- package/src/__tests__/db.test.ts +2 -1
- package/src/__tests__/index.test.ts +5 -0
- package/src/__tests__/jira-provider-mutations.test.ts +2 -0
- package/src/__tests__/linear-provider-sync.test.ts +33 -6
- package/src/__tests__/postgres-jira-provider.test.ts +33 -2
- package/src/__tests__/postgres-linear-provider.test.ts +46 -1
- package/src/__tests__/postgres-local-provider.test.ts +5 -0
- package/src/__tests__/webhook-events.test.ts +73 -0
- package/src/api.ts +2 -0
- package/src/db.ts +22 -5
- package/src/index.ts +5 -1
- package/src/labels.ts +34 -0
- package/src/providers/jira-client.ts +4 -0
- package/src/providers/jira.ts +3 -1
- package/src/providers/linear-client.ts +95 -0
- package/src/providers/linear.ts +3 -1
- package/src/providers/local.ts +2 -1
- package/src/providers/postgres-jira.ts +32 -1
- package/src/providers/postgres-linear.ts +32 -1
- package/src/providers/postgres-local.ts +8 -3
- package/src/providers/types.ts +1 -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.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 () => {
|
package/src/__tests__/db.test.ts
CHANGED
|
@@ -79,7 +79,7 @@ describe('schema', () => {
|
|
|
79
79
|
expect(listColumns(db)).toHaveLength(5)
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
-
test('migrateSchema adds project
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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({
|
|
201
|
+
const created = await provider.createTask({
|
|
202
|
+
title: 'Hello',
|
|
203
|
+
labels: ['garage-smoke', 'garage-owner-local'],
|
|
204
|
+
})
|
|
180
205
|
|
|
181
|
-
expect(
|
|
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>)
|
|
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 })
|
|
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
|
+
}
|
package/src/providers/jira.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/providers/linear.ts
CHANGED
|
@@ -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')
|
package/src/providers/local.ts
CHANGED
|
@@ -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
|
`
|
package/src/providers/types.ts
CHANGED
|
@@ -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
|
+
}
|