@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.
- package/README.md +34 -5
- package/package.json +1 -1
- package/src/__tests__/activity.test.ts +2 -2
- package/src/__tests__/api.test.ts +3 -3
- package/src/__tests__/commands/board.test.ts +3 -3
- package/src/__tests__/commands/bulk.test.ts +3 -3
- package/src/__tests__/commands/column.test.ts +4 -4
- package/src/__tests__/conflict.test.ts +3 -3
- package/src/__tests__/db.test.ts +2 -2
- package/src/__tests__/id.test.ts +1 -1
- package/src/__tests__/index.test.ts +3 -3
- package/src/__tests__/jira-adf.test.ts +270 -4
- package/src/__tests__/jira-cache.test.ts +1 -1
- package/src/__tests__/jira-client.test.ts +2 -2
- package/src/__tests__/jira-provider-comment.test.ts +3 -3
- package/src/__tests__/jira-provider-mutations.test.ts +4 -4
- package/src/__tests__/jira-provider-read.test.ts +52 -6
- package/src/__tests__/jira-wiring.test.ts +3 -3
- package/src/__tests__/linear-cache-description-activity.test.ts +1 -1
- package/src/__tests__/linear-provider-comment.test.ts +2 -2
- package/src/__tests__/linear-provider-sync.test.ts +4 -9
- package/src/__tests__/local-provider-comment.test.ts +2 -2
- package/src/__tests__/mcp-core.test.ts +4 -4
- package/src/__tests__/mcp-server.test.ts +3 -3
- package/src/__tests__/metrics.test.ts +2 -2
- package/src/__tests__/output.test.ts +1 -1
- package/src/__tests__/provider-capabilities.test.ts +40 -0
- package/src/__tests__/server.test.ts +3 -10
- package/src/__tests__/webhooks.test.ts +6 -6
- package/src/activity.ts +2 -2
- package/src/api.ts +3 -3
- package/src/commands/board.ts +4 -4
- package/src/commands/bulk.ts +4 -4
- package/src/commands/column.ts +4 -4
- package/src/commands/mcp.ts +3 -3
- package/src/config.ts +1 -1
- package/src/db.ts +4 -4
- package/src/index.ts +13 -19
- package/src/mcp/core.ts +4 -4
- package/src/mcp/errors.ts +1 -1
- package/src/mcp/index.ts +6 -6
- package/src/mcp/server.ts +3 -3
- package/src/mcp/types.ts +2 -2
- package/src/metrics.ts +1 -1
- package/src/output.ts +1 -1
- package/src/providers/capabilities.ts +21 -31
- package/src/providers/errors.ts +1 -1
- package/src/providers/index.ts +6 -6
- package/src/providers/jira-adf.ts +116 -12
- package/src/providers/jira-cache.ts +1 -1
- package/src/providers/jira-client.ts +2 -2
- package/src/providers/jira.ts +9 -14
- package/src/providers/linear-cache.ts +1 -1
- package/src/providers/linear-client.ts +3 -6
- package/src/providers/linear.ts +8 -13
- package/src/providers/local.ts +8 -8
- package/src/providers/types.ts +2 -2
- package/src/server.ts +2 -2
- package/src/types.ts +1 -0
package/README.md
CHANGED
|
@@ -2,11 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/abpai/agent-kanban/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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.
|
|
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,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
|
|
4
|
-
import { handleRequest } from '../api
|
|
5
|
-
import { createProvider } from '../providers/index
|
|
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
|
|
4
|
-
import { boardInit, boardReset } from '../../commands/board
|
|
5
|
-
import { KanbanError } from '../../errors
|
|
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
|
|
4
|
-
import { bulkMoveAllCmd, bulkClearDoneCmd } from '../../commands/bulk
|
|
5
|
-
import { KanbanError } from '../../errors
|
|
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
|
|
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
|
|
11
|
-
import { KanbanError } from '../../errors
|
|
12
|
-
import type { Column } from '../../types
|
|
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
|
|
4
|
-
import { LocalProvider } from '../providers/local
|
|
5
|
-
import { ErrorCode, KanbanError } from '../errors
|
|
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
|
package/src/__tests__/db.test.ts
CHANGED
package/src/__tests__/id.test.ts
CHANGED
|
@@ -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
|
|
7
|
-
import type { Task } from '../types
|
|
8
|
-
import { parseServeArgs, run } from '../index
|
|
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 {
|
|
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('
|
|
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: '
|
|
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('
|
|
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', () => {
|
|
@@ -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
|
|
4
|
-
import { JiraClient } from '../providers/jira-client
|
|
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
|
|
4
|
-
import { JiraProvider, type JiraProviderConfig } from '../providers/jira
|
|
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
|
|
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
|
|
4
|
-
import { JiraClient } from '../providers/jira-client
|
|
5
|
-
import { JiraProvider, type JiraProviderConfig } from '../providers/jira
|
|
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
|
|
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
|
|
4
|
-
import { JiraClient } from '../providers/jira-client
|
|
5
|
-
import { JiraProvider, type JiraProviderConfig } from '../providers/jira
|
|
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
|
|
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
|
|
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()
|