@andypai/agent-kanban 0.2.0 → 0.3.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.
Files changed (59) hide show
  1. package/README.md +89 -22
  2. package/package.json +4 -2
  3. package/src/__tests__/activity.test.ts +15 -9
  4. package/src/__tests__/api.test.ts +96 -0
  5. package/src/__tests__/board-utils.test.ts +100 -0
  6. package/src/__tests__/commands/board.test.ts +6 -13
  7. package/src/__tests__/conflict.test.ts +64 -0
  8. package/src/__tests__/index.test.ts +233 -56
  9. package/src/__tests__/jira-adf.test.ts +168 -0
  10. package/src/__tests__/jira-cache.test.ts +304 -0
  11. package/src/__tests__/jira-client.test.ts +169 -0
  12. package/src/__tests__/jira-provider-comment.test.ts +281 -0
  13. package/src/__tests__/jira-provider-mutations.test.ts +771 -0
  14. package/src/__tests__/jira-provider-read.test.ts +594 -0
  15. package/src/__tests__/jira-wiring.test.ts +187 -0
  16. package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
  17. package/src/__tests__/linear-provider-comment.test.ts +243 -0
  18. package/src/__tests__/linear-provider-sync.test.ts +493 -0
  19. package/src/__tests__/local-provider-comment.test.ts +60 -0
  20. package/src/__tests__/mcp-core.test.ts +164 -0
  21. package/src/__tests__/mcp-server.test.ts +252 -0
  22. package/src/__tests__/server.test.ts +298 -0
  23. package/src/__tests__/webhooks.test.ts +604 -0
  24. package/src/activity.ts +1 -11
  25. package/src/api.ts +154 -19
  26. package/src/commands/board.ts +1 -11
  27. package/src/commands/mcp.ts +87 -0
  28. package/src/db.ts +115 -3
  29. package/src/errors.ts +2 -0
  30. package/src/id.ts +1 -1
  31. package/src/index.ts +72 -18
  32. package/src/mcp/core.ts +193 -0
  33. package/src/mcp/errors.ts +109 -0
  34. package/src/mcp/index.ts +13 -0
  35. package/src/mcp/server.ts +512 -0
  36. package/src/mcp/types.ts +72 -0
  37. package/src/providers/capabilities.ts +15 -0
  38. package/src/providers/index.ts +31 -1
  39. package/src/providers/jira-adf.ts +275 -0
  40. package/src/providers/jira-cache.ts +625 -0
  41. package/src/providers/jira-client.ts +390 -0
  42. package/src/providers/jira.ts +778 -0
  43. package/src/providers/linear-cache.ts +249 -70
  44. package/src/providers/linear-client.ts +256 -13
  45. package/src/providers/linear.ts +337 -14
  46. package/src/providers/local.ts +68 -17
  47. package/src/providers/types.ts +18 -2
  48. package/src/server.ts +139 -11
  49. package/src/tunnel.ts +79 -0
  50. package/src/types.ts +18 -2
  51. package/src/webhooks.ts +36 -0
  52. package/ui/dist/assets/index-DBnoKL_k.css +1 -0
  53. package/ui/dist/assets/index-qNVJ6clH.js +40 -0
  54. package/ui/dist/index.html +2 -2
  55. package/src/__tests__/commands/task.test.ts +0 -144
  56. package/src/commands/task.ts +0 -117
  57. package/src/fixtures.ts +0 -128
  58. package/ui/dist/assets/index-B8f9NB4z.css +0 -1
  59. package/ui/dist/assets/index-zWp-rB7b.js +0 -40
