@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.
- package/README.md +120 -24
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +16 -10
- package/src/__tests__/api.test.ts +99 -3
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +7 -14
- package/src/__tests__/commands/bulk.test.ts +3 -3
- package/src/__tests__/commands/column.test.ts +4 -4
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/db.test.ts +2 -2
- package/src/__tests__/id.test.ts +1 -1
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +180 -0
- package/src/__tests__/jira-cache.test.ts +304 -0
- package/src/__tests__/jira-client.test.ts +169 -0
- package/src/__tests__/jira-provider-comment.test.ts +281 -0
- package/src/__tests__/jira-provider-mutations.test.ts +771 -0
- package/src/__tests__/jira-provider-read.test.ts +594 -0
- package/src/__tests__/jira-wiring.test.ts +187 -0
- package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
- package/src/__tests__/linear-provider-comment.test.ts +243 -0
- package/src/__tests__/linear-provider-sync.test.ts +488 -0
- package/src/__tests__/local-provider-comment.test.ts +60 -0
- package/src/__tests__/mcp-core.test.ts +164 -0
- package/src/__tests__/mcp-server.test.ts +252 -0
- package/src/__tests__/metrics.test.ts +2 -2
- package/src/__tests__/output.test.ts +1 -1
- package/src/__tests__/provider-capabilities.test.ts +40 -0
- package/src/__tests__/server.test.ts +291 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +2 -12
- package/src/api.ts +156 -21
- package/src/commands/board.ts +4 -14
- package/src/commands/bulk.ts +4 -4
- package/src/commands/column.ts +4 -4
- package/src/commands/mcp.ts +87 -0
- package/src/config.ts +1 -1
- package/src/db.ts +118 -6
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +83 -35
- package/src/mcp/core.ts +193 -0
- package/src/mcp/errors.ts +109 -0
- package/src/mcp/index.ts +13 -0
- package/src/mcp/server.ts +512 -0
- package/src/mcp/types.ts +72 -0
- package/src/metrics.ts +1 -1
- package/src/output.ts +1 -1
- package/src/providers/capabilities.ts +22 -17
- package/src/providers/errors.ts +1 -1
- package/src/providers/index.ts +36 -6
- package/src/providers/jira-adf.ts +275 -0
- package/src/providers/jira-cache.ts +625 -0
- package/src/providers/jira-client.ts +390 -0
- package/src/providers/jira.ts +773 -0
- package/src/providers/linear-cache.ts +250 -71
- package/src/providers/linear-client.ts +255 -15
- package/src/providers/linear.ts +338 -20
- package/src/providers/local.ts +74 -23
- package/src/providers/types.ts +19 -3
- package/src/server.ts +141 -13
- package/src/tunnel.ts +79 -0
- package/src/types.ts +19 -2
- package/src/webhooks.ts +36 -0
- package/ui/dist/assets/index-DBnoKL_k.css +1 -0
- package/ui/dist/assets/index-qNVJ6clH.js +40 -0
- package/ui/dist/index.html +2 -2
- package/src/__tests__/commands/task.test.ts +0 -144
- package/src/commands/task.ts +0 -117
- package/src/fixtures.ts +0 -128
- package/ui/dist/assets/index-B8f9NB4z.css +0 -1
- package/ui/dist/assets/index-zWp-rB7b.js +0 -40
package/src/providers/index.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite'
|
|
2
|
-
import { getDbPath, initSchema, seedDefaultColumns } from '../db
|
|
3
|
-
import { providerNotConfigured } from './errors
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
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
|
+
}
|