@andypai/agent-kanban 0.3.0 → 0.3.2

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 +34 -5
  2. package/package.json +1 -1
  3. package/src/__tests__/activity.test.ts +2 -2
  4. package/src/__tests__/api.test.ts +3 -3
  5. package/src/__tests__/commands/board.test.ts +3 -3
  6. package/src/__tests__/commands/bulk.test.ts +3 -3
  7. package/src/__tests__/commands/column.test.ts +4 -4
  8. package/src/__tests__/conflict.test.ts +3 -3
  9. package/src/__tests__/db.test.ts +2 -2
  10. package/src/__tests__/id.test.ts +1 -1
  11. package/src/__tests__/index.test.ts +3 -3
  12. package/src/__tests__/jira-adf.test.ts +270 -4
  13. package/src/__tests__/jira-cache.test.ts +1 -1
  14. package/src/__tests__/jira-client.test.ts +2 -2
  15. package/src/__tests__/jira-provider-comment.test.ts +3 -3
  16. package/src/__tests__/jira-provider-mutations.test.ts +4 -4
  17. package/src/__tests__/jira-provider-read.test.ts +52 -6
  18. package/src/__tests__/jira-wiring.test.ts +3 -3
  19. package/src/__tests__/linear-cache-description-activity.test.ts +1 -1
  20. package/src/__tests__/linear-provider-comment.test.ts +2 -2
  21. package/src/__tests__/linear-provider-sync.test.ts +4 -9
  22. package/src/__tests__/local-provider-comment.test.ts +2 -2
  23. package/src/__tests__/mcp-core.test.ts +4 -4
  24. package/src/__tests__/mcp-server.test.ts +3 -3
  25. package/src/__tests__/metrics.test.ts +2 -2
  26. package/src/__tests__/output.test.ts +1 -1
  27. package/src/__tests__/provider-capabilities.test.ts +40 -0
  28. package/src/__tests__/server.test.ts +3 -10
  29. package/src/__tests__/webhooks.test.ts +6 -6
  30. package/src/activity.ts +2 -2
  31. package/src/api.ts +3 -3
  32. package/src/commands/board.ts +4 -4
  33. package/src/commands/bulk.ts +4 -4
  34. package/src/commands/column.ts +4 -4
  35. package/src/commands/mcp.ts +3 -3
  36. package/src/config.ts +1 -1
  37. package/src/db.ts +4 -4
  38. package/src/index.ts +13 -19
  39. package/src/mcp/core.ts +4 -4
  40. package/src/mcp/errors.ts +1 -1
  41. package/src/mcp/index.ts +6 -6
  42. package/src/mcp/server.ts +3 -3
  43. package/src/mcp/types.ts +2 -2
  44. package/src/metrics.ts +1 -1
  45. package/src/output.ts +1 -1
  46. package/src/providers/capabilities.ts +21 -31
  47. package/src/providers/errors.ts +1 -1
  48. package/src/providers/index.ts +6 -6
  49. package/src/providers/jira-adf.ts +116 -12
  50. package/src/providers/jira-cache.ts +1 -1
  51. package/src/providers/jira-client.ts +2 -2
  52. package/src/providers/jira.ts +9 -14
  53. package/src/providers/linear-cache.ts +1 -1
  54. package/src/providers/linear-client.ts +3 -6
  55. package/src/providers/linear.ts +8 -13
  56. package/src/providers/local.ts +8 -8
  57. package/src/providers/types.ts +2 -2
  58. package/src/server.ts +2 -2
  59. package/src/types.ts +1 -0
@@ -2,14 +2,14 @@
2
2
  //
3
3
  // This module is intentionally dependency-free: no imports from the rest of the
4
4
  // provider stack, no bun:sqlite, no fetch. It models only the subset of ADF
5
- // that agent-kanban round-trips through plain text:
5
+ // that agent-kanban writes, plus a few Jira nodes it must preserve on read:
6
6
  //
7
- // doc > { paragraph | bulletList | orderedList | codeBlock | heading }
8
- // paragraph / heading / codeBlock > text(inline)
7
+ // doc > { paragraph | bulletList | orderedList | codeBlock | heading | card }
8
+ // paragraph / heading / codeBlock > text(inline) | inlineCard | hardBreak
9
9
  // bulletList / orderedList > listItem > paragraph > text
10
10
  //
