@andypai/agent-kanban 0.3.1 → 0.3.3
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 +10 -2
- package/package.json +1 -1
- package/src/__tests__/index.test.ts +47 -1
- package/src/__tests__/jira-adf.test.ts +397 -4
- package/src/__tests__/jira-provider-read.test.ts +47 -1
- package/src/__tests__/output.test.ts +17 -0
- package/src/index.ts +47 -0
- package/src/output.ts +22 -1
- package/src/providers/jira-adf.ts +187 -23
package/README.md
CHANGED
|
@@ -127,8 +127,8 @@ surface.
|
|
|
127
127
|
|
|
128
128
|
Unsupported operations return error code `UNSUPPORTED_OPERATION` with exit code 1.
|
|
129
129
|
|
|
130
|
-
Task comments are
|
|
131
|
-
detail flows
|
|
130
|
+
Task comments are exposed through the CLI, REST API, MCP, and dashboard task
|
|
131
|
+
detail flows.
|
|
132
132
|
|
|
133
133
|
In Linear and Jira modes, webhooks update the cache immediately when configured,
|
|
134
134
|
and the normal poll loop still runs as a fallback so missed deliveries and
|
|
@@ -192,6 +192,14 @@ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
|
|
|
192
192
|
| `--project <name>` | New project |
|
|
193
193
|
| `-m <json>` | New metadata |
|
|
194
194
|
|
|
195
|
+
### comment
|
|
196
|
+
|
|
197
|
+
| Command | Description |
|
|
198
|
+
| ----------------------------------------------------- | --------------------- |
|
|
199
|
+
| `kanban comment list <task-id>` | List task comments |
|
|
200
|
+
| `kanban comment add <task-id> <body>` | Create a task comment |
|
|
201
|
+
| `kanban comment update <task-id> <comment-id> <body>` | Update a task comment |
|
|
202
|
+
|
|
195
203
|
### column
|
|
196
204
|
|
|
197
205
|
| Command | Description |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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": {
|
|
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
|
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { ErrorCode, KanbanError, type ErrorCodeValue } from '../errors'
|
|
7
|
-
import type { Task } from '../types'
|
|
7
|
+
import type { Task, TaskComment } from '../types'
|
|
8
8
|
import { parseServeArgs, run } from '../index'
|
|
9
9
|
|
|
10
10
|
async function withTempDb(runTest: (dbPath: string) => Promise<void>): Promise<void> {
|
|
@@ -234,6 +234,28 @@ describe('run', () => {
|
|
|
234
234
|
})
|
|
235
235
|
})
|
|
236
236
|
|
|
237
|
+
test('runs comment commands through the real CLI path', async () => {
|
|
238
|
+
await withTempDb(async (dbPath) => {
|
|
239
|
+
const task = expectOk<Task>(await run(['--db', dbPath, 'task', 'add', 'Comment target']))
|
|
240
|
+
|
|
241
|
+
const created = expectOk<TaskComment>(
|
|
242
|
+
await run(['--db', dbPath, 'comment', 'add', task.id, 'hello', 'from', 'cli']),
|
|
243
|
+
)
|
|
244
|
+
expect(created.task_id).toBe(task.id)
|
|
245
|
+
expect(created.body).toBe('hello from cli')
|
|
246
|
+
|
|
247
|
+
const listed = expectOk<TaskComment[]>(
|
|
248
|
+
await run(['--db', dbPath, 'comment', 'list', task.id]),
|
|
249
|
+
)
|
|
250
|
+
expect(listed.map((comment) => comment.id)).toEqual([created.id])
|
|
251
|
+
|
|
252
|
+
const updated = expectOk<TaskComment>(
|
|
253
|
+
await run(['--db', dbPath, 'comment', 'update', task.id, created.id, 'edited', 'comment']),
|
|
254
|
+
)
|
|
255
|
+
expect(updated.body).toBe('edited comment')
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
237
259
|
test('raises CLI errors for missing task arguments', async () => {
|
|
238
260
|
await withTempDb(async (dbPath) => {
|
|
239
261
|
const missingTitle = await expectKanbanError(
|
|
@@ -249,4 +271,28 @@ describe('run', () => {
|
|
|
249
271
|
expect(missingId.message).toContain('Task ID is required')
|
|
250
272
|
})
|
|
251
273
|
})
|
|
274
|
+
|
|
275
|
+
test('raises CLI errors for missing comment arguments', async () => {
|
|
276
|
+
await withTempDb(async (dbPath) => {
|
|
277
|
+
const missingListId = await expectKanbanError(
|
|
278
|
+
run(['--db', dbPath, 'comment', 'list']),
|
|
279
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
280
|
+
)
|
|
281
|
+
expect(missingListId.message).toContain('Task ID is required')
|
|
282
|
+
|
|
283
|
+
const missingAddBody = await expectKanbanError(
|
|
284
|
+
run(['--db', dbPath, 'comment', 'add', 't_1']),
|
|
285
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
286
|
+
)
|
|
287
|
+
expect(missingAddBody.message).toContain('kanban comment add <task-id> <body>')
|
|
288
|
+
|
|
289
|
+
const missingUpdateBody = await expectKanbanError(
|
|
290
|
+
run(['--db', dbPath, 'comment', 'update', 't_1', 'c_1']),
|
|
291
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
292
|
+
)
|
|
293
|
+
expect(missingUpdateBody.message).toContain(
|
|
294
|
+
'kanban comment update <task-id> <comment-id> <body>',
|
|
295
|
+
)
|
|
296
|
+
})
|
|
297
|
+
})
|
|
252
298
|
})
|
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
adfToPlainText,
|
|
4
|
+
plainTextToAdf,
|
|
5
|
+
type AdfBulletListNode,
|
|
6
|
+
type AdfCodeBlockNode,
|
|
7
|
+
type AdfDocument,
|
|
8
|
+
type AdfExpandNode,
|
|
9
|
+
type AdfInlineNode,
|
|
10
|
+
type AdfParagraphNode,
|
|
11
|
+
} from '../providers/jira-adf'
|
|
12
|
+
|
|
13
|
+
function firstParagraphContent(doc: AdfDocument): AdfInlineNode[] {
|
|
14
|
+
const paragraph = doc.content[0] as AdfParagraphNode
|
|
15
|
+
expect(paragraph.type).toBe('paragraph')
|
|
16
|
+
return paragraph.content ?? []
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function firstCodeBlock(doc: AdfDocument): AdfCodeBlockNode {
|
|
20
|
+
const code = doc.content[0] as AdfCodeBlockNode
|
|
21
|
+
expect(code.type).toBe('codeBlock')
|
|
22
|
+
return code
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function firstBulletList(doc: AdfDocument): AdfBulletListNode {
|
|
26
|
+
const list = doc.content[0] as AdfBulletListNode
|
|
27
|
+
expect(list.type).toBe('bulletList')
|
|
28
|
+
return list
|
|
29
|
+
}
|
|
3
30
|
|
|
4
31
|
describe('plainTextToAdf / adfToPlainText', () => {
|
|
5
32
|
test('empty doc round-trip', () => {
|
|
@@ -154,7 +181,7 @@ describe('plainTextToAdf / adfToPlainText', () => {
|
|
|
154
181
|
expect(adfToPlainText(doc)).toBe('before\n\nafter')
|
|
155
182
|
})
|
|
156
183
|
|
|
157
|
-
test('
|
|
184
|
+
test('strong + link marks survive on read as markdown', () => {
|
|
158
185
|
const doc: AdfDocument = {
|
|
159
186
|
version: 1,
|
|
160
187
|
type: 'doc',
|
|
@@ -163,12 +190,240 @@ describe('plainTextToAdf / adfToPlainText', () => {
|
|
|
163
190
|
type: 'paragraph',
|
|
164
191
|
content: [
|
|
165
192
|
{ type: 'text', text: 'bold', marks: [{ type: 'strong' }] },
|
|
166
|
-
{ type: 'text', text: '
|
|
193
|
+
{ type: 'text', text: ' ' },
|
|
194
|
+
{
|
|
195
|
+
type: 'text',
|
|
196
|
+
text: 'click',
|
|
197
|
+
marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
}
|
|
203
|
+
expect(adfToPlainText(doc)).toBe('**bold** [click](https://example.com)')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('write path emits **bold** as a strong mark', () => {
|
|
207
|
+
const input = 'hello **world**'
|
|
208
|
+
const doc = plainTextToAdf(input)
|
|
209
|
+
expect(firstParagraphContent(doc)).toEqual([
|
|
210
|
+
{ type: 'text', text: 'hello ' },
|
|
211
|
+
{ type: 'text', text: 'world', marks: [{ type: 'strong' }] },
|
|
212
|
+
])
|
|
213
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('write path emits [label](https://...) as a link mark', () => {
|
|
217
|
+
const input = 'see [docs](https://example.com/x)'
|
|
218
|
+
const doc = plainTextToAdf(input)
|
|
219
|
+
expect(firstParagraphContent(doc)).toEqual([
|
|
220
|
+
{ type: 'text', text: 'see ' },
|
|
221
|
+
{
|
|
222
|
+
type: 'text',
|
|
223
|
+
text: 'docs',
|
|
224
|
+
marks: [{ type: 'link', attrs: { href: 'https://example.com/x' } }],
|
|
225
|
+
},
|
|
226
|
+
])
|
|
227
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('write path emits side-by-side bold and link as adjacent text nodes', () => {
|
|
231
|
+
const input = '**PR opened** — [repo#1](https://github.com/o/r/pull/1)'
|
|
232
|
+
const doc = plainTextToAdf(input)
|
|
233
|
+
expect(firstParagraphContent(doc)).toEqual([
|
|
234
|
+
{ type: 'text', text: 'PR opened', marks: [{ type: 'strong' }] },
|
|
235
|
+
{ type: 'text', text: ' — ' },
|
|
236
|
+
{
|
|
237
|
+
type: 'text',
|
|
238
|
+
text: 'repo#1',
|
|
239
|
+
marks: [{ type: 'link', attrs: { href: 'https://github.com/o/r/pull/1' } }],
|
|
240
|
+
},
|
|
241
|
+
])
|
|
242
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('write path bolds bullet item field-name prefix', () => {
|
|
246
|
+
const input = '- **Marker:** drift:HUMAN REVIEW:516652'
|
|
247
|
+
const doc = plainTextToAdf(input)
|
|
248
|
+
const list = firstBulletList(doc)
|
|
249
|
+
const itemParagraph = list.content[0]!.content[0] as AdfParagraphNode
|
|
250
|
+
const itemContent = itemParagraph.content ?? []
|
|
251
|
+
expect(itemContent).toEqual([
|
|
252
|
+
{ type: 'text', text: 'Marker:', marks: [{ type: 'strong' }] },
|
|
253
|
+
{ type: 'text', text: ' drift:HUMAN REVIEW:516652' },
|
|
254
|
+
])
|
|
255
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('inline tokenizer does NOT run inside fenced code blocks', () => {
|
|
259
|
+
const input = '```ts\nconst s = "**not bold** [x](https://e.com)"\n```'
|
|
260
|
+
const doc = plainTextToAdf(input)
|
|
261
|
+
const code = firstCodeBlock(doc)
|
|
262
|
+
const codeContent = code.content ?? []
|
|
263
|
+
expect(codeContent).toHaveLength(1)
|
|
264
|
+
expect(codeContent[0]?.text).toBe('const s = "**not bold** [x](https://e.com)"')
|
|
265
|
+
expect(codeContent[0]?.marks).toBeUndefined()
|
|
266
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('read: code block text marks stay literal', () => {
|
|
270
|
+
const doc: AdfDocument = {
|
|
271
|
+
version: 1,
|
|
272
|
+
type: 'doc',
|
|
273
|
+
content: [
|
|
274
|
+
{
|
|
275
|
+
type: 'codeBlock',
|
|
276
|
+
content: [
|
|
277
|
+
{ type: 'text', text: '**not bold**', marks: [{ type: 'strong' }] },
|
|
278
|
+
{
|
|
279
|
+
type: 'text',
|
|
280
|
+
text: '\nhttps://example.com',
|
|
281
|
+
marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
}
|
|
287
|
+
expect(adfToPlainText(doc)).toBe('```\n**not bold**\nhttps://example.com\n```')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('non-http link target is left as literal text (no link mark)', () => {
|
|
291
|
+
const input = 'see [docs](mailto:dev@example.com)'
|
|
292
|
+
const doc = plainTextToAdf(input)
|
|
293
|
+
expect(firstParagraphContent(doc)).toEqual([{ type: 'text', text: input }])
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
test('read: paragraph containing inlineCard emits the URL inline', () => {
|
|
297
|
+
const doc: AdfDocument = {
|
|
298
|
+
version: 1,
|
|
299
|
+
type: 'doc',
|
|
300
|
+
content: [
|
|
301
|
+
{
|
|
302
|
+
type: 'paragraph',
|
|
303
|
+
content: [
|
|
304
|
+
{ type: 'text', text: 'Repo: ' },
|
|
305
|
+
{ type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
}
|
|
310
|
+
expect(adfToPlainText(doc)).toBe('Repo: https://github.com/abpai/garage-band')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('read: split strong field label keeps colon inside markdown', () => {
|
|
314
|
+
const doc: AdfDocument = {
|
|
315
|
+
version: 1,
|
|
316
|
+
type: 'doc',
|
|
317
|
+
content: [
|
|
318
|
+
{
|
|
319
|
+
type: 'paragraph',
|
|
320
|
+
content: [
|
|
321
|
+
{ type: 'text', text: 'Repo', marks: [{ type: 'strong' }] },
|
|
322
|
+
{ type: 'text', text: ': ' },
|
|
323
|
+
{ type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
|
|
324
|
+
],
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
}
|
|
328
|
+
expect(adfToPlainText(doc)).toBe('**Repo:** https://github.com/abpai/garage-band')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('read: blockCard renders as a standalone block with the URL', () => {
|
|
332
|
+
const doc: AdfDocument = {
|
|
333
|
+
version: 1,
|
|
334
|
+
type: 'doc',
|
|
335
|
+
content: [
|
|
336
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'before' }] },
|
|
337
|
+
{ type: 'blockCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
|
|
338
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'after' }] },
|
|
339
|
+
],
|
|
340
|
+
}
|
|
341
|
+
expect(adfToPlainText(doc)).toBe('before\n\nhttps://github.com/abpai/garage-band\n\nafter')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('read: embedCard also emits its URL', () => {
|
|
345
|
+
const doc: AdfDocument = {
|
|
346
|
+
version: 1,
|
|
347
|
+
type: 'doc',
|
|
348
|
+
content: [{ type: 'embedCard', attrs: { url: 'https://example.com/embed' } }],
|
|
349
|
+
}
|
|
350
|
+
expect(adfToPlainText(doc)).toBe('https://example.com/embed')
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test('read: DXS-12-shaped description preserves smart-link Repo URL', () => {
|
|
354
|
+
// Captured shape from a real Jira description where a user pasted a URL
|
|
355
|
+
// and Jira auto-converted it to an inlineCard. Before the fix,
|
|
356
|
+
// adfToPlainText returned "Repo: " with the URL silently dropped.
|
|
357
|
+
const doc: AdfDocument = {
|
|
358
|
+
version: 1,
|
|
359
|
+
type: 'doc',
|
|
360
|
+
content: [
|
|
361
|
+
{
|
|
362
|
+
type: 'paragraph',
|
|
363
|
+
content: [
|
|
364
|
+
{ type: 'text', text: 'Repo', marks: [{ type: 'strong' }] },
|
|
365
|
+
{ type: 'text', text: ': ' },
|
|
366
|
+
{ type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
|
|
367
|
+
],
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
type: 'paragraph',
|
|
371
|
+
content: [{ type: 'text', text: 'Please make one minimal change.' }],
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
type: 'heading',
|
|
375
|
+
attrs: { level: 3 },
|
|
376
|
+
content: [{ type: 'text', text: 'Acceptance criteria:' }],
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
type: 'bulletList',
|
|
380
|
+
content: [
|
|
381
|
+
{
|
|
382
|
+
type: 'listItem',
|
|
383
|
+
content: [
|
|
384
|
+
{
|
|
385
|
+
type: 'paragraph',
|
|
386
|
+
content: [{ type: 'text', text: 'Change only SMOKE_TEST_TASK.md.' }],
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
type: 'listItem',
|
|
392
|
+
content: [
|
|
393
|
+
{
|
|
394
|
+
type: 'paragraph',
|
|
395
|
+
content: [{ type: 'text', text: 'Increment current_count by exactly one.' }],
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
}
|
|
403
|
+
const out = adfToPlainText(doc)
|
|
404
|
+
expect(out).toContain('Repo:')
|
|
405
|
+
expect(out).toContain('https://github.com/abpai/garage-band')
|
|
406
|
+
// Repo line carries the URL — agents grepping for the URL recover it.
|
|
407
|
+
const repoLine = out.split('\n').find((l) => l.includes('Repo:'))
|
|
408
|
+
expect(repoLine).toBe('**Repo:** https://github.com/abpai/garage-band')
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
test('read: hardBreak between text runs emits newline', () => {
|
|
412
|
+
const doc: AdfDocument = {
|
|
413
|
+
version: 1,
|
|
414
|
+
type: 'doc',
|
|
415
|
+
content: [
|
|
416
|
+
{
|
|
417
|
+
type: 'paragraph',
|
|
418
|
+
content: [
|
|
419
|
+
{ type: 'text', text: 'first line' },
|
|
420
|
+
{ type: 'hardBreak' },
|
|
421
|
+
{ type: 'text', text: 'second line' },
|
|
167
422
|
],
|
|
168
423
|
},
|
|
169
424
|
],
|
|
170
425
|
}
|
|
171
|
-
expect(adfToPlainText(doc)).toBe('
|
|
426
|
+
expect(adfToPlainText(doc)).toBe('first line\nsecond line')
|
|
172
427
|
})
|
|
173
428
|
|
|
174
429
|
test('bullet item containing digits-dot substring is still a bullet', () => {
|
|
@@ -178,3 +433,141 @@ describe('plainTextToAdf / adfToPlainText', () => {
|
|
|
178
433
|
expect(adfToPlainText(doc)).toBe(input)
|
|
179
434
|
})
|
|
180
435
|
})
|
|
436
|
+
|
|
437
|
+
describe('jira-adf expand block', () => {
|
|
438
|
+
test('write: expand wrapping a fenced code block produces nested ADF', () => {
|
|
439
|
+
const input =
|
|
440
|
+
'::: expand title="garage-baton (machine-readable)"\n' +
|
|
441
|
+
'```garage-baton\n' +
|
|
442
|
+
'{"v":1}\n' +
|
|
443
|
+
'```\n' +
|
|
444
|
+
':::'
|
|
445
|
+
const doc = plainTextToAdf(input)
|
|
446
|
+
expect(doc.content).toHaveLength(1)
|
|
447
|
+
const expand = doc.content[0] as AdfExpandNode
|
|
448
|
+
expect(expand.type).toBe('expand')
|
|
449
|
+
expect(expand.attrs?.title).toBe('garage-baton (machine-readable)')
|
|
450
|
+
expect(expand.content).toHaveLength(1)
|
|
451
|
+
const code = expand.content[0] as AdfCodeBlockNode
|
|
452
|
+
expect(code.type).toBe('codeBlock')
|
|
453
|
+
expect(code.attrs?.language).toBe('garage-baton')
|
|
454
|
+
expect(code.content?.[0]).toEqual({ type: 'text', text: '{"v":1}' })
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
test('read: ADF expand wrapping a codeBlock renders back to the wire format', () => {
|
|
458
|
+
const doc: AdfDocument = {
|
|
459
|
+
version: 1,
|
|
460
|
+
type: 'doc',
|
|
461
|
+
content: [
|
|
462
|
+
{
|
|
463
|
+
type: 'expand',
|
|
464
|
+
attrs: { title: 'garage-baton (machine-readable)' },
|
|
465
|
+
content: [
|
|
466
|
+
{
|
|
467
|
+
type: 'codeBlock',
|
|
468
|
+
attrs: { language: 'garage-baton' },
|
|
469
|
+
content: [{ type: 'text', text: '{"v":1}' }],
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
},
|
|
473
|
+
],
|
|
474
|
+
}
|
|
475
|
+
expect(adfToPlainText(doc)).toBe(
|
|
476
|
+
'::: expand title="garage-baton (machine-readable)"\n' +
|
|
477
|
+
'```garage-baton\n' +
|
|
478
|
+
'{"v":1}\n' +
|
|
479
|
+
'```\n' +
|
|
480
|
+
':::',
|
|
481
|
+
)
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
test('write: bare ::: expand emits empty title', () => {
|
|
485
|
+
const input = '::: expand\nbody paragraph\n:::'
|
|
486
|
+
const doc = plainTextToAdf(input)
|
|
487
|
+
const expand = doc.content[0] as AdfExpandNode
|
|
488
|
+
expect(expand.type).toBe('expand')
|
|
489
|
+
expect(expand.attrs?.title).toBe('')
|
|
490
|
+
expect(expand.content[0]?.type).toBe('paragraph')
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
test('read: bare ::: expand renders without title attribute', () => {
|
|
494
|
+
const doc: AdfDocument = {
|
|
495
|
+
version: 1,
|
|
496
|
+
type: 'doc',
|
|
497
|
+
content: [
|
|
498
|
+
{
|
|
499
|
+
type: 'expand',
|
|
500
|
+
attrs: { title: '' },
|
|
501
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'body' }] }],
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
}
|
|
505
|
+
expect(adfToPlainText(doc)).toBe('::: expand\nbody\n:::')
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
test('read: expand without attrs renders as a bare expand block', () => {
|
|
509
|
+
const doc: AdfDocument = {
|
|
510
|
+
version: 1,
|
|
511
|
+
type: 'doc',
|
|
512
|
+
content: [
|
|
513
|
+
{
|
|
514
|
+
type: 'expand',
|
|
515
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'body' }] }],
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
}
|
|
519
|
+
expect(adfToPlainText(doc)).toBe('::: expand\nbody\n:::')
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
test('write: unterminated ::: expand falls through to paragraph', () => {
|
|
523
|
+
const input = '::: expand title="oops"\nstray text without close'
|
|
524
|
+
const doc = plainTextToAdf(input)
|
|
525
|
+
// Both lines collapse into a single paragraph; no expand node.
|
|
526
|
+
expect(doc.content.every((b) => b.type !== 'expand')).toBe(true)
|
|
527
|
+
expect(doc.content[0]?.type).toBe('paragraph')
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
test('round-trip: typed-comment-shaped string is byte-stable', () => {
|
|
531
|
+
const input =
|
|
532
|
+
'garage-triage: **Accepted** — abpai/garage-band\n' +
|
|
533
|
+
'\n' +
|
|
534
|
+
'Increment current_count by 1.\n' +
|
|
535
|
+
'\n' +
|
|
536
|
+
'**Next:** Handing off to planner.\n' +
|
|
537
|
+
'\n' +
|
|
538
|
+
'::: expand title="garage-baton (machine-readable)"\n' +
|
|
539
|
+
'```garage-baton\n' +
|
|
540
|
+
'{"v":1,"accepted":true}\n' +
|
|
541
|
+
'```\n' +
|
|
542
|
+
':::'
|
|
543
|
+
expect(adfToPlainText(plainTextToAdf(input))).toBe(input)
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
test('round-trip: nested paragraph and bullet list inside expand survive', () => {
|
|
547
|
+
const input =
|
|
548
|
+
'::: expand title="details"\n' +
|
|
549
|
+
'first paragraph\n' +
|
|
550
|
+
'\n' +
|
|
551
|
+
'- bullet one\n' +
|
|
552
|
+
'- bullet two\n' +
|
|
553
|
+
':::'
|
|
554
|
+
expect(adfToPlainText(plainTextToAdf(input))).toBe(input)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
test('write: expand title preserves escaped quote', () => {
|
|
558
|
+
const input = '::: expand title="quoted \\"title\\""\nx\n:::'
|
|
559
|
+
const doc = plainTextToAdf(input)
|
|
560
|
+
const expand = doc.content[0] as AdfExpandNode
|
|
561
|
+
expect(expand.attrs?.title).toBe('quoted "title"')
|
|
562
|
+
// Round-trip emits the same escaping.
|
|
563
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
test('write: text followed by ::: expand on the next line is two blocks', () => {
|
|
567
|
+
const input = 'leading paragraph\n' + '::: expand title="x"\n' + 'inside\n' + ':::'
|
|
568
|
+
const doc = plainTextToAdf(input)
|
|
569
|
+
expect(doc.content).toHaveLength(2)
|
|
570
|
+
expect(doc.content[0]?.type).toBe('paragraph')
|
|
571
|
+
expect(doc.content[1]?.type).toBe('expand')
|
|
572
|
+
})
|
|
573
|
+
})
|
|
@@ -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()
|
|
@@ -36,4 +36,21 @@ describe('formatOutput', () => {
|
|
|
36
36
|
const output = formatOutput(result, true)
|
|
37
37
|
expect(output).toBe('Board initialized.')
|
|
38
38
|
})
|
|
39
|
+
|
|
40
|
+
test('formats comments in pretty mode', () => {
|
|
41
|
+
const output = formatOutput(
|
|
42
|
+
success([
|
|
43
|
+
{
|
|
44
|
+
id: 'c_1',
|
|
45
|
+
task_id: 't_1',
|
|
46
|
+
body: 'hello from cli',
|
|
47
|
+
author: 'alice',
|
|
48
|
+
created_at: '2026-04-26T00:00:00.000Z',
|
|
49
|
+
updated_at: '2026-04-26T00:00:00.000Z',
|
|
50
|
+
},
|
|
51
|
+
]),
|
|
52
|
+
true,
|
|
53
|
+
)
|
|
54
|
+
expect(output).toBe(' c_1 @alice hello from cli')
|
|
55
|
+
})
|
|
39
56
|
})
|
package/src/index.ts
CHANGED
|
@@ -139,6 +139,45 @@ async function routeTask(
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
async function routeComment(
|
|
143
|
+
provider: ReturnType<typeof createProvider>,
|
|
144
|
+
action: string | undefined,
|
|
145
|
+
positionals: string[],
|
|
146
|
+
): Promise<CliOutput> {
|
|
147
|
+
switch (action) {
|
|
148
|
+
case 'list': {
|
|
149
|
+
const id = positionals[2]
|
|
150
|
+
if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
|
|
151
|
+
return success(await provider.listComments(id))
|
|
152
|
+
}
|
|
153
|
+
case 'add': {
|
|
154
|
+
const id = positionals[2]
|
|
155
|
+
const body = positionals.slice(3).join(' ')
|
|
156
|
+
if (!id || !body) {
|
|
157
|
+
throw new KanbanError(
|
|
158
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
159
|
+
'Usage: kanban comment add <task-id> <body>',
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
return success(await provider.comment(id, body))
|
|
163
|
+
}
|
|
164
|
+
case 'update': {
|
|
165
|
+
const id = positionals[2]
|
|
166
|
+
const commentId = positionals[3]
|
|
167
|
+
const body = positionals.slice(4).join(' ')
|
|
168
|
+
if (!id || !commentId || !body) {
|
|
169
|
+
throw new KanbanError(
|
|
170
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
171
|
+
'Usage: kanban comment update <task-id> <comment-id> <body>',
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
return success(await provider.updateComment(id, commentId, body))
|
|
175
|
+
}
|
|
176
|
+
default:
|
|
177
|
+
throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown comment command '${action}'`)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
142
181
|
function routeColumn(
|
|
143
182
|
db: Database,
|
|
144
183
|
providerType: string,
|
|
@@ -298,6 +337,9 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
|
|
|
298
337
|
case 'task':
|
|
299
338
|
output = await routeTask(provider, action, positionals, values)
|
|
300
339
|
break
|
|
340
|
+
case 'comment':
|
|
341
|
+
output = await routeComment(provider, action, positionals)
|
|
342
|
+
break
|
|
301
343
|
case 'column':
|
|
302
344
|
output = routeColumn(db, provider.type, action, positionals, values)
|
|
303
345
|
break
|
|
@@ -335,6 +377,11 @@ Commands:
|
|
|
335
377
|
task assign <id> <user> Assign task
|
|
336
378
|
task prioritize <id> <lvl> Set priority
|
|
337
379
|
|
|
380
|
+
comment list <task-id> List comments on a task
|
|
381
|
+
comment add <task-id> <body> Create a comment
|
|
382
|
+
comment update <task-id> <comment-id> <body>
|
|
383
|
+
Update a comment
|
|
384
|
+
|
|
338
385
|
column add <name> Add column [--position n] [--color hex]
|
|
339
386
|
column list List columns
|
|
340
387
|
column rename <id> <name> Rename column
|
package/src/output.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CliOutput, BoardView, Task, TaskWithColumn, Column } from './types'
|
|
1
|
+
import type { CliOutput, BoardView, Task, TaskWithColumn, TaskComment, Column } from './types'
|
|
2
2
|
|
|
3
3
|
export function success<T>(data: T): CliOutput<T> {
|
|
4
4
|
return { ok: true, data }
|
|
@@ -25,12 +25,16 @@ function formatPrettyData(data: unknown): string {
|
|
|
25
25
|
if (Array.isArray(data)) {
|
|
26
26
|
if (data.length === 0) return 'No items found.'
|
|
27
27
|
if ('column_id' in data[0]) return data.map(formatTaskLine).join('\n')
|
|
28
|
+
if ('task_id' in data[0]) return data.map(formatCommentLine).join('\n')
|
|
28
29
|
if ('position' in data[0]) return data.map(formatColumnLine).join('\n')
|
|
29
30
|
return JSON.stringify(data, null, 2)
|
|
30
31
|
}
|
|
31
32
|
if (data && typeof data === 'object' && 'column_id' in data) {
|
|
32
33
|
return formatTaskDetail(data as TaskWithColumn)
|
|
33
34
|
}
|
|
35
|
+
if (data && typeof data === 'object' && 'task_id' in data) {
|
|
36
|
+
return formatCommentDetail(data as TaskComment)
|
|
37
|
+
}
|
|
34
38
|
if (data && typeof data === 'object' && 'moved' in data) {
|
|
35
39
|
return `Moved ${(data as { moved: number }).moved} task(s).`
|
|
36
40
|
}
|
|
@@ -79,6 +83,23 @@ function formatTaskDetail(task: Task): string {
|
|
|
79
83
|
return lines.join('\n')
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
function formatCommentLine(comment: TaskComment): string {
|
|
87
|
+
const author = comment.author ? ` @${comment.author}` : ''
|
|
88
|
+
return ` ${comment.id}${author} ${comment.body}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatCommentDetail(comment: TaskComment): string {
|
|
92
|
+
const lines = [
|
|
93
|
+
`Comment: ${comment.id}`,
|
|
94
|
+
`Task: ${comment.task_id}`,
|
|
95
|
+
...(comment.author ? [`Author: ${comment.author}`] : []),
|
|
96
|
+
`Body: ${comment.body}`,
|
|
97
|
+
`Created: ${comment.created_at}`,
|
|
98
|
+
`Updated: ${comment.updated_at}`,
|
|
99
|
+
]
|
|
100
|
+
return lines.join('\n')
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
function formatColumnLine(col: Column): string {
|
|
83
104
|
const color = col.color ? ` (${col.color})` : ''
|
|
84
105
|
return ` ${col.position}. ${col.name}${color} [${col.id}]`
|
|
@@ -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
|
|
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
|
-
//
|
|
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?:
|
|
28
|
+
marks?: AdfMark[]
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
export interface AdfUnknownInlineNode {
|
|
@@ -63,6 +68,12 @@ export interface AdfHeadingNode {
|
|
|
63
68
|
content?: AdfInlineNode[]
|
|
64
69
|
}
|
|
65
70
|
|
|
71
|
+
export interface AdfExpandNode {
|
|
72
|
+
type: 'expand'
|
|
73
|
+
attrs?: { title?: string }
|
|
74
|
+
content: AdfBlockNode[]
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
export interface AdfUnknownBlockNode {
|
|
67
78
|
type: string
|
|
68
79
|
[key: string]: unknown
|
|
@@ -74,6 +85,7 @@ export type AdfBlockNode =
|
|
|
74
85
|
| AdfOrderedListNode
|
|
75
86
|
| AdfCodeBlockNode
|
|
76
87
|
| AdfHeadingNode
|
|
88
|
+
| AdfExpandNode
|
|
77
89
|
| AdfUnknownBlockNode
|
|
78
90
|
|
|
79
91
|
// Public AdfNode union covers every node shape this module recognizes.
|
|
@@ -85,6 +97,18 @@ const ORDERED_MARKER = /^(\d+)\. (.*)$/
|
|
|
85
97
|
// no whitespace before it. Fence must occupy the whole line.
|
|
86
98
|
const FENCE_OPEN = /^```([^\s`]*)$/
|
|
87
99
|
const FENCE_CLOSE = /^```$/
|
|
100
|
+
// Expand wrapper: `::: expand` or `::: expand title="..."`. Quotes and
|
|
101
|
+
// backslashes in the title are escaped with a leading `\`.
|
|
102
|
+
const EXPAND_OPEN = /^::: expand(?: title="((?:\\.|[^"\\])*)")?$/
|
|
103
|
+
const EXPAND_CLOSE = /^:::$/
|
|
104
|
+
|
|
105
|
+
function unescapeTitle(raw: string): string {
|
|
106
|
+
return raw.replace(/\\(.)/g, '$1')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function escapeTitle(value: string): string {
|
|
110
|
+
return value.replace(/(["\\])/g, '\\$1')
|
|
111
|
+
}
|
|
88
112
|
|
|
89
113
|
function paragraphFromText(text: string): AdfParagraphNode {
|
|
90
114
|
if (text.length === 0) {
|
|
@@ -92,7 +116,7 @@ function paragraphFromText(text: string): AdfParagraphNode {
|
|
|
92
116
|
}
|
|
93
117
|
return {
|
|
94
118
|
type: 'paragraph',
|
|
95
|
-
content:
|
|
119
|
+
content: tokenizeInline(text),
|
|
96
120
|
}
|
|
97
121
|
}
|
|
98
122
|
|
|
@@ -103,17 +127,63 @@ function listItemFromText(text: string): AdfListItemNode {
|
|
|
103
127
|
}
|
|
104
128
|
}
|
|
105
129
|
|
|
130
|
+
const INLINE_MARK = /\*\*([^*\n]+)\*\*|\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g
|
|
131
|
+
|
|
132
|
+
function tokenizeInline(text: string): AdfTextNode[] {
|
|
133
|
+
const out: AdfTextNode[] = []
|
|
134
|
+
INLINE_MARK.lastIndex = 0
|
|
135
|
+
let cursor = 0
|
|
136
|
+
for (const match of text.matchAll(INLINE_MARK)) {
|
|
137
|
+
const start = match.index ?? 0
|
|
138
|
+
if (start > cursor) {
|
|
139
|
+
out.push({ type: 'text', text: text.slice(cursor, start) })
|
|
140
|
+
}
|
|
141
|
+
const boldText = match[1]
|
|
142
|
+
if (boldText !== undefined) {
|
|
143
|
+
out.push({
|
|
144
|
+
type: 'text',
|
|
145
|
+
text: boldText,
|
|
146
|
+
marks: [{ type: 'strong' }],
|
|
147
|
+
})
|
|
148
|
+
} else {
|
|
149
|
+
out.push({
|
|
150
|
+
type: 'text',
|
|
151
|
+
text: match[2]!,
|
|
152
|
+
marks: [{ type: 'link', attrs: { href: match[3]! } }],
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
cursor = start + match[0].length
|
|
156
|
+
}
|
|
157
|
+
if (cursor < text.length) {
|
|
158
|
+
out.push({ type: 'text', text: text.slice(cursor) })
|
|
159
|
+
}
|
|
160
|
+
return out
|
|
161
|
+
}
|
|
162
|
+
|
|
106
163
|
export function plainTextToAdf(text: string): AdfDocument {
|
|
107
164
|
if (text.length === 0) {
|
|
108
165
|
return { version: 1, type: 'doc', content: [] }
|
|
109
166
|
}
|
|
110
|
-
|
|
111
167
|
const lines = text.split('\n')
|
|
112
|
-
const blocks
|
|
168
|
+
const { blocks } = parseBlocks(lines, 0, () => false)
|
|
169
|
+
return { version: 1, type: 'doc', content: blocks }
|
|
170
|
+
}
|
|
113
171
|
|
|
114
|
-
|
|
172
|
+
interface ParseResult {
|
|
173
|
+
blocks: AdfBlockNode[]
|
|
174
|
+
// Index just past the last consumed line. When `stop` matches, this points
|
|
175
|
+
// at the stop line itself (the caller is responsible for stepping past it).
|
|
176
|
+
endIndex: number
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseBlocks(lines: string[], start: number, stop: (line: string) => boolean): ParseResult {
|
|
180
|
+
const blocks: AdfBlockNode[] = []
|
|
181
|
+
let i = start
|
|
115
182
|
while (i < lines.length) {
|
|
116
183
|
const line = lines[i] ?? ''
|
|
184
|
+
if (stop(line)) {
|
|
185
|
+
return { blocks, endIndex: i }
|
|
186
|
+
}
|
|
117
187
|
|
|
118
188
|
// Blank line separates blocks — consume and move on.
|
|
119
189
|
if (line === '') {
|
|
@@ -121,6 +191,19 @@ export function plainTextToAdf(text: string): AdfDocument {
|
|
|
121
191
|
continue
|
|
122
192
|
}
|
|
123
193
|
|
|
194
|
+
// Expand wrapper.
|
|
195
|
+
const expandOpen = line.match(EXPAND_OPEN)
|
|
196
|
+
if (expandOpen) {
|
|
197
|
+
const title = unescapeTitle(expandOpen[1] ?? '')
|
|
198
|
+
const inner = parseBlocks(lines, i + 1, (l) => EXPAND_CLOSE.test(l))
|
|
199
|
+
if (inner.endIndex < lines.length) {
|
|
200
|
+
blocks.push({ type: 'expand', attrs: { title }, content: inner.blocks })
|
|
201
|
+
i = inner.endIndex + 1
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
// Unterminated `::: expand` — fall through to paragraph.
|
|
205
|
+
}
|
|
206
|
+
|
|
124
207
|
// Fenced code block.
|
|
125
208
|
const fenceOpen = line.match(FENCE_OPEN)
|
|
126
209
|
if (fenceOpen) {
|
|
@@ -189,16 +272,19 @@ export function plainTextToAdf(text: string): AdfDocument {
|
|
|
189
272
|
continue
|
|
190
273
|
}
|
|
191
274
|
|
|
192
|
-
// Paragraph: consume until blank line or a
|
|
275
|
+
// Paragraph: consume until blank line, stop predicate, or a
|
|
276
|
+
// block-starting line.
|
|
193
277
|
const paragraphLines: string[] = [line]
|
|
194
278
|
i += 1
|
|
195
279
|
while (i < lines.length) {
|
|
196
280
|
const current = lines[i] ?? ''
|
|
197
281
|
if (
|
|
198
282
|
current === '' ||
|
|
283
|
+
stop(current) ||
|
|
199
284
|
BULLET_MARKER.test(current) ||
|
|
200
285
|
ORDERED_MARKER.test(current) ||
|
|
201
|
-
FENCE_OPEN.test(current)
|
|
286
|
+
FENCE_OPEN.test(current) ||
|
|
287
|
+
EXPAND_OPEN.test(current)
|
|
202
288
|
) {
|
|
203
289
|
break
|
|
204
290
|
}
|
|
@@ -208,21 +294,82 @@ export function plainTextToAdf(text: string): AdfDocument {
|
|
|
208
294
|
blocks.push(paragraphFromText(paragraphLines.join('\n')))
|
|
209
295
|
}
|
|
210
296
|
|
|
211
|
-
return {
|
|
297
|
+
return { blocks, endIndex: i }
|
|
212
298
|
}
|
|
213
299
|
|
|
214
|
-
function inlineText(
|
|
300
|
+
function inlineText(
|
|
301
|
+
nodes: AdfInlineNode[] | undefined,
|
|
302
|
+
opts: { renderMarks?: boolean } = {},
|
|
303
|
+
): string {
|
|
215
304
|
if (!nodes) return ''
|
|
305
|
+
const renderMarks = opts.renderMarks ?? true
|
|
216
306
|
let out = ''
|
|
217
|
-
for (
|
|
307
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
308
|
+
const node = nodes[i]!
|
|
218
309
|
if (node.type === 'text') {
|
|
219
|
-
|
|
310
|
+
const textNode = node as AdfTextNode
|
|
311
|
+
if (!renderMarks) {
|
|
312
|
+
out += textNode.text
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
const nextNode = nodes[i + 1]
|
|
316
|
+
const labelColon = readPlainTextLeadingColon(nextNode)
|
|
317
|
+
if (labelColon && hasMark(textNode, 'strong')) {
|
|
318
|
+
out += renderTextNode({ ...textNode, text: `${textNode.text}:` })
|
|
319
|
+
out += labelColon.remainder
|
|
320
|
+
i += 1
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
out += renderTextNode(textNode)
|
|
324
|
+
continue
|
|
220
325
|
}
|
|
221
|
-
|
|
326
|
+
if (node.type === 'inlineCard') {
|
|
327
|
+
const url = readCardUrl(node)
|
|
328
|
+
if (url) out += url
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
if (node.type === 'hardBreak') {
|
|
332
|
+
out += '\n'
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
// Other unknown inline nodes (mentions, emoji, etc.) are skipped.
|
|
222
336
|
}
|
|
223
337
|
return out
|
|
224
338
|
}
|
|
225
339
|
|
|
340
|
+
function hasMark(node: AdfTextNode, type: string): boolean {
|
|
341
|
+
return node.marks?.some((m) => m.type === type) ?? false
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function readPlainTextLeadingColon(node: AdfInlineNode | undefined): { remainder: string } | null {
|
|
345
|
+
if (!node || node.type !== 'text') return null
|
|
346
|
+
const textNode = node as AdfTextNode
|
|
347
|
+
if (textNode.marks && textNode.marks.length > 0) return null
|
|
348
|
+
if (!textNode.text.startsWith(':')) return null
|
|
349
|
+
return { remainder: textNode.text.slice(1) }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function renderTextNode(node: AdfTextNode): string {
|
|
353
|
+
if (!node.marks || node.marks.length === 0) return node.text
|
|
354
|
+
const link = node.marks.find((m) => m.type === 'link')
|
|
355
|
+
let out = node.text
|
|
356
|
+
if (link) {
|
|
357
|
+
const href = link.attrs?.['href']
|
|
358
|
+
if (typeof href === 'string' && href.length > 0) out = `[${out}](${href})`
|
|
359
|
+
}
|
|
360
|
+
if (hasMark(node, 'strong')) {
|
|
361
|
+
out = `**${out}**`
|
|
362
|
+
}
|
|
363
|
+
return out
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function readCardUrl(node: AdfInlineNode | AdfBlockNode): string | null {
|
|
367
|
+
const attrs = (node as { attrs?: Record<string, unknown> }).attrs
|
|
368
|
+
if (!attrs) return null
|
|
369
|
+
const url = attrs['url']
|
|
370
|
+
return typeof url === 'string' && url.length > 0 ? url : null
|
|
371
|
+
}
|
|
372
|
+
|
|
226
373
|
function listItemInnerText(item: AdfListItemNode): string {
|
|
227
374
|
// Each list item wraps a paragraph (or nested blocks). We flatten to the
|
|
228
375
|
// first paragraph's inline text, which is all the write path produces.
|
|
@@ -252,24 +399,41 @@ function renderBlock(node: AdfBlockNode): string | null {
|
|
|
252
399
|
case 'codeBlock': {
|
|
253
400
|
const code = node as AdfCodeBlockNode
|
|
254
401
|
const language = code.attrs?.language ?? ''
|
|
255
|
-
const body = inlineText(code.content)
|
|
402
|
+
const body = inlineText(code.content, { renderMarks: false })
|
|
256
403
|
const fence = language.length > 0 ? `\`\`\`${language}` : '```'
|
|
257
404
|
return `${fence}\n${body}\n\`\`\``
|
|
258
405
|
}
|
|
259
406
|
case 'heading':
|
|
260
407
|
return inlineText((node as AdfHeadingNode).content)
|
|
408
|
+
case 'expand': {
|
|
409
|
+
const expand = node as AdfExpandNode
|
|
410
|
+
const title = expand.attrs?.title ?? ''
|
|
411
|
+
const open = title.length > 0 ? `::: expand title="${escapeTitle(title)}"` : '::: expand'
|
|
412
|
+
const inner = renderBlocks(expand.content)
|
|
413
|
+
const body = inner.length > 0 ? `${open}\n${inner}\n:::` : `${open}\n:::`
|
|
414
|
+
return body
|
|
415
|
+
}
|
|
416
|
+
case 'blockCard':
|
|
417
|
+
case 'embedCard': {
|
|
418
|
+
const url = readCardUrl(node)
|
|
419
|
+
return url ?? null
|
|
420
|
+
}
|
|
261
421
|
default:
|
|
262
422
|
// Unknown block node — skip entirely, never throw.
|
|
263
423
|
return null
|
|
264
424
|
}
|
|
265
425
|
}
|
|
266
426
|
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
for (const block of
|
|
427
|
+
function renderBlocks(nodes: AdfBlockNode[]): string {
|
|
428
|
+
const out: string[] = []
|
|
429
|
+
for (const block of nodes) {
|
|
270
430
|
const text = renderBlock(block)
|
|
271
431
|
if (text === null) continue
|
|
272
|
-
|
|
432
|
+
out.push(text)
|
|
273
433
|
}
|
|
274
|
-
return
|
|
434
|
+
return out.join('\n\n')
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function adfToPlainText(doc: AdfDocument): string {
|
|
438
|
+
return renderBlocks(doc.content)
|
|
275
439
|
}
|