@@ -3,63 +3,118 @@ import { Database } from 'bun:sqlite'
3
3
  import { mkdtempSync, rmSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
- import { run } from '../index.ts'
6
+ import { ErrorCode, KanbanError, type ErrorCodeValue } from '../errors.ts'
7
+ import type { Task } from '../types.ts'
8
+ import { parseServeArgs, run } from '../index.ts'
9
+
10
+ async function withTempDb(runTest: (dbPath: string) => Promise<void>): Promise<void> {
11
+ const dir = mkdtempSync(join(tmpdir(), 'kanban-run-'))
12
+ const dbPath = join(dir, 'board.db')
13
+ const prevProvider = process.env['KANBAN_PROVIDER']
14
+ process.env['KANBAN_PROVIDER'] = 'local'
15
+
16
+ try {
17
+ await runTest(dbPath)
18
+ } finally {
19
+ if (prevProvider === undefined) delete process.env['KANBAN_PROVIDER']
20
+ else process.env['KANBAN_PROVIDER'] = prevProvider
21
+ rmSync(dir, { recursive: true, force: true })
22
+ }
23
+ }
24
+
25
+ function expectOk<T>(result: Awaited<ReturnType<typeof run>>): T {
26
+ expect(result.exitCode).toBe(0)
27
+ expect(result.output.ok).toBe(true)
28
+ if (!result.output.ok) {
29
+ throw new Error('expected successful CLI output')
30
+ }
31
+ return result.output.data as T
32
+ }
33
+
34
+ async function expectKanbanError(
35
+ runPromise: Promise<Awaited<ReturnType<typeof run>>>,
36
+ code: ErrorCodeValue,
37
+ ): Promise<KanbanError> {
38
+ const err = await runPromise.then(
39
+ () => null,
40
+ (e: unknown) => e,
41
+ )
42
+ expect(err).toBeInstanceOf(KanbanError)
43
+ expect((err as KanbanError).code).toBe(code)
44
+ return err as KanbanError
45
+ }
46
+
47
+ describe('parseServeArgs', () => {
48
+ test('defaults: no tunnel, port from PORT env or 3000', () => {
49
+ const prev = process.env['PORT']
50
+ delete process.env['PORT']
51
+ try {
52
+ expect(parseServeArgs(['serve'])).toEqual({ db: undefined, port: 3000, tunnel: false })
53
+ process.env['PORT'] = '4001'
54
+ expect(parseServeArgs(['serve'])).toEqual({ db: undefined, port: 4001, tunnel: false })
55
+ } finally {
56
+ if (prev === undefined) delete process.env['PORT']
57
+ else process.env['PORT'] = prev
58
+ }
59
+ })
60
+
61
+ test('--tunnel opts in; --port and --db override', () => {
62
+ const opts = parseServeArgs(['serve', '--tunnel', '--port', '5050', '--db', '/tmp/b.db'])
63
+ expect(opts).toEqual({ db: '/tmp/b.db', port: 5050, tunnel: true })
64
+ })
65
+ })
7
66
 
8
67
  describe('run', () => {
9
68
  test('applies schema migration before task commands', async () => {
10
- process.env['KANBAN_PROVIDER'] = 'local'
11
- const dir = mkdtempSync(join(tmpdir(), 'kanban-run-'))
12
- const dbPath = join(dir, 'board.db')
13
-
14
- const legacy = new Database(dbPath)
15
- legacy.run(
16
- `CREATE TABLE columns (
17
- id TEXT PRIMARY KEY,
18
- name TEXT UNIQUE NOT NULL,
19
- position INTEGER NOT NULL,
20
- color TEXT,
21
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
22
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
23
- )`,
24
- )
25
- legacy.run(
26
- `CREATE TABLE tasks (
27
- id TEXT PRIMARY KEY,
28
- title TEXT NOT NULL,
29
- description TEXT NOT NULL DEFAULT '',
30
- column_id TEXT NOT NULL,
31
- position INTEGER NOT NULL DEFAULT 0,
32
- priority TEXT NOT NULL DEFAULT 'medium',
33
- assignee TEXT NOT NULL DEFAULT '',
34
- metadata TEXT NOT NULL DEFAULT '{}',
35
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
36
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
37
- )`,
38
- )
39
- legacy.run(
40
- `CREATE TABLE activity_log (
41
- id TEXT PRIMARY KEY,
42
- task_id TEXT NOT NULL,
43
- action TEXT NOT NULL,
44
- field_changed TEXT,
45
- old_value TEXT,
46
- new_value TEXT,
47
- timestamp TEXT NOT NULL DEFAULT (datetime('now'))
48
- )`,
49
- )
50
- legacy.run(
51
- `CREATE TABLE column_time_tracking (
52
- id TEXT PRIMARY KEY,
53
- task_id TEXT NOT NULL,
54
- column_id TEXT NOT NULL,
55
- entered_at TEXT NOT NULL DEFAULT (datetime('now')),
56
- exited_at TEXT
57
- )`,
58
- )
59
- legacy.run("INSERT INTO columns (id, name, position) VALUES ('c_backlog', 'backlog', 0)")
60
- legacy.close()
69
+ await withTempDb(async (dbPath) => {
70
+ const legacy = new Database(dbPath)
71
+ legacy.run(
72
+ `CREATE TABLE columns (
73
+ id TEXT PRIMARY KEY,
74
+ name TEXT UNIQUE NOT NULL,
75
+ position INTEGER NOT NULL,
76
+ color TEXT,
77
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
78
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
79
+ )`,
80
+ )
81
+ legacy.run(
82
+ `CREATE TABLE tasks (
83
+ id TEXT PRIMARY KEY,
84
+ title TEXT NOT NULL,
85
+ description TEXT NOT NULL DEFAULT '',
86
+ column_id TEXT NOT NULL,
87
+ position INTEGER NOT NULL DEFAULT 0,
88
+ priority TEXT NOT NULL DEFAULT 'medium',
89
+ assignee TEXT NOT NULL DEFAULT '',
90
+ metadata TEXT NOT NULL DEFAULT '{}',
91
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
92
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
93
+ )`,
94
+ )
95
+ legacy.run(
96
+ `CREATE TABLE activity_log (
97
+ id TEXT PRIMARY KEY,
98
+ task_id TEXT NOT NULL,
99
+ action TEXT NOT NULL,
100
+ field_changed TEXT,
101
+ old_value TEXT,
102
+ new_value TEXT,
103
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
104
+ )`,
105
+ )
106
+ legacy.run(
107
+ `CREATE TABLE column_time_tracking (
108
+ id TEXT PRIMARY KEY,
109
+ task_id TEXT NOT NULL,
110
+ column_id TEXT NOT NULL,
111
+ entered_at TEXT NOT NULL DEFAULT (datetime('now')),
112
+ exited_at TEXT
113
+ )`,
114
+ )
115
+ legacy.run("INSERT INTO columns (id, name, position) VALUES ('c_backlog', 'backlog', 0)")
116
+ legacy.close()
61
117
 
62
- try {
63
118
  const result = await run(['--db', dbPath, 'task', 'add', 'Migrated task'])
64
119
  expect(result.exitCode).toBe(0)
65
120
  expect(result.output.ok).toBe(true)
@@ -68,8 +123,130 @@ describe('run', () => {
68
123
  const columns = verify.query('PRAGMA table_info(tasks)').all() as { name: string }[]
69
124
  expect(columns.some((column) => column.name === 'project')).toBe(true)
70
125
  verify.close()
71
- } finally {
72
- rmSync(dir, { recursive: true, force: true })
73
- }
126
+ })
127
+ })
128
+
129
+ test('runs task lifecycle commands through the real CLI path', async () => {
130
+ await withTempDb(async (dbPath) => {
131
+ const created = expectOk<Task>(
132
+ await run([
133
+ '--db',
134
+ dbPath,
135
+ 'task',
136
+ 'add',
137
+ 'Build feature',
138
+ '-d',
139
+ 'Do the thing',
140
+ '-c',
141
+ 'recurring',
142
+ '-p',
143
+ 'high',
144
+ '-a',
145
+ 'alice',
146
+ '--project',
147
+ 'Platform',
148
+ '-m',
149
+ '{"sprint":5}',
150
+ ]),
151
+ )
152
+ expect(created.title).toBe('Build feature')
153
+ expect(created.description).toBe('Do the thing')
154
+ expect(created.priority).toBe('high')
155
+ expect(created.assignee).toBe('alice')
156
+ expect(created.project).toBe('Platform')
157
+ expect(created.metadata).toBe('{"sprint":5}')
158
+
159
+ const listed = expectOk<Task[]>(
160
+ await run(['--db', dbPath, 'task', 'list', '-c', 'recurring']),
161
+ )
162
+ expect(listed).toHaveLength(1)
163
+ expect(listed[0]!.id).toBe(created.id)
164
+
165
+ const viewed = expectOk<Task>(await run(['--db', dbPath, 'task', 'view', created.id]))
166
+ expect(viewed.id).toBe(created.id)
167
+
168
+ const updated = expectOk<Task>(
169
+ await run([
170
+ '--db',
171
+ dbPath,
172
+ 'task',
173
+ 'update',
174
+ created.id,
175
+ '--title',
176
+ 'Modified feature',
177
+ '-d',
178
+ 'Ship it',
179
+ '-p',
180
+ 'urgent',
181
+ '-a',
182
+ 'bob',
183
+ '--project',
184
+ 'Infra',
185
+ '-m',
186
+ '{"sprint":6}',
187
+ ]),
188
+ )
189
+ expect(updated.title).toBe('Modified feature')
190
+ expect(updated.description).toBe('Ship it')
191
+ expect(updated.priority).toBe('urgent')
192
+ expect(updated.assignee).toBe('bob')
193
+ expect(updated.project).toBe('Infra')
194
+ expect(updated.metadata).toBe('{"sprint":6}')
195
+
196
+ const moved = expectOk<Task>(
197
+ await run(['--db', dbPath, 'task', 'move', created.id, 'in-progress']),
198
+ )
199
+ expect(moved.column_id).toBeTruthy()
200
+
201
+ const assigned = expectOk<Task>(
202
+ await run(['--db', dbPath, 'task', 'assign', created.id, 'carol']),
203
+ )
204
+ expect(assigned.assignee).toBe('carol')
205
+
206
+ const prioritized = expectOk<Task>(
207
+ await run(['--db', dbPath, 'task', 'prioritize', created.id, 'low']),
208
+ )
209
+ expect(prioritized.priority).toBe('low')
210
+
211
+ const deleted = expectOk<Task>(await run(['--db', dbPath, 'task', 'delete', created.id]))
212
+ expect(deleted.id).toBe(created.id)
213
+
214
+ await expectKanbanError(
215
+ run(['--db', dbPath, 'task', 'view', created.id]),
216
+ ErrorCode.TASK_NOT_FOUND,
217
+ )
218
+ })
219
+ })
220
+
221
+ test('lists and filters tasks through the CLI path', async () => {
222
+ await withTempDb(async (dbPath) => {
223
+ expectOk<Task>(await run(['--db', dbPath, 'task', 'add', 'A', '-c', 'recurring']))
224
+ expectOk<Task>(await run(['--db', dbPath, 'task', 'add', 'B', '-c', 'done']))
225
+
226
+ const allTasks = expectOk<Task[]>(await run(['--db', dbPath, 'task', 'list']))
227
+ expect(allTasks).toHaveLength(2)
228
+
229
+ const recurring = expectOk<Task[]>(
230
+ await run(['--db', dbPath, 'task', 'list', '-c', 'recurring']),
231
+ )
232
+ expect(recurring).toHaveLength(1)
233
+ expect(recurring[0]!.title).toBe('A')
234
+ })
235
+ })
236
+
237
+ test('raises CLI errors for missing task arguments', async () => {
238
+ await withTempDb(async (dbPath) => {
239
+ const missingTitle = await expectKanbanError(
240
+ run(['--db', dbPath, 'task', 'add']),
241
+ ErrorCode.MISSING_ARGUMENT,
242
+ )
243
+ expect(missingTitle.message).toContain('Task title is required')
244
+
245
+ const missingId = await expectKanbanError(
246
+ run(['--db', dbPath, 'task', 'view']),
247
+ ErrorCode.MISSING_ARGUMENT,
248
+ )
249
+ expect(missingId.message).toContain('Task ID is required')
250
+ })
74
251
  })
75
252
  })
@@ -0,0 +1,168 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { adfToPlainText, plainTextToAdf, type AdfDocument } from '../providers/jira-adf.ts'
3
+
4
+ describe('plainTextToAdf / adfToPlainText', () => {
5
+ test('empty doc round-trip', () => {
6
+ const doc = plainTextToAdf('')
7
+ expect(doc).toEqual({ version: 1, type: 'doc', content: [] })
8
+ expect(adfToPlainText(doc)).toBe('')
9
+ })
10
+
11
+ test('single paragraph round-trip', () => {
12
+ const input = 'hello world'
13
+ const doc = plainTextToAdf(input)
14
+ expect(doc).toEqual({
15
+ version: 1,
16
+ type: 'doc',
17
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello world' }] }],
18
+ })
19
+ expect(adfToPlainText(doc)).toBe(input)
20
+ })
21
+
22
+ test('multi-paragraph round-trip (blank-line separated)', () => {
23
+ const input = 'first paragraph\n\nsecond paragraph\n\nthird'
24
+ const doc = plainTextToAdf(input)
25
+ expect(doc.content).toHaveLength(3)
26
+ expect(doc.content[0]).toEqual({
27
+ type: 'paragraph',
28
+ content: [{ type: 'text', text: 'first paragraph' }],
29
+ })
30
+ expect(adfToPlainText(doc)).toBe(input)
31
+ })
32
+
33
+ test('bullet list round-trip (- and * both accepted, - emitted)', () => {
34
+ const input = '- one\n* two\n- three'
35
+ const doc = plainTextToAdf(input)
36
+ expect(doc.content).toHaveLength(1)
37
+ const list = doc.content[0] as { type: string; content: unknown[] }
38
+ expect(list.type).toBe('bulletList')
39
+ expect(list.content).toHaveLength(3)
40
+ // Output always uses `- `.
41
+ expect(adfToPlainText(doc)).toBe('- one\n- two\n- three')
42
+ })
43
+
44
+ test('ordered list round-trip starting at 1', () => {
45
+ const input = '1. first\n2. second\n3. third'
46
+ const doc = plainTextToAdf(input)
47
+ const list = doc.content[0] as {
48
+ type: string
49
+ attrs?: { order?: number }
50
+ content: unknown[]
51
+ }
52
+ expect(list.type).toBe('orderedList')
53
+ expect(list.attrs).toBeUndefined()
54
+ expect(list.content).toHaveLength(3)
55
+ expect(adfToPlainText(doc)).toBe(input)
56
+ })
57
+
58
+ test('ordered list preserves non-default attrs.order', () => {
59
+ const input = '5. fifth\n6. sixth'
60
+ const doc = plainTextToAdf(input)
61
+ const list = doc.content[0] as {
62
+ type: string
63
+ attrs?: { order?: number }
64
+ }
65
+ expect(list.type).toBe('orderedList')
66
+ expect(list.attrs?.order).toBe(5)
67
+ expect(adfToPlainText(doc)).toBe(input)
68
+ })
69
+
70
+ test('fenced code block round-trip without language', () => {
71
+ const input = '```\nconst x = 1\nconst y = 2\n```'
72
+ const doc = plainTextToAdf(input)
73
+ const code = doc.content[0] as {
74
+ type: string
75
+ attrs?: { language?: string }
76
+ }
77
+ expect(code.type).toBe('codeBlock')
78
+ expect(code.attrs).toBeUndefined()
79
+ expect(adfToPlainText(doc)).toBe(input)
80
+ })
81
+
82
+ test('fenced code block round-trip with language tag', () => {
83
+ const input = '```ts\nconst x: number = 1\n```'
84
+ const doc = plainTextToAdf(input)
85
+ const code = doc.content[0] as {
86
+ type: string
87
+ attrs?: { language?: string }
88
+ }
89
+ expect(code.type).toBe('codeBlock')
90
+ expect(code.attrs?.language).toBe('ts')
91
+ expect(adfToPlainText(doc)).toBe(input)
92
+ })
93
+
94
+ test('mixed content (paragraph + list + code block) round-trip', () => {
95
+ const input = 'intro paragraph\n\n- a\n- b\n\n```js\nconsole.log(1)\n```\n\nouttro'
96
+ const doc = plainTextToAdf(input)
97
+ expect(doc.content).toHaveLength(4)
98
+ expect(doc.content[0]?.type).toBe('paragraph')
99
+ expect(doc.content[1]?.type).toBe('bulletList')
100
+ expect(doc.content[2]?.type).toBe('codeBlock')
101
+ expect(doc.content[3]?.type).toBe('paragraph')
102
+ expect(adfToPlainText(doc)).toBe(input)
103
+ })
104
+
105
+ test('heading flattened to plain text (read path)', () => {
106
+ const doc: AdfDocument = {
107
+ version: 1,
108
+ type: 'doc',
109
+ content: [
110
+ {
111
+ type: 'heading',
112
+ attrs: { level: 2 },
113
+ content: [{ type: 'text', text: 'Big Title' }],
114
+ },
115
+ {
116
+ type: 'paragraph',
117
+ content: [{ type: 'text', text: 'body' }],
118
+ },
119
+ ],
120
+ }
121
+ // Heading renders as bare inner text (no `#` prefix).
122
+ expect(adfToPlainText(doc)).toBe('Big Title\n\nbody')
123
+ })
124
+
125
+ test('unknown block node type is gracefully skipped, no throw', () => {
126
+ const doc: AdfDocument = {
127
+ version: 1,
128
+ type: 'doc',
129
+ content: [
130
+ {
131
+ type: 'paragraph',
132
+ content: [{ type: 'text', text: 'before' }],
133
+ },
134
+ { type: 'mediaSingle', attrs: { layout: 'center' } },
135
+ {
136
+ type: 'paragraph',
137
+ content: [{ type: 'text', text: 'after' }],
138
+ },
139
+ ],
140
+ }
141
+ expect(() => adfToPlainText(doc)).not.toThrow()
142
+ expect(adfToPlainText(doc)).toBe('before\n\nafter')
143
+ })
144
+
145
+ test('inline text marks are stripped on read', () => {
146
+ const doc: AdfDocument = {
147
+ version: 1,
148
+ type: 'doc',
149
+ content: [
150
+ {
151
+ type: 'paragraph',
152
+ content: [
153
+ { type: 'text', text: 'bold', marks: [{ type: 'strong' }] },
154
+ { type: 'text', text: ' normal' },
155
+ ],
156
+ },
157
+ ],
158
+ }
159
+ expect(adfToPlainText(doc)).toBe('bold normal')
160
+ })
161
+
162
+ test('bullet item containing digits-dot substring is still a bullet', () => {
163
+ const input = '- item containing 1. something'
164
+ const doc = plainTextToAdf(input)
165
+ expect(doc.content[0]?.type).toBe('bulletList')
166
+ expect(adfToPlainText(doc)).toBe(input)
167
+ })
168
+ })