11
- // Unknown node types are tolerated on the read path (skipped silently) and
12
- // never emitted on the write path.
11
+ // Other unknown node types are tolerated on the read path (skipped silently)
12
+ // and never emitted on the write path.
13
13
 
14
14
  export interface AdfDocument {
15
15
  version: 1
@@ -17,10 +17,15 @@ export interface AdfDocument {
17
17
  content: AdfBlockNode[]
18
18
  }
19
19
 
20
+ export interface AdfMark {
21
+ type: string
22
+ attrs?: Record<string, unknown>
23
+ }
24
+
20
25
  export interface AdfTextNode {
21
26
  type: 'text'
22
27
  text: string
23
- marks?: unknown[]
28
+ marks?: AdfMark[]
24
29
  }
25
30
 
26
31
  export interface AdfUnknownInlineNode {
@@ -92,7 +97,7 @@ function paragraphFromText(text: string): AdfParagraphNode {
92
97
  }
93
98
  return {
94
99
  type: 'paragraph',
95
- content: [{ type: 'text', text }],
100
+ content: tokenizeInline(text),
96
101
  }
97
102
  }
98
103
 
@@ -103,6 +108,39 @@ function listItemFromText(text: string): AdfListItemNode {
103
108
  }
104
109
  }
105
110
 
