@andypai/agent-kanban 0.3.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andypai/agent-kanban",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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": {
@@ -1,5 +1,31 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { adfToPlainText, plainTextToAdf, type AdfDocument } from '../providers/jira-adf'
2
+ import {
3
+ adfToPlainText,
4
+ plainTextToAdf,
5
+ type AdfBulletListNode,
6
+ type AdfCodeBlockNode,
7
+ type AdfDocument,
8
+ type AdfInlineNode,
9
+ type AdfParagraphNode,
10
+ } from '../providers/jira-adf'
11
+
12
+ function firstParagraphContent(doc: AdfDocument): AdfInlineNode[] {
13
+ const paragraph = doc.content[0] as AdfParagraphNode
14
+ expect(paragraph.type).toBe('paragraph')
15
+ return paragraph.content ?? []
16
+ }
17
+
18
+ function firstCodeBlock(doc: AdfDocument): AdfCodeBlockNode {
19
+ const code = doc.content[0] as AdfCodeBlockNode
20
+ expect(code.type).toBe('codeBlock')
21
+ return code
22
+ }
23
+
24
+ function firstBulletList(doc: AdfDocument): AdfBulletListNode {
25
+ const list = doc.content[0] as AdfBulletListNode
26
+ expect(list.type).toBe('bulletList')
27
+ return list
28
+ }
3
29
 
4
30
  describe('plainTextToAdf / adfToPlainText', () => {
5
31
  test('empty doc round-trip', () => {
@@ -154,7 +180,7 @@ describe('plainTextToAdf / adfToPlainText', () => {
154
180
  expect(adfToPlainText(doc)).toBe('before\n\nafter')
155
181
  })
156
182
 
157
- test('inline text marks are stripped on read', () => {
183
+ test('strong + link marks survive on read as markdown', () => {
158
184
  const doc: AdfDocument = {
159
185
  version: 1,
160
186
  type: 'doc',
@@ -163,12 +189,240 @@ describe('plainTextToAdf / adfToPlainText', () => {
163
189
  type: 'paragraph',
164
190
  content: [
165
191
  { type: 'text', text: 'bold', marks: [{ type: 'strong' }] },
166
- { type: 'text', text: ' normal' },
192
+ { type: 'text', text: ' ' },
193
+ {
194
+ type: 'text',
195
+ text: 'click',
196
+ marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
197
+ },
198
+ ],
199
+ },
200
+ ],
201
+ }
202
+ expect(adfToPlainText(doc)).toBe('**bold** [click](https://example.com)')
203
+ })
204
+
205
+ test('write path emits **bold** as a strong mark', () => {
206
+ const input = 'hello **world**'
207
+ const doc = plainTextToAdf(input)
208
+ expect(firstParagraphContent(doc)).toEqual([
209
+ { type: 'text', text: 'hello ' },
210
+ { type: 'text', text: 'world', marks: [{ type: 'strong' }] },
211
+ ])
212
+ expect(adfToPlainText(doc)).toBe(input)
213
+ })
214
+
215
+ test('write path emits [label](https://...) as a link mark', () => {
216
+ const input = 'see [docs](https://example.com/x)'
217
+ const doc = plainTextToAdf(input)
218
+ expect(firstParagraphContent(doc)).toEqual([
219
+ { type: 'text', text: 'see ' },
220
+ {
221
+ type: 'text',
222
+ text: 'docs',
223
+ marks: [{ type: 'link', attrs: { href: 'https://example.com/x' } }],
224
+ },
225
+ ])
226
+ expect(adfToPlainText(doc)).toBe(input)
227
+ })
228
+
229
+ test('write path emits side-by-side bold and link as adjacent text nodes', () => {
230
+ const input = '**PR opened** — [repo#1](https://github.com/o/r/pull/1)'
231
+ const doc = plainTextToAdf(input)
232
+ expect(firstParagraphContent(doc)).toEqual([
233
+ { type: 'text', text: 'PR opened', marks: [{ type: 'strong' }] },
234
+ { type: 'text', text: ' — ' },
235
+ {
236
+ type: 'text',
237
+ text: 'repo#1',
238
+ marks: [{ type: 'link', attrs: { href: 'https://github.com/o/r/pull/1' } }],
239
+ },
240
+ ])
241
+ expect(adfToPlainText(doc)).toBe(input)
242
+ })
243
+
244
+ test('write path bolds bullet item field-name prefix', () => {
245
+ const input = '- **Marker:** drift:HUMAN REVIEW:516652'
246
+ const doc = plainTextToAdf(input)
247
+ const list = firstBulletList(doc)
248
+ const itemParagraph = list.content[0]!.content[0] as AdfParagraphNode
249
+ const itemContent = itemParagraph.content ?? []
250
+ expect(itemContent).toEqual([
251
+ { type: 'text', text: 'Marker:', marks: [{ type: 'strong' }] },
252
+ { type: 'text', text: ' drift:HUMAN REVIEW:516652' },
253
+ ])
254
+ expect(adfToPlainText(doc)).toBe(input)
255
+ })
256
+
257
+ test('inline tokenizer does NOT run inside fenced code blocks', () => {
258
+ const input = '```ts\nconst s = "**not bold** [x](https://e.com)"\n```'
259
+ const doc = plainTextToAdf(input)
260
+ const code = firstCodeBlock(doc)
261
+ const codeContent = code.content ?? []
262
+ expect(codeContent).toHaveLength(1)
263
+ expect(codeContent[0]?.text).toBe('const s = "**not bold** [x](https://e.com)"')
264
+ expect(codeContent[0]?.marks).toBeUndefined()
265
+ expect(adfToPlainText(doc)).toBe(input)
266
+ })
267
+
268
+ test('read: code block text marks stay literal', () => {
269
+ const doc: AdfDocument = {
270
+ version: 1,
271
+ type: 'doc',
272
+ content: [
273
+ {
274
+ type: 'codeBlock',
275
+ content: [
276
+ { type: 'text', text: '**not bold**', marks: [{ type: 'strong' }] },
277
+ {
278
+ type: 'text',
279
+ text: '\nhttps://example.com',
280
+ marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
281
+ },
282
+ ],
283
+ },
284
+ ],
285
+ }
286
+ expect(adfToPlainText(doc)).toBe('```\n**not bold**\nhttps://example.com\n```')
287
+ })
288
+
289
+ test('non-http link target is left as literal text (no link mark)', () => {
290
+ const input = 'see [docs](mailto:dev@example.com)'
291
+ const doc = plainTextToAdf(input)
292
+ expect(firstParagraphContent(doc)).toEqual([{ type: 'text', text: input }])
293
+ })
294
+
295
+ test('read: paragraph containing inlineCard emits the URL inline', () => {
296
+ const doc: AdfDocument = {
297
+ version: 1,
298
+ type: 'doc',
299
+ content: [
300
+ {
301
+ type: 'paragraph',
302
+ content: [
303
+ { type: 'text', text: 'Repo: ' },
304
+ { type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
305
+ ],
306
+ },
307
+ ],
308
+ }
309
+ expect(adfToPlainText(doc)).toBe('Repo: https://github.com/abpai/garage-band')
310
+ })
311
+
312
+ test('read: split strong field label keeps colon inside markdown', () => {
313
+ const doc: AdfDocument = {
314
+ version: 1,
315
+ type: 'doc',
316
+ content: [
317
+ {
318
+ type: 'paragraph',
319
+ content: [
320
+ { type: 'text', text: 'Repo', marks: [{ type: 'strong' }] },
321
+ { type: 'text', text: ': ' },
322
+ { type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
323
+ ],
324
+ },
325
+ ],
326
+ }
327
+ expect(adfToPlainText(doc)).toBe('**Repo:** https://github.com/abpai/garage-band')
328
+ })
329
+
330
+ test('read: blockCard renders as a standalone block with the URL', () => {
331
+ const doc: AdfDocument = {
332
+ version: 1,
333
+ type: 'doc',
334
+ content: [
335
+ { type: 'paragraph', content: [{ type: 'text', text: 'before' }] },
336
+ { type: 'blockCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
337
+ { type: 'paragraph', content: [{ type: 'text', text: 'after' }] },
338
+ ],
339
+ }
340
+ expect(adfToPlainText(doc)).toBe('before\n\nhttps://github.com/abpai/garage-band\n\nafter')
341
+ })
342
+
343
+ test('read: embedCard also emits its URL', () => {
344
+ const doc: AdfDocument = {
345
+ version: 1,
346
+ type: 'doc',
347
+ content: [{ type: 'embedCard', attrs: { url: 'https://example.com/embed' } }],
348
+ }
349
+ expect(adfToPlainText(doc)).toBe('https://example.com/embed')
350
+ })
351
+
352
+ test('read: DXS-12-shaped description preserves smart-link Repo URL', () => {
353
+ // Captured shape from a real Jira description where a user pasted a URL
354
+ // and Jira auto-converted it to an inlineCard. Before the fix,
355
+ // adfToPlainText returned "Repo: " with the URL silently dropped.
356
+ const doc: AdfDocument = {
357
+ version: 1,
358
+ type: 'doc',
359
+ content: [
360
+ {
361
+ type: 'paragraph',
362
+ content: [
363
+ { type: 'text', text: 'Repo', marks: [{ type: 'strong' }] },
364
+ { type: 'text', text: ': ' },
365
+ { type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
366
+ ],
367
+ },
368
+ {
369
+ type: 'paragraph',
370
+ content: [{ type: 'text', text: 'Please make one minimal change.' }],
371
+ },
372
+ {
373
+ type: 'heading',
374
+ attrs: { level: 3 },
375
+ content: [{ type: 'text', text: 'Acceptance criteria:' }],
376
+ },
377
+ {
378
+ type: 'bulletList',
379
+ content: [
380
+ {
381
+ type: 'listItem',
382
+ content: [
383
+ {
384
+ type: 'paragraph',
385
+ content: [{ type: 'text', text: 'Change only SMOKE_TEST_TASK.md.' }],
386
+ },
387
+ ],
388
+ },
389
+ {
390
+ type: 'listItem',
391
+ content: [
392
+ {
393
+ type: 'paragraph',
394
+ content: [{ type: 'text', text: 'Increment current_count by exactly one.' }],
395
+ },
396
+ ],
397
+ },
398
+ ],
399
+ },
400
+ ],
401
+ }
402
+ const out = adfToPlainText(doc)
403
+ expect(out).toContain('Repo:')
404
+ expect(out).toContain('https://github.com/abpai/garage-band')
405
+ // Repo line carries the URL — agents grepping for the URL recover it.
406
+ const repoLine = out.split('\n').find((l) => l.includes('Repo:'))
407
+ expect(repoLine).toBe('**Repo:** https://github.com/abpai/garage-band')
408
+ })
409
+
410
+ test('read: hardBreak between text runs emits newline', () => {
411
+ const doc: AdfDocument = {
412
+ version: 1,
413
+ type: 'doc',
414
+ content: [
415
+ {
416
+ type: 'paragraph',
417
+ content: [
418
+ { type: 'text', text: 'first line' },
419
+ { type: 'hardBreak' },
420
+ { type: 'text', text: 'second line' },
167
421
  ],
168
422
  },
169
423
  ],
170
424
  }
171
- expect(adfToPlainText(doc)).toBe('bold normal')
425
+ expect(adfToPlainText(doc)).toBe('first line\nsecond line')
172
426
  })
173
427
 
174
428
  test('bullet item containing digits-dot substring is still a bullet', () => {
@@ -67,13 +67,14 @@ function makeIssue(opts: {
67
67
  updated?: string
68
68
  summary?: string
69
69
  assignee?: { accountId: string; displayName: string } | null
70
+ description?: unknown
70
71
  }): Record<string, unknown> {
71
72
  return {
72
73
  id: opts.id,
73
74
  key: opts.key,
74
75
  fields: {
75
76
  summary: opts.summary ?? opts.key,
76
- description: null,
77
+ description: opts.description ?? null,
77
78
  status: { id: opts.statusId, name: 'Status ' + opts.statusId },
78
79
  issuetype: { id: '10000', name: 'Bug' },
79
80
  priority: { id: '2', name: 'High' },
@@ -429,6 +430,51 @@ describe('JiraProvider read path', () => {
429
430
  expect(byPrefixed.externalRef).toBe('ENG-1')
430
431
  })
431
432
 
433
+ test('description ADF with an inlineCard smart-link preserves the URL through getTask', async () => {
434
+ // Regression for the DXS-12 shape: a user pastes
435
+ // `Repo: https://github.com/abpai/garage-band` in the description, Jira
436
+ // auto-converts the URL to an inlineCard, and the previous
437
+ // adfToPlainText silently dropped attrs.url.
438
+ const description = {
439
+ version: 1,
440
+ type: 'doc',
441
+ content: [
442
+ {
443
+ type: 'paragraph',
444
+ content: [
445
+ { type: 'text', text: 'Repo', marks: [{ type: 'strong' }] },
446
+ { type: 'text', text: ': ' },
447
+ { type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
448
+ ],
449
+ },
450
+ {
451
+ type: 'paragraph',
452
+ content: [{ type: 'text', text: 'Increment SMOKE_TEST_TASK.md by 1.' }],
453
+ },
454
+ ],
455
+ }
456
+ const searchHandler: StubHandler = () =>
457
+ jsonResponse({
458
+ startAt: 0,
459
+ maxResults: 100,
460
+ total: 1,
461
+ issues: [
462
+ makeIssue({
463
+ id: '20001',
464
+ key: 'ENG-12',
465
+ statusId: '10001',
466
+ description,
467
+ }),
468
+ ],
469
+ })
470
+ const { provider } = makeProvider(standardRoutes({ searchHandler }))
471
+ await provider.getBoard()
472
+ const task = await provider.getTask('ENG-12')
473
+ expect(task.description).toContain('Repo:')
474
+ expect(task.description).toContain('https://github.com/abpai/garage-band')
475
+ expect(task.description.split('\n')[0]).toBe('**Repo:** https://github.com/abpai/garage-band')
476
+ })
477
+
432
478
  test('getBootstrap returns provider=jira and the adapted BoardConfig', async () => {
433
479
  const { provider } = makeProvider(standardRoutes({}))
434
480
  const result = await provider.getBootstrap()
@@ -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