@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
package/README.md CHANGED
@@ -2,11 +2,21 @@
2
2
 
3
3
  [![CI](https://github.com/abpai/agent-kanban/actions/workflows/ci.yml/badge.svg)](https://github.com/abpai/agent-kanban/actions/workflows/ci.yml)
4
4
 
5
- Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.
5
+ `agent-kanban` exists because browser-first project tools are a bad control
6
+ plane for agents, shell scripts, and CI jobs. If automation has to click
7
+ through a web app, scrape HTML, or learn a different integration for every
8
+ tracker, the setup gets brittle fast.
6
9
 
7
- ## Why
10
+ This repo gives you one small contract across three modes: a local SQLite board,
11
+ Linear, and Jira Cloud. The CLI stays the same. The JSON envelope stays the
12
+ same. Humans still get an optional dashboard when they want a visual pass.
8
13
 
9
- Most project-management tools are built for humans clicking through UIs. `agent-kanban` is built for **CLI-first workflows** — AI agents and scripts get deterministic JSON they can parse, humans get a pretty-printed view and a web dashboard. Runs against a local SQLite file, a Linear backend, or a Jira Cloud project.
14
+ That buys you a few things that are easy to miss at first:
15
+
16
+ - You can prototype an agent against a local board, then point the same workflow at Linear or Jira later.
17
+ - Remote modes use webhooks plus polling fallback, so missed events are less painful than with one-shot scripts or browser automation.
18
+ - Local mode needs no external database or service, which makes scratch boards, demos, and CI setups much easier to spin up.
19
+ - The repo also includes a reusable MCP layer, so sibling tools can reuse the same tracker semantics instead of growing their own tracker adapter.
10
20
 
11
21
  ## Documentation
12
22
 
@@ -107,6 +117,13 @@ kanban board view
107
117
  | webhooks | no | yes | yes |
108
118
 
109
119
  Linear tasks carry an `externalRef` (e.g. `TEAM-123`) and a `url`. Commands accept either the internal ID or the external ref.
120
+ Jira tasks can also be addressed by issue key (for example `ENG-123`).
121
+
122
+ Local mode is still the only mode with built-in metrics, config mutation, bulk
123
+ cleanup, and the dashboard/bootstrap activity feed. Linear and Jira do keep
124
+ remote issue history and comment counts in their cache tables for sync and
125
+ provider-backed flows, but those modes do not expose the same local analytics
126
+ surface.
110
127
 
111
128
  Unsupported operations return error code `UNSUPPORTED_OPERATION` with exit code 1.
112
129
 
@@ -223,6 +240,16 @@ kanban serve --port 8080
223
240
  kanban serve --tunnel # optional public URL for webhook testing
224
241
  ```
225
242
 
243
+ ### mcp
244
+
245
+ ```bash
246
+ kanban mcp
247
+ kanban mcp --db /path/to/board.db
248
+ ```
249
+
250
+ Runs the bundled MCP server over stdio for local MCP clients such as Claude
251
+ Desktop. See [`docs/mcp.md`](docs/mcp.md) for the tool surface and caveats.
252
+
226
253
  ## Global flags
227
254
 
228
255
  | Flag | Description |
@@ -278,8 +305,10 @@ Comment routes:
278
305
  ## Reusable MCP core
279
306
 
280
307
  The repo also includes a reusable tracker MCP implementation under `src/mcp/`.
281
- It is intended for sibling workspaces and in-repo consumers rather than the
282
- `kanban` CLI itself.
308
+ There are two ways to use it today:
309
+
310
+ - run `kanban mcp` for a bundled stdio MCP server
311
+ - import the helpers in `src/mcp/` from a sibling workspace or in-repo consumer
283
312
 
284
313
  See [`docs/mcp.md`](docs/mcp.md) for the current default tool set, the auth and
285
314
  policy model, and the caveats around source-level imports and `kanban serve`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andypai/agent-kanban",
3
- "version": "0.3.0",
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": {
@@ -9,8 +9,8 @@ import {
9
9
  moveTask,
10
10
  bulkMoveAll,
11
11
  bulkClearDone,
12
- } from '../db.ts'
13
- import { listActivity } from '../activity.ts'
12
+ } from '../db'
13
+ import { listActivity } from '../activity'
14
14
 
15
15
  let db: Database
16
16
 
@@ -1,8 +1,8 @@
1
1
  import { beforeEach, describe, expect, test } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { initSchema, seedDefaultColumns, addTask } from '../db.ts'
4
- import { handleRequest } from '../api.ts'
5
- import { createProvider } from '../providers/index.ts'
3
+ import { initSchema, seedDefaultColumns, addTask } from '../db'
4
+ import { handleRequest } from '../api'
5
+ import { createProvider } from '../providers/index'
6
6
 
7
7
  let db: Database
8
8
  let provider: ReturnType<typeof createProvider>
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, test, beforeEach } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { getBoardView, initSchema, seedDefaultColumns } from '../../db.ts'
4
- import { boardInit, boardReset } from '../../commands/board.ts'
5
- import { KanbanError } from '../../errors.ts'
3
+ import { getBoardView, initSchema, seedDefaultColumns } from '../../db'
4
+ import { boardInit, boardReset } from '../../commands/board'
5
+ import { KanbanError } from '../../errors'
6
6
 
7
7
  let db: Database
8
8
 
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, test, beforeEach } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { initSchema, seedDefaultColumns, addTask, listTasks } from '../../db.ts'
4
- import { bulkMoveAllCmd, bulkClearDoneCmd } from '../../commands/bulk.ts'
5
- import { KanbanError } from '../../errors.ts'
3
+ import { initSchema, seedDefaultColumns, addTask, listTasks } from '../../db'
4
+ import { bulkMoveAllCmd, bulkClearDoneCmd } from '../../commands/bulk'
5
+ import { KanbanError } from '../../errors'
6
6
 
7
7
  let db: Database
8
8
 
@@ -1,15 +1,15 @@
1
1
  import { describe, expect, test, beforeEach } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { initSchema, seedDefaultColumns, addTask } from '../../db.ts'
3
+ import { initSchema, seedDefaultColumns, addTask } from '../../db'
4
4
  import {
5
5
  columnAdd,
6
6
  columnList,
7
7
  columnRename,
8
8
  columnReorder,
9
9
  columnDelete,
10
- } from '../../commands/column.ts'
11
- import { KanbanError } from '../../errors.ts'
12
- import type { Column } from '../../types.ts'
10
+ } from '../../commands/column'
11
+ import { KanbanError } from '../../errors'
12
+ import type { Column } from '../../types'
13
13
 
14
14
  let db: Database
15
15
 
@@ -1,8 +1,8 @@
1
1
  import { beforeEach, describe, expect, test } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { initSchema, seedDefaultColumns } from '../db.ts'
4
- import { LocalProvider } from '../providers/local.ts'
5
- import { ErrorCode, KanbanError } from '../errors.ts'
3
+ import { initSchema, seedDefaultColumns } from '../db'
4
+ import { LocalProvider } from '../providers/local'
5
+ import { ErrorCode, KanbanError } from '../errors'
6
6
 
7
7
  let db: Database
8
8
  let provider: LocalProvider
@@ -25,8 +25,8 @@ import {
25
25
  bulkClearDone,
26
26
  resetBoard,
27
27
  migrateSchema,
28
- } from '../db.ts'
29
- import { KanbanError } from '../errors.ts'
28
+ } from '../db'
29
+ import { KanbanError } from '../errors'
30
30
 
31
31
  let db: Database
32
32
  let originalCwd: string
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { generateId } from '../id.ts'
2
+ import { generateId } from '../id'
3
3
 
4
4
  describe('generateId', () => {
5
5
  test('generates task IDs with t_ prefix', () => {
@@ -3,9 +3,9 @@ import { Database } from 'bun:sqlite'
3
3
  import { mkdtempSync, rmSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
- import { ErrorCode, KanbanError, type ErrorCodeValue } from '../errors.ts'
7
- import type { Task } from '../types.ts'
8
- import { parseServeArgs, run } from '../index.ts'
6
+ import { ErrorCode, KanbanError, type ErrorCodeValue } from '../errors'
7
+ import type { Task } from '../types'
8
+ import { parseServeArgs, run } from '../index'
9
9
 
10
10
  async function withTempDb(runTest: (dbPath: string) => Promise<void>): Promise<void> {
11
11
  const dir = mkdtempSync(join(tmpdir(), 'kanban-run-'))
@@ -1,5 +1,31 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { adfToPlainText, plainTextToAdf, type AdfDocument } from '../providers/jira-adf.ts'
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', () => {
@@ -91,6 +117,18 @@ describe('plainTextToAdf / adfToPlainText', () => {
91
117
  expect(adfToPlainText(doc)).toBe(input)
92
118
  })
93
119
 
120
+ test('garage-baton fenced comment round-trips byte-for-byte', () => {
121
+ const input =
122
+ 'garage-triage: ✅ Accepted — abpai/garage-band\n\nIncrement SMOKE_TEST_TASK.md from current_count=1 to 2.\n\n```garage-baton\n{"v":1,"accepted":true,"repo":{"owner":"abpai","name":"garage-band"},"questions":[],"summary":"Increment smoke counter."}\n```'
123
+ const doc = plainTextToAdf(input)
124
+ const code = doc.content.find((node) => node.type === 'codeBlock') as
125
+ | { type: string; attrs?: { language?: string } }
126
+ | undefined
127
+
128
+ expect(code?.attrs?.language).toBe('garage-baton')
129
+ expect(adfToPlainText(doc)).toBe(input)
130
+ })
131
+
94
132
  test('mixed content (paragraph + list + code block) round-trip', () => {
95
133
  const input = 'intro paragraph\n\n- a\n- b\n\n```js\nconsole.log(1)\n```\n\nouttro'
96
134
  const doc = plainTextToAdf(input)
@@ -142,7 +180,7 @@ describe('plainTextToAdf / adfToPlainText', () => {
142
180
  expect(adfToPlainText(doc)).toBe('before\n\nafter')
143
181
  })
144
182
 
145
- test('inline text marks are stripped on read', () => {
183
+ test('strong + link marks survive on read as markdown', () => {
146
184
  const doc: AdfDocument = {
147
185
  version: 1,
148
186
  type: 'doc',
@@ -151,12 +189,240 @@ describe('plainTextToAdf / adfToPlainText', () => {
151
189
  type: 'paragraph',
152
190
  content: [
153
191
  { type: 'text', text: 'bold', marks: [{ type: 'strong' }] },
154
- { 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' },
155
421
  ],
156
422
  },
157
423
  ],
158
424
  }
159
- expect(adfToPlainText(doc)).toBe('bold normal')
425
+ expect(adfToPlainText(doc)).toBe('first line\nsecond line')
160
426
  })
161
427
 
162
428
  test('bullet item containing digits-dot substring is still a bullet', () => {
@@ -15,7 +15,7 @@ import {
15
15
  saveJiraSyncMeta,
16
16
  upsertJiraIssues,
17
17
  upsertJiraUsers,
18
- } from '../providers/jira-cache.ts'
18
+ } from '../providers/jira-cache'
19
19
 
20
20
  let db: Database
21
21
 
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test'
2
2
  import { Buffer } from 'node:buffer'
3
- import { ErrorCode, KanbanError } from '../errors.ts'
4
- import { JiraClient } from '../providers/jira-client.ts'
3
+ import { ErrorCode, KanbanError } from '../errors'
4
+ import { JiraClient } from '../providers/jira-client'
5
5
 
6
6
  const origFetch = globalThis.fetch
7
7
  let lastRequest: { url: string; init?: RequestInit } | null = null
@@ -1,13 +1,13 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { JiraClient } from '../providers/jira-client.ts'
4
- import { JiraProvider, type JiraProviderConfig } from '../providers/jira.ts'
3
+ import { JiraClient } from '../providers/jira-client'
4
+ import { JiraProvider, type JiraProviderConfig } from '../providers/jira'
5
5
  import {
6
6
  initJiraCacheSchema,
7
7
  saveJiraSyncMeta,
8
8
  saveTeamInfo,
9
9
  upsertJiraIssues,
10
- } from '../providers/jira-cache.ts'
10
+ } from '../providers/jira-cache'
11
11
 
12
12
  const baseConfig: JiraProviderConfig = {
13
13
  baseUrl: 'https://example.atlassian.net',
@@ -1,8 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { ErrorCode, KanbanError } from '../errors.ts'
4
- import { JiraClient } from '../providers/jira-client.ts'
5
- import { JiraProvider, type JiraProviderConfig } from '../providers/jira.ts'
3
+ import { ErrorCode, KanbanError } from '../errors'
4
+ import { JiraClient } from '../providers/jira-client'
5
+ import { JiraProvider, type JiraProviderConfig } from '../providers/jira'
6
6
  import {
7
7
  initJiraCacheSchema,
8
8
  replaceJiraColumns,
@@ -12,7 +12,7 @@ import {
12
12
  saveTeamInfo,
13
13
  upsertJiraIssues,
14
14
  upsertJiraUsers,
15
- } from '../providers/jira-cache.ts'
15
+ } from '../providers/jira-cache'
16
16
 
17
17
  type FetchInit = RequestInit | undefined
18
18
  type StubCall = { url: string; method: string; body: string | null }
@@ -1,8 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { ErrorCode, KanbanError } from '../errors.ts'
4
- import { JiraClient } from '../providers/jira-client.ts'
5
- import { JiraProvider, type JiraProviderConfig } from '../providers/jira.ts'
3
+ import { ErrorCode, KanbanError } from '../errors'
4
+ import { JiraClient } from '../providers/jira-client'
5
+ import { JiraProvider, type JiraProviderConfig } from '../providers/jira'
6
6
  import {
7
7
  getCachedColumns,
8
8
  getCachedTasks,
@@ -11,7 +11,7 @@ import {
11
11
  saveJiraSyncMeta,
12
12
  saveTeamInfo,
13
13
  initJiraCacheSchema,
14
- } from '../providers/jira-cache.ts'
14
+ } from '../providers/jira-cache'
15
15
 
16
16
  type FetchInit = RequestInit | undefined
17
17
  type StubCall = { url: string; init?: FetchInit }
@@ -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' },
@@ -353,7 +354,7 @@ describe('JiraProvider read path', () => {
353
354
  // Sync first (populates statuses-based columns)
354
355
  await provider.getBoard()
355
356
  // Overwrite with the test-scenario columns + issues directly.
356
- const { replaceJiraColumns, upsertJiraIssues } = await import('../providers/jira-cache.ts')
357
+ const { replaceJiraColumns, upsertJiraIssues } = await import('../providers/jira-cache')
357
358
  replaceJiraColumns(db, [
358
359
  {
359
360
  id: 'board:3:Done',
@@ -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()