111
+ const INLINE_MARK = /\*\*([^*\n]+)\*\*|\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g
112
+
113
+ function tokenizeInline(text: string): AdfTextNode[] {
114
+ const out: AdfTextNode[] = []
115
+ INLINE_MARK.lastIndex = 0
116
+ let cursor = 0
117
+ for (const match of text.matchAll(INLINE_MARK)) {
118
+ const start = match.index ?? 0
119
+ if (start > cursor) {
120
+ out.push({ type: 'text', text: text.slice(cursor, start) })
121
+ }
122
+ const boldText = match[1]
123
+ if (boldText !== undefined) {
124
+ out.push({
125
+ type: 'text',
126
+ text: boldText,
127
+ marks: [{ type: 'strong' }],
128
+ })
129
+ } else {
130
+ out.push({
131
+ type: 'text',
132
+ text: match[2]!,
133
+ marks: [{ type: 'link', attrs: { href: match[3]! } }],
134
+ })
135
+ }
136
+ cursor = start + match[0].length
137
+ }
138
+ if (cursor < text.length) {
139
+ out.push({ type: 'text', text: text.slice(cursor) })
140
+ }
141
+ return out
142
+ }
143
+
106
144
  export function plainTextToAdf(text: string): AdfDocument {
107
145
  if (text.length === 0) {
108
146
  return { version: 1, type: 'doc', content: [] }
@@ -211,18 +249,79 @@ export function plainTextToAdf(text: string): AdfDocument {
211
249
  return { version: 1, type: 'doc', content: blocks }
212
250
  }
213
251
 
214
- function inlineText(nodes: AdfInlineNode[] | undefined): string {
252
+ function inlineText(
253
+ nodes: AdfInlineNode[] | undefined,
254
+ opts: { renderMarks?: boolean } = {},
255
+ ): string {
215
256
  if (!nodes) return ''
257
+ const renderMarks = opts.renderMarks ?? true
216
258
  let out = ''
217
- for (const node of nodes) {
259
+ for (let i = 0; i < nodes.length; i += 1) {
260
+ const node = nodes[i]!
218
261
  if (node.type === 'text') {
219
- out += (node as AdfTextNode).text
262
+ const textNode = node as AdfTextNode
263
+ if (!renderMarks) {
264
+ out += textNode.text
265
+ continue
266
+ }
267
+ const nextNode = nodes[i + 1]
268
+ const labelColon = readPlainTextLeadingColon(nextNode)
269
+ if (labelColon && hasMark(textNode, 'strong')) {
270
+ out += renderTextNode({ ...textNode, text: `${textNode.text}:` })
271
+ out += labelColon.remainder
272
+ i += 1
273
+ continue
274
+ }
275
+ out += renderTextNode(textNode)
276
+ continue
220
277
  }
221
- // Unknown inline nodes (mentions, emoji, hardBreak, etc.) are skipped.
278
+ if (node.type === 'inlineCard') {
279
+ const url = readCardUrl(node)
280
+ if (url) out += url
281
+ continue
282
+ }
283
+ if (node.type === 'hardBreak') {
284
+ out += '\n'
285
+ continue
286
+ }
287
+ // Other unknown inline nodes (mentions, emoji, etc.) are skipped.
288
+ }
289
+ return out
290
+ }
291
+
292
+ function hasMark(node: AdfTextNode, type: string): boolean {
293
+ return node.marks?.some((m) => m.type === type) ?? false
294
+ }
295
+
296
+ function readPlainTextLeadingColon(node: AdfInlineNode | undefined): { remainder: string } | null {
297
+ if (!node || node.type !== 'text') return null
298
+ const textNode = node as AdfTextNode
299
+ if (textNode.marks && textNode.marks.length > 0) return null
300
+ if (!textNode.text.startsWith(':')) return null
301
+ return { remainder: textNode.text.slice(1) }
302
+ }
303
+
304
+ function renderTextNode(node: AdfTextNode): string {
305
+ if (!node.marks || node.marks.length === 0) return node.text
306
+ const link = node.marks.find((m) => m.type === 'link')
307
+ let out = node.text
308
+ if (link) {
309
+ const href = link.attrs?.['href']
310
+ if (typeof href === 'string' && href.length > 0) out = `[${out}](${href})`
311
+ }
312
+ if (hasMark(node, 'strong')) {
313
+ out = `**${out}**`
222
314
  }
223
315
  return out
224
316
  }
225
317
 
318
+ function readCardUrl(node: AdfInlineNode | AdfBlockNode): string | null {
319
+ const attrs = (node as { attrs?: Record<string, unknown> }).attrs
320
+ if (!attrs) return null
321
+ const url = attrs['url']
322
+ return typeof url === 'string' && url.length > 0 ? url : null
323
+ }
324
+
226
325
  function listItemInnerText(item: AdfListItemNode): string {
227
326
  // Each list item wraps a paragraph (or nested blocks). We flatten to the
228
327
  // first paragraph's inline text, which is all the write path produces.
@@ -252,12 +351,17 @@ function renderBlock(node: AdfBlockNode): string | null {
252
351
  case 'codeBlock': {
253
352
  const code = node as AdfCodeBlockNode
254
353
  const language = code.attrs?.language ?? ''
255
- const body = inlineText(code.content)
354
+ const body = inlineText(code.content, { renderMarks: false })
256
355
  const fence = language.length > 0 ? `\`\`\`${language}` : '```'
257
356
  return `${fence}\n${body}\n\`\`\``
258
357
  }
259
358
  case 'heading':
260
359
  return inlineText((node as AdfHeadingNode).content)
360
+ case 'blockCard':
361
+ case 'embedCard': {
362
+ const url = readCardUrl(node)
363
+ return url ?? null
364
+ }
261
365
  default:
262
366
  // Unknown block node — skip entirely, never throw.
263
367
  return null
@@ -1,5 +1,5 @@
1
1
  import type { Database } from 'bun:sqlite'
2
- import type { BoardView, ProviderTeamInfo, Task } from '../types.ts'
2
+ import type { BoardView, ProviderTeamInfo, Task } from '../types'
3
3
 
4
4
  // Column ids are prefixed to avoid collisions across sources:
5
5
  // - board-sourced columns: 'board:<boardId>:<columnName>'
@@ -1,6 +1,6 @@
1
1
  import { Buffer } from 'node:buffer'
2
- import { ErrorCode } from '../errors.ts'
3
- import { providerUpstreamError } from './errors.ts'
2
+ import { ErrorCode } from '../errors'
3
+ import { providerUpstreamError } from './errors'
4
4
 
5
5
  export interface JiraProject {
6
6
  id: string
@@ -1,5 +1,5 @@
1
1
  import type { Database } from 'bun:sqlite'
2
- import { ErrorCode, KanbanError } from '../errors.ts'
2
+ import { ErrorCode, KanbanError } from '../errors'
3
3
  import type {
4
4
  ActivityEntry,
5
5
  BoardBootstrap,
@@ -10,17 +10,12 @@ import type {
10
10
  Priority,
11
11
  TaskComment,
12
12
  Task,
13
- } from '../types.ts'
14
- import {
15
- headerLower,
16
- verifyHmacSha256,
17
- type WebhookRequest,
18
- type WebhookResult,
19
- } from '../webhooks.ts'
20
- import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf.ts'
21
- import { JIRA_CAPABILITIES } from './capabilities.ts'
22
- import { providerUpstreamError, unsupportedOperation } from './errors.ts'
23
- import { JiraClient, type JiraComment, type JiraIssue } from './jira-client.ts'
13
+ } from '../types'
14
+ import { headerLower, verifyHmacSha256, type WebhookRequest, type WebhookResult } from '../webhooks'
15
+ import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
16
+ import { JIRA_CAPABILITIES } from './capabilities'
17
+ import { providerUpstreamError, unsupportedOperation } from './errors'
18
+ import { JiraClient, type JiraComment, type JiraIssue } from './jira-client'
24
19
  import {
25
20
  adjustJiraIssueCommentCount,
26
21
  decodeColumnStatusIds,
@@ -45,7 +40,7 @@ import {
45
40
  upsertJiraUsers,
46
41
  type JiraActivityRow,
47
42
  type JiraSyncMeta,
48
- } from './jira-cache.ts'
43
+ } from './jira-cache'
49
44
  import type {
50
45
  CreateTaskInput,
51
46
  KanbanProvider,
@@ -53,7 +48,7 @@ import type {
53
48
  ProviderSyncStatus,
54
49
  TaskListFilters,
55
50
  UpdateTaskInput,
56
- } from './types.ts'
51
+ } from './types'
57
52
 
58
53
  const SYNC_INTERVAL_MS = 30_000
59
54
  const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
@@ -1,5 +1,5 @@
1
1
  import type { Database } from 'bun:sqlite'
2
- import type { BoardConfig, BoardView, ProviderTeamInfo, Task } from '../types.ts'
2
+ import type { BoardConfig, BoardView, ProviderTeamInfo, Task } from '../types'
3
3
 
4
4
  export interface LinearStateRow {
5
5
  id: string
@@ -1,5 +1,5 @@
1
- import { ErrorCode } from '../errors.ts'
2
- import { providerUpstreamError } from './errors.ts'
1
+ import { ErrorCode } from '../errors'
2
+ import { providerUpstreamError } from './errors'
3
3
 
4
4
  interface GraphQLResponse<T> {
5
5
  data?: T
@@ -57,7 +57,6 @@ interface LinearIssueNode {
57
57
  state: { id: string; name: string; position: number }
58
58
  labels?: { nodes: Array<{ id: string; name: string }> }
59
59
  comments?: {
60
- totalCount?: number
61
60
  nodes: Array<{ id: string }>
62
61
  pageInfo?: { hasNextPage: boolean; endCursor: string | null }
63
62
  } | null
@@ -81,7 +80,7 @@ function toLinearIssue(node: LinearIssueNode): LinearIssue {
81
80
  }
82
81
  : null,
83
82
  labels: node.labels?.nodes.map((label) => label.name) ?? [],
84
- commentCount: node.comments?.totalCount ?? node.comments?.nodes?.length ?? undefined,
83
+ commentCount: node.comments?.nodes?.length ?? undefined,
85
84
  }
86
85
  }
87
86
 
@@ -284,7 +283,6 @@ export class LinearClient {
284
283
  nodes { id name }
285
284
  }
286
285
  comments(first: 250) {
287
- totalCount
288
286
  nodes { id }
289
287
  pageInfo { hasNextPage endCursor }
290
288
  }
@@ -335,7 +333,6 @@ export class LinearClient {
335
333
  state { id name position }
336
334
  labels { nodes { id name } }
337
335
  comments(first: 250) {
338
- totalCount
339
336
  nodes { id }
340
337
  pageInfo { hasNextPage endCursor }
341
338
  }
@@ -1,5 +1,5 @@
1
1
  import type { Database } from 'bun:sqlite'
2
- import { ErrorCode, KanbanError } from '../errors.ts'
2
+ import { ErrorCode, KanbanError } from '../errors'
3
3
  import type {
4
4
  ActivityEntry,
5
5
  BoardBootstrap,
@@ -8,14 +8,9 @@ import type {
8
8
  Column,
9
9
  TaskComment,
10
10
  Task,
11
- } from '../types.ts'
12
- import {
13
- headerLower,
14
- verifyHmacSha256,
15
- type WebhookRequest,
16
- type WebhookResult,
17
- } from '../webhooks.ts'
18
- import { LINEAR_CAPABILITIES } from './capabilities.ts'
11
+ } from '../types'
12
+ import { headerLower, verifyHmacSha256, type WebhookRequest, type WebhookResult } from '../webhooks'
13
+ import { LINEAR_CAPABILITIES } from './capabilities'
19
14
  import {
20
15
  adjustLinearIssueCommentCount,
21
16
  deleteLinearIssue,
@@ -35,9 +30,9 @@ import {
35
30
  upsertProjects,
36
31
  upsertUsers,
37
32
  type LinearActivityRow,
38
- } from './linear-cache.ts'
39
- import { LinearClient, type LinearComment } from './linear-client.ts'
40
- import { unsupportedOperation } from './errors.ts'
33
+ } from './linear-cache'
34
+ import { LinearClient, type LinearComment } from './linear-client'
35
+ import { unsupportedOperation } from './errors'
41
36
  import type {
42
37
  CreateTaskInput,
43
38
  KanbanProvider,
@@ -45,7 +40,7 @@ import type {
45
40
  ProviderSyncStatus,
46
41
  TaskListFilters,
47
42
  UpdateTaskInput,
48
- } from './types.ts'
43
+ } from './types'
49
44
 
50
45
  const SYNC_INTERVAL_MS = 30_000
51
46
  const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
@@ -1,6 +1,6 @@
1
1
  import type { Database } from 'bun:sqlite'
2
- import { listActivity } from '../activity.ts'
3
- import { getConfigPath, loadConfig, saveConfig } from '../config.ts'
2
+ import { listActivity } from '../activity'
3
+ import { getConfigPath, loadConfig, saveConfig } from '../config'
4
4
  import {
5
5
  addComment,
6
6
  countComments,
@@ -17,11 +17,11 @@ import {
17
17
  moveTask,
18
18
  updateComment as updateTaskComment,
19
19
  updateTask,
20
- } from '../db.ts'
21
- import { getBoardMetrics, getDiscoveredAssignees, getDiscoveredProjects } from '../metrics.ts'
22
- import type { BoardBootstrap, BoardConfig, Task, TaskComment } from '../types.ts'
23
- import { ErrorCode, KanbanError } from '../errors.ts'
24
- import { LOCAL_CAPABILITIES } from './capabilities.ts'
20
+ } from '../db'
21
+ import { getBoardMetrics, getDiscoveredAssignees, getDiscoveredProjects } from '../metrics'
22
+ import type { BoardBootstrap, BoardConfig, Task, TaskComment } from '../types'
23
+ import { ErrorCode, KanbanError } from '../errors'
24
+ import { LOCAL_CAPABILITIES } from './capabilities'
25
25
  import type {
26
26
  CreateTaskInput,
27
27
  KanbanProvider,
@@ -29,7 +29,7 @@ import type {
29
29
  ProviderSyncStatus,
30
30
  TaskListFilters,
31
31
  UpdateTaskInput,
32
- } from './types.ts'
32
+ } from './types'
33
33
 
34
34
  function buildLocalConfig(
35
35
  db: Database,
@@ -1,4 +1,4 @@
1
- import type { WebhookRequest, WebhookResult } from '../webhooks.ts'
1
+ import type { WebhookRequest, WebhookResult } from '../webhooks'
2
2
  import type {
3
3
  ActivityEntry,
4
4
  BoardBootstrap,
@@ -11,7 +11,7 @@ import type {
11
11
  ProviderTeamInfo,
12
12
  TaskComment,
13
13
  Task,
14
- } from '../types.ts'
14
+ } from '../types'
15
15
 
16
16
  export interface TaskListFilters {
17
17
  column?: string
package/src/server.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { handleRequest } from './api.ts'
3
+ import { handleRequest } from './api'
4
4
  import type { ServerWebSocket } from 'bun'
5
- import type { KanbanProvider } from './providers/types.ts'
5
+ import type { KanbanProvider } from './providers/types'
6
6
 
7
7
  const wsClients = new Set<ServerWebSocket<unknown>>()
8
8
  const CORS_HEADERS = {
package/src/types.ts CHANGED
@@ -115,6 +115,7 @@ export interface ProviderCapabilities {
115
115
  taskMove: boolean
116
116
  taskDelete: boolean
117
117
  comment: boolean
118
+ /** True when provider-backed bootstrap/dashboard activity is exposed, not merely cached internally. */
118
119
  activity: boolean
119
120
  metrics: boolean
120
121
  columnCrud: boolean