@andypai/agent-kanban 0.2.0 → 0.3.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.
Files changed (72) hide show
  1. package/README.md +120 -24
  2. package/package.json +4 -2
  3. package/src/__tests__/activity.test.ts +16 -10
  4. package/src/__tests__/api.test.ts +99 -3
  5. package/src/__tests__/board-utils.test.ts +100 -0
  6. package/src/__tests__/commands/board.test.ts +7 -14
  7. package/src/__tests__/commands/bulk.test.ts +3 -3
  8. package/src/__tests__/commands/column.test.ts +4 -4
  9. package/src/__tests__/conflict.test.ts +64 -0
  10. package/src/__tests__/db.test.ts +2 -2
  11. package/src/__tests__/id.test.ts +1 -1
  12. package/src/__tests__/index.test.ts +233 -56
  13. package/src/__tests__/jira-adf.test.ts +180 -0
  14. package/src/__tests__/jira-cache.test.ts +304 -0
  15. package/src/__tests__/jira-client.test.ts +169 -0
  16. package/src/__tests__/jira-provider-comment.test.ts +281 -0
  17. package/src/__tests__/jira-provider-mutations.test.ts +771 -0
  18. package/src/__tests__/jira-provider-read.test.ts +594 -0
  19. package/src/__tests__/jira-wiring.test.ts +187 -0
  20. package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
  21. package/src/__tests__/linear-provider-comment.test.ts +243 -0
  22. package/src/__tests__/linear-provider-sync.test.ts +488 -0
  23. package/src/__tests__/local-provider-comment.test.ts +60 -0
  24. package/src/__tests__/mcp-core.test.ts +164 -0
  25. package/src/__tests__/mcp-server.test.ts +252 -0
  26. package/src/__tests__/metrics.test.ts +2 -2
  27. package/src/__tests__/output.test.ts +1 -1
  28. package/src/__tests__/provider-capabilities.test.ts +40 -0
  29. package/src/__tests__/server.test.ts +291 -0
  30. package/src/__tests__/webhooks.test.ts +604 -0
  31. package/src/activity.ts +2 -12
  32. package/src/api.ts +156 -21
  33. package/src/commands/board.ts +4 -14
  34. package/src/commands/bulk.ts +4 -4
  35. package/src/commands/column.ts +4 -4
  36. package/src/commands/mcp.ts +87 -0
  37. package/src/config.ts +1 -1
  38. package/src/db.ts +118 -6
  39. package/src/errors.ts +2 -0
  40. package/src/id.ts +1 -1
  41. package/src/index.ts +83 -35
  42. package/src/mcp/core.ts +193 -0
  43. package/src/mcp/errors.ts +109 -0
  44. package/src/mcp/index.ts +13 -0
  45. package/src/mcp/server.ts +512 -0
  46. package/src/mcp/types.ts +72 -0
  47. package/src/metrics.ts +1 -1
  48. package/src/output.ts +1 -1
  49. package/src/providers/capabilities.ts +22 -17
  50. package/src/providers/errors.ts +1 -1
  51. package/src/providers/index.ts +36 -6
  52. package/src/providers/jira-adf.ts +275 -0
  53. package/src/providers/jira-cache.ts +625 -0
  54. package/src/providers/jira-client.ts +390 -0
  55. package/src/providers/jira.ts +773 -0
  56. package/src/providers/linear-cache.ts +250 -71
  57. package/src/providers/linear-client.ts +255 -15
  58. package/src/providers/linear.ts +338 -20
  59. package/src/providers/local.ts +74 -23
  60. package/src/providers/types.ts +19 -3
  61. package/src/server.ts +141 -13
  62. package/src/tunnel.ts +79 -0
  63. package/src/types.ts +19 -2
  64. package/src/webhooks.ts +36 -0
  65. package/ui/dist/assets/index-DBnoKL_k.css +1 -0
  66. package/ui/dist/assets/index-qNVJ6clH.js +40 -0
  67. package/ui/dist/index.html +2 -2
  68. package/src/__tests__/commands/task.test.ts +0 -144
  69. package/src/commands/task.ts +0 -117
  70. package/src/fixtures.ts +0 -128
  71. package/ui/dist/assets/index-B8f9NB4z.css +0 -1
  72. package/ui/dist/assets/index-zWp-rB7b.js +0 -40
@@ -1,12 +1,14 @@
1
1
  import type { Database } from 'bun:sqlite'
