@andypai/agent-kanban 0.4.0 → 0.5.1
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__/board-utils.test.ts +37 -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__/jira-provider-read.test.ts +29 -0
- package/src/__tests__/linear-provider-sync.test.ts +33 -6
- package/src/__tests__/postgres-jira-provider.test.ts +92 -3
- package/src/__tests__/postgres-linear-provider.test.ts +46 -1
- package/src/__tests__/postgres-local-provider.test.ts +5 -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-cache.ts +64 -1
- package/src/providers/jira-client.ts +4 -0
- package/src/providers/jira.ts +7 -20
- 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 +12 -20
- package/src/providers/postgres-linear.ts +3 -1
- package/src/providers/postgres-local.ts +8 -3
- package/src/providers/types.ts +1 -0
- package/ui/dist/assets/index-CFhtfqCn.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-qNVJ6clH.js +0 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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 () => {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
moveTaskInBoard,
|
|
4
|
+
replaceTask,
|
|
5
|
+
upsertTaskInColumn,
|
|
6
|
+
} from '../../ui/src/components/boardUtils'
|
|
3
7
|
import type { BoardView, Task } from '../../ui/src/types'
|
|
4
8
|
|
|
5
9
|
function makeTask(
|
|
@@ -97,4 +101,36 @@ describe('boardUtils', () => {
|
|
|
97
101
|
expect(nextBoard.columns[0]!.tasks.map((task) => task.id)).toEqual(['t-1', 'tmp-1', 't-2'])
|
|
98
102
|
expect(nextBoard.columns[0]!.tasks[0]!.title).toBe('First edited')
|
|
99
103
|
})
|
|
104
|
+
|
|
105
|
+
test('moveTaskInBoard accepts a column id when column names repeat', () => {
|
|
106
|
+
const board: BoardView = {
|
|
107
|
+
columns: [
|
|
108
|
+
{
|
|
109
|
+
id: 'board:1006:Backlog',
|
|
110
|
+
name: 'Backlog',
|
|
111
|
+
position: 0,
|
|
112
|
+
color: null,
|
|
113
|
+
created_at: '',
|
|
114
|
+
updated_at: '',
|
|
115
|
+
tasks: [
|
|
116
|
+
makeTask({ id: 't-1', title: 'First', column_id: 'board:1006:Backlog', position: 0 }),
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: 'board:1006:Backlog:1',
|
|
121
|
+
name: 'Backlog',
|
|
122
|
+
position: 1,
|
|
123
|
+
color: null,
|
|
124
|
+
created_at: '',
|
|
125
|
+
updated_at: '',
|
|
126
|
+
tasks: [],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const nextBoard = moveTaskInBoard(board, 't-1', 'board:1006:Backlog:1')
|
|
132
|
+
|
|
133
|
+
expect(nextBoard.columns[0]!.tasks).toEqual([])
|
|
134
|
+
expect(nextBoard.columns[1]!.tasks.map((task) => task.id)).toEqual(['t-1'])
|
|
135
|
+
})
|
|
100
136
|
})
|
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
|
|
@@ -229,6 +229,35 @@ describe('JiraProvider read path', () => {
|
|
|
229
229
|
expect(decodeColumnStatusIds(cols[0]!)).toEqual(['10001', '10002'])
|
|
230
230
|
})
|
|
231
231
|
|
|
232
|
+
test('sync keeps duplicate Jira board column names as distinct cached columns', async () => {
|
|
233
|
+
const { provider } = makeProviderWithBoard(
|
|
234
|
+
standardRoutes({
|
|
235
|
+
boardCfg: {
|
|
236
|
+
id: 1006,
|
|
237
|
+
name: 'ENG Board',
|
|
238
|
+
columnConfig: {
|
|
239
|
+
columns: [
|
|
240
|
+
{ name: 'Backlog', statuses: [{ id: '10001' }] },
|
|
241
|
+
{ name: 'Backlog', statuses: [{ id: '10002' }] },
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
}),
|
|
246
|
+
1006,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
await provider.getBoard()
|
|
250
|
+
|
|
251
|
+
const cols = getCachedColumns(db)
|
|
252
|
+
expect(cols.map((column) => column.id)).toEqual(['board:1006:Backlog', 'board:1006:Backlog:1'])
|
|
253
|
+
expect(cols.map((column) => decodeColumnStatusIds(column))).toEqual([['10001'], ['10002']])
|
|
254
|
+
await expect(provider.listTasks({ column: 'Backlog' })).rejects.toMatchObject({
|
|
255
|
+
code: ErrorCode.COLUMN_NOT_FOUND,
|
|
256
|
+
message: expect.stringContaining('ambiguous'),
|
|
257
|
+
})
|
|
258
|
+
expect(await provider.listTasks({ column: 'board:1006:Backlog' })).toEqual([])
|
|
259
|
+
})
|
|
260
|
+
|
|
232
261
|
test('sync populates columns from statuses when boardId is absent', async () => {
|
|
233
262
|
const { provider } = makeProvider(standardRoutes({}))
|
|
234
263
|
await provider.getBoard()
|
|
@@ -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',
|
|
@@ -65,7 +67,7 @@ function makeIssue(
|
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
function standardRoutes(): StubRoute[] {
|
|
70
|
+
function standardRoutes(opts: { boardCfg?: unknown } = {}): StubRoute[] {
|
|
69
71
|
const issues = [makeIssue()]
|
|
70
72
|
const comments: Record<
|
|
71
73
|
string,
|
|
@@ -98,6 +100,19 @@ function standardRoutes(): StubRoute[] {
|
|
|
98
100
|
match: (url) => url.includes('/rest/api/3/project/ENG'),
|
|
99
101
|
handler: () => jsonResponse(projectFixture),
|
|
100
102
|
},
|
|
103
|
+
{
|
|
104
|
+
match: (url) => url.includes('/rest/agile/1.0/board/'),
|
|
105
|
+
handler: () =>
|
|
106
|
+
jsonResponse(
|
|
107
|
+
opts.boardCfg ?? {
|
|
108
|
+
id: 1006,
|
|
109
|
+
name: 'ENG Board',
|
|
110
|
+
columnConfig: {
|
|
111
|
+
columns: [{ name: 'Done', statuses: [{ id: '10' }, { id: '20' }] }],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
),
|
|
115
|
+
},
|
|
101
116
|
{
|
|
102
117
|
match: (url) => url.includes('/rest/api/3/user/assignable/search'),
|
|
103
118
|
handler: () => jsonResponse([{ accountId: 'a1', displayName: 'Alice', active: true }]),
|
|
@@ -114,12 +129,13 @@ function standardRoutes(): StubRoute[] {
|
|
|
114
129
|
match: (url) => url.endsWith('/rest/api/3/issue'),
|
|
115
130
|
handler: async (_url, init) => {
|
|
116
131
|
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
|
117
|
-
fields?: { summary?: string }
|
|
132
|
+
fields?: { labels?: string[]; summary?: string }
|
|
118
133
|
}
|
|
119
134
|
const issue = makeIssue({
|
|
120
135
|
id: '10002',
|
|
121
136
|
key: 'ENG-2',
|
|
122
137
|
summary: body.fields?.summary ?? 'Created issue',
|
|
138
|
+
labels: body.fields?.labels,
|
|
123
139
|
updated: '2026-01-03T00:00:00Z',
|
|
124
140
|
})
|
|
125
141
|
issues.push(issue)
|
|
@@ -303,6 +319,79 @@ describe('postgres jira provider', () => {
|
|
|
303
319
|
expect(comments).toHaveLength(1)
|
|
304
320
|
})
|
|
305
321
|
|
|
322
|
+
pgTest('passes labels when creating Jira tasks through Postgres storage', async () => {
|
|
323
|
+
expect(sql).not.toBeNull()
|
|
324
|
+
if (!sql) throw new Error('expected postgres test connection')
|
|
325
|
+
const stub = jiraFetchStub(standardRoutes())
|
|
326
|
+
globalThis.fetch = stub.fn
|
|
327
|
+
const provider = new PostgresJiraProvider(sql, {
|
|
328
|
+
baseUrl: 'https://example.atlassian.net',
|
|
329
|
+
email: 'user@example.com',
|
|
330
|
+
apiToken: 'token',
|
|
331
|
+
projectKey: 'ENG',
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
const created = await provider.createTask({
|
|
335
|
+
title: 'Created with labels',
|
|
336
|
+
labels: ['garage-smoke', 'garage-owner-local'],
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
expect(created.labels).toEqual(['garage-smoke', 'garage-owner-local'])
|
|
340
|
+
const postCall = stub.calls.find(
|
|
341
|
+
(call) => call.init?.method === 'POST' && call.url.endsWith('/rest/api/3/issue'),
|
|
342
|
+
)
|
|
343
|
+
expect(postCall).toBeDefined()
|
|
344
|
+
const body = JSON.parse(String(postCall?.init?.body ?? '{}')) as {
|
|
345
|
+
fields?: Record<string, unknown>
|
|
346
|
+
}
|
|
347
|
+
expect(body.fields?.labels).toEqual(['garage-smoke', 'garage-owner-local'])
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
pgTest('keeps duplicate Jira board column names as distinct cached columns', async () => {
|
|
351
|
+
expect(sql).not.toBeNull()
|
|
352
|
+
if (!sql) throw new Error('expected postgres test connection')
|
|
353
|
+
const stub = jiraFetchStub(
|
|
354
|
+
standardRoutes({
|
|
355
|
+
boardCfg: {
|
|
356
|
+
id: 1006,
|
|
357
|
+
name: 'ENG Board',
|
|
358
|
+
columnConfig: {
|
|
359
|
+
columns: [
|
|
360
|
+
{ name: 'Backlog', statuses: [{ id: '10' }] },
|
|
361
|
+
{ name: 'Backlog', statuses: [{ id: '20' }] },
|
|
362
|
+
],
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
}),
|
|
366
|
+
)
|
|
367
|
+
globalThis.fetch = stub.fn
|
|
368
|
+
const provider = new PostgresJiraProvider(sql, {
|
|
369
|
+
baseUrl: 'https://example.atlassian.net',
|
|
370
|
+
email: 'user@example.com',
|
|
371
|
+
apiToken: 'token',
|
|
372
|
+
projectKey: 'ENG',
|
|
373
|
+
boardId: 1006,
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const board = await provider.getBoard()
|
|
377
|
+
|
|
378
|
+
expect(board.columns.map((column) => column.id)).toEqual([
|
|
379
|
+
'board:1006:Backlog',
|
|
380
|
+
'board:1006:Backlog:1',
|
|
381
|
+
])
|
|
382
|
+
expect(board.columns.map((column) => column.tasks.map((task) => task.externalRef))).toEqual([
|
|
383
|
+
['ENG-1'],
|
|
384
|
+
[],
|
|
385
|
+
])
|
|
386
|
+
await expect(provider.listTasks({ column: 'Backlog' })).rejects.toMatchObject({
|
|
387
|
+
code: 'COLUMN_NOT_FOUND',
|
|
388
|
+
message: expect.stringContaining('ambiguous'),
|
|
389
|
+
})
|
|
390
|
+
expect(
|
|
391
|
+
(await provider.listTasks({ column: 'board:1006:Backlog' })).map((task) => task.id),
|
|
392
|
+
).toEqual(['jira:10001'])
|
|
393
|
+
})
|
|
394
|
+
|
|
306
395
|
pgTest('moves Jira tasks through Postgres storage', async () => {
|
|
307
396
|
const moved = expectOk<Task>(await run(['task', 'move', 'ENG-1', 'Done']))
|
|
308
397
|
|
|
@@ -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']))
|
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]
|