@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andypai/agent-kanban",
3
- "version": "0.4.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 { replaceTask, upsertTaskInColumn } from '../../ui/src/components/boardUtils'
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
  })
@@ -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
@@ -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
- 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',
@@ -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>) 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]