2
- import { getDbPath, initSchema, seedDefaultColumns } from '../db.ts'
3
- import { providerNotConfigured } from './errors.ts'
4
- import { LinearProvider } from './linear.ts'
5
- import { LocalProvider } from './local.ts'
6
- import type { KanbanProvider } from './types.ts'
2
+ import { getDbPath, initSchema, seedDefaultColumns } from '../db'
3
+ import { providerNotConfigured } from './errors'
4
+ import { JiraProvider } from './jira'
5
+ import { LinearProvider } from './linear'
6
+ import { LocalProvider } from './local'
7
+ import type { KanbanProvider } from './types'
7
8
 
8
9
  export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvider {
9
- const providerType = (process.env['KANBAN_PROVIDER'] ?? 'local') as 'local' | 'linear'
10
+ const providerType = (process.env['KANBAN_PROVIDER'] ?? 'local') as 'local' | 'linear' | 'jira'
11
+
10
12
  if (providerType === 'linear') {
11
13
  const apiKey = process.env['LINEAR_API_KEY']
12
14
  const teamId = process.env['LINEAR_TEAM_ID']
@@ -18,6 +20,34 @@ export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvid
18
20
  return new LinearProvider(db, teamId!, apiKey!)
19
21
  }
20
22
 
23
+ if (providerType === 'jira') {
24
+ const baseUrl = process.env['JIRA_BASE_URL']
25
+ const email = process.env['JIRA_EMAIL']
26
+ const apiToken = process.env['JIRA_API_TOKEN']
27
+ const projectKey = process.env['JIRA_PROJECT_KEY']
28
+ const missing: string[] = []
29
+ if (!baseUrl) missing.push('JIRA_BASE_URL')
30
+ if (!email) missing.push('JIRA_EMAIL')
31
+ if (!apiToken) missing.push('JIRA_API_TOKEN')
32
+ if (!projectKey) missing.push('JIRA_PROJECT_KEY')
33
+ if (missing.length > 0) {
34
+ providerNotConfigured(
35
+ `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} required when KANBAN_PROVIDER=jira`,
36
+ )
37
+ }
38
+ const boardIdRaw = process.env['JIRA_BOARD_ID']
39
+ const boardId = boardIdRaw ? Number.parseInt(boardIdRaw, 10) : undefined
40
+ const defaultIssueType = process.env['JIRA_ISSUE_TYPE'] ?? 'Task'
41
+ return new JiraProvider(db, {
42
+ baseUrl: baseUrl!,
43
+ email: email!,
44
+ apiToken: apiToken!,
45
+ projectKey: projectKey!,
46
+ boardId: Number.isFinite(boardId) ? boardId : undefined,
47
+ defaultIssueType,
48
+ })
49
+ }
50
+
21
51
  initSchema(db)
22
52
  seedDefaultColumns(db)
23
53
  return new LocalProvider(db, dbPath)
@@ -0,0 +1,275 @@
1
+ // Pure ADF (Atlassian Document Format) translation helpers.
2
+ //
3
+ // This module is intentionally dependency-free: no imports from the rest of the
4
+ // provider stack, no bun:sqlite, no fetch. It models only the subset of ADF
5
+ // that agent-kanban round-trips through plain text:
6
+ //
7
+ // doc > { paragraph | bulletList | orderedList | codeBlock | heading }
8
+ // paragraph / heading / codeBlock > text(inline)
9
+ // bulletList / orderedList > listItem > paragraph > text
10
+ //
11
+ // Unknown node types are tolerated on the read path (skipped silently) and
12
+ // never emitted on the write path.
13
+
14
+ export interface AdfDocument {
15
+ version: 1
16
+ type: 'doc'
17
+ content: AdfBlockNode[]
18
+ }
19
+
20
+ export interface AdfTextNode {
21
+ type: 'text'
22
+ text: string
23
+ marks?: unknown[]
24
+ }
25
+
26
+ export interface AdfUnknownInlineNode {
27
+ type: string
28
+ [key: string]: unknown
29
+ }
30
+
31
+ export type AdfInlineNode = AdfTextNode | AdfUnknownInlineNode
32
+
33
+ export interface AdfParagraphNode {
34
+ type: 'paragraph'
35
+ content?: AdfInlineNode[]
36
+ }
37
+
38
+ export interface AdfListItemNode {
39
+ type: 'listItem'
40
+ content: AdfBlockNode[]
41
+ }
42
+
43
+ export interface AdfBulletListNode {
44
+ type: 'bulletList'
45
+ content: AdfListItemNode[]
46
+ }
47
+
48
+ export interface AdfOrderedListNode {
49
+ type: 'orderedList'
50
+ content: AdfListItemNode[]
51
+ attrs?: { order?: number }
52
+ }
53
+
54
+ export interface AdfCodeBlockNode {
55
+ type: 'codeBlock'
56
+ attrs?: { language?: string }
57
+ content?: AdfInlineNode[]
58
+ }
59
+
60
+ export interface AdfHeadingNode {
61
+ type: 'heading'
62
+ attrs: { level: number }
63
+ content?: AdfInlineNode[]
64
+ }
65
+
66
+ export interface AdfUnknownBlockNode {
67
+ type: string
68
+ [key: string]: unknown
69
+ }
70
+
71
+ export type AdfBlockNode =
72
+ | AdfParagraphNode
73
+ | AdfBulletListNode
74
+ | AdfOrderedListNode
75
+ | AdfCodeBlockNode
76
+ | AdfHeadingNode
77
+ | AdfUnknownBlockNode
78
+
79
+ // Public AdfNode union covers every node shape this module recognizes.
80
+ export type AdfNode = AdfDocument | AdfBlockNode | AdfListItemNode | AdfInlineNode
81
+
82
+ const BULLET_MARKER = /^[-*] (.*)$/
83
+ const ORDERED_MARKER = /^(\d+)\. (.*)$/
84
+ // Opening/closing fence: `` ``` `` optionally followed by a language tag with
85
+ // no whitespace before it. Fence must occupy the whole line.
86
+ const FENCE_OPEN = /^```([^\s`]*)$/
87
+ const FENCE_CLOSE = /^```$/
88
+
89
+ function paragraphFromText(text: string): AdfParagraphNode {
90
+ if (text.length === 0) {
91
+ return { type: 'paragraph' }
92
+ }
93
+ return {
94
+ type: 'paragraph',
95
+ content: [{ type: 'text', text }],
96
+ }
97
+ }
98
+
99
+ function listItemFromText(text: string): AdfListItemNode {
100
+ return {
101
+ type: 'listItem',
102
+ content: [paragraphFromText(text)],
103
+ }
104
+ }
105
+
106
+ export function plainTextToAdf(text: string): AdfDocument {
107
+ if (text.length === 0) {
108
+ return { version: 1, type: 'doc', content: [] }
109
+ }
110
+
111
+ const lines = text.split('\n')
112
+ const blocks: AdfBlockNode[] = []
113
+
114
+ let i = 0
115
+ while (i < lines.length) {
116
+ const line = lines[i] ?? ''
117
+
118
+ // Blank line separates blocks — consume and move on.
119
+ if (line === '') {
120
+ i += 1
121
+ continue
122
+ }
123
+
124
+ // Fenced code block.
125
+ const fenceOpen = line.match(FENCE_OPEN)
126
+ if (fenceOpen) {
127
+ const language = fenceOpen[1] ?? ''
128
+ const codeLines: string[] = []
129
+ let j = i + 1
130
+ let closed = false
131
+ while (j < lines.length) {
132
+ const inner = lines[j] ?? ''
133
+ if (FENCE_CLOSE.test(inner)) {
134
+ closed = true
135
+ break
136
+ }
137
+ codeLines.push(inner)
138
+ j += 1
139
+ }
140
+ if (closed) {
141
+ const node: AdfCodeBlockNode = { type: 'codeBlock' }
142
+ if (language.length > 0) {
143
+ node.attrs = { language }
144
+ }
145
+ const code = codeLines.join('\n')
146
+ if (code.length > 0) {
147
+ node.content = [{ type: 'text', text: code }]
148
+ }
149
+ blocks.push(node)
150
+ i = j + 1
151
+ continue
152
+ }
153
+ // Unterminated fence — fall through and treat as a paragraph.
154
+ }
155
+
156
+ // Bullet list run.
157
+ if (BULLET_MARKER.test(line)) {
158
+ const items: AdfListItemNode[] = []
159
+ while (i < lines.length) {
160
+ const current = lines[i] ?? ''
161
+ const match = current.match(BULLET_MARKER)
162
+ if (!match) break
163
+ items.push(listItemFromText(match[1] ?? ''))
164
+ i += 1
165
+ }
166
+ blocks.push({ type: 'bulletList', content: items })
167
+ continue
168
+ }
169
+
170
+ // Ordered list run.
171
+ const orderedFirst = line.match(ORDERED_MARKER)
172
+ if (orderedFirst) {
173
+ const items: AdfListItemNode[] = []
174
+ const firstNumber = Number.parseInt(orderedFirst[1] ?? '1', 10)
175
+ items.push(listItemFromText(orderedFirst[2] ?? ''))
176
+ i += 1
177
+ while (i < lines.length) {
178
+ const current = lines[i] ?? ''
179
+ const match = current.match(ORDERED_MARKER)
180
+ if (!match) break
181
+ items.push(listItemFromText(match[2] ?? ''))
182
+ i += 1
183
+ }
184
+ const node: AdfOrderedListNode = { type: 'orderedList', content: items }
185
+ if (firstNumber !== 1) {
186
+ node.attrs = { order: firstNumber }
187
+ }
188
+ blocks.push(node)
189
+ continue
190
+ }
191
+
192
+ // Paragraph: consume until blank line or a block-starting line.
193
+ const paragraphLines: string[] = [line]
194
+ i += 1
195
+ while (i < lines.length) {
196
+ const current = lines[i] ?? ''
197
+ if (
198
+ current === '' ||
199
+ BULLET_MARKER.test(current) ||
200
+ ORDERED_MARKER.test(current) ||
201
+ FENCE_OPEN.test(current)
202
+ ) {
203
+ break
204
+ }
205
+ paragraphLines.push(current)
206
+ i += 1
207
+ }
208
+ blocks.push(paragraphFromText(paragraphLines.join('\n')))
209
+ }
210
+
211
+ return { version: 1, type: 'doc', content: blocks }
212
+ }
213
+
214
+ function inlineText(nodes: AdfInlineNode[] | undefined): string {
215
+ if (!nodes) return ''
216
+ let out = ''
217
+ for (const node of nodes) {
218
+ if (node.type === 'text') {
219
+ out += (node as AdfTextNode).text
220
+ }
221
+ // Unknown inline nodes (mentions, emoji, hardBreak, etc.) are skipped.
222
+ }
223
+ return out
224
+ }
225
+
226
+ function listItemInnerText(item: AdfListItemNode): string {
227
+ // Each list item wraps a paragraph (or nested blocks). We flatten to the
228
+ // first paragraph's inline text, which is all the write path produces.
229
+ for (const child of item.content) {
230
+ if (child.type === 'paragraph') {
231
+ return inlineText((child as AdfParagraphNode).content)
232
+ }
233
+ }
234
+ return ''
235
+ }
236
+
237
+ function renderBlock(node: AdfBlockNode): string | null {
238
+ switch (node.type) {
239
+ case 'paragraph':
240
+ return inlineText((node as AdfParagraphNode).content)
241
+ case 'bulletList': {
242
+ const list = node as AdfBulletListNode
243
+ const lines = list.content.map((item) => `- ${listItemInnerText(item)}`)
244
+ return lines.join('\n')
245
+ }
246
+ case 'orderedList': {
247
+ const list = node as AdfOrderedListNode
248
+ const start = list.attrs?.order ?? 1
249
+ const lines = list.content.map((item, idx) => `${start + idx}. ${listItemInnerText(item)}`)
250
+ return lines.join('\n')
251
+ }
252
+ case 'codeBlock': {
253
+ const code = node as AdfCodeBlockNode
254
+ const language = code.attrs?.language ?? ''
255
+ const body = inlineText(code.content)
256
+ const fence = language.length > 0 ? `\`\`\`${language}` : '```'
257
+ return `${fence}\n${body}\n\`\`\``
258
+ }
259
+ case 'heading':
260
+ return inlineText((node as AdfHeadingNode).content)
261
+ default:
262
+ // Unknown block node — skip entirely, never throw.
263
+ return null
264
+ }
265
+ }
266
+
267
+ export function adfToPlainText(doc: AdfDocument): string {
268
+ const rendered: string[] = []
269
+ for (const block of doc.content) {
270
+ const text = renderBlock(block)
271
+ if (text === null) continue
272
+ rendered.push(text)
273
+ }
274
+ return rendered.join('\n\n')
275
+ }