@andypai/agent-kanban 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/board-utils.test.ts +37 -1
- package/src/__tests__/jira-provider-read.test.ts +29 -0
- package/src/__tests__/postgres-jira-provider.test.ts +59 -1
- package/src/providers/jira-cache.ts +64 -1
- package/src/providers/jira.ts +4 -19
- package/src/providers/postgres-jira.ts +9 -19
- package/ui/dist/assets/index-CFhtfqCn.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-qNVJ6clH.js +0 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.",
|
|
5
5
|
"homepage": "https://github.com/abpai/agent-kanban#readme",
|
|
6
6
|
"repository": {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
moveTaskInBoard,
|
|
4
|
+
replaceTask,
|
|
5
|
+
upsertTaskInColumn,
|
|
6
|
+
} from '../../ui/src/components/boardUtils'
|
|
3
7
|
import type { BoardView, Task } from '../../ui/src/types'
|
|
4
8
|
|
|
5
9
|
function makeTask(
|
|
@@ -97,4 +101,36 @@ describe('boardUtils', () => {
|
|
|
97
101
|
expect(nextBoard.columns[0]!.tasks.map((task) => task.id)).toEqual(['t-1', 'tmp-1', 't-2'])
|
|
98
102
|
expect(nextBoard.columns[0]!.tasks[0]!.title).toBe('First edited')
|
|
99
103
|
})
|
|
104
|
+
|
|
105
|
+
test('moveTaskInBoard accepts a column id when column names repeat', () => {
|
|
106
|
+
const board: BoardView = {
|
|
107
|
+
columns: [
|
|
108
|
+
{
|
|
109
|
+
id: 'board:1006:Backlog',
|
|
110
|
+
name: 'Backlog',
|
|
111
|
+
position: 0,
|
|
112
|
+
color: null,
|
|
113
|
+
created_at: '',
|
|
114
|
+
updated_at: '',
|
|
115
|
+
tasks: [
|
|
116
|
+
makeTask({ id: 't-1', title: 'First', column_id: 'board:1006:Backlog', position: 0 }),
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: 'board:1006:Backlog:1',
|
|
121
|
+
name: 'Backlog',
|
|
122
|
+
position: 1,
|
|
123
|
+
color: null,
|
|
124
|
+
created_at: '',
|
|
125
|
+
updated_at: '',
|
|
126
|
+
tasks: [],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const nextBoard = moveTaskInBoard(board, 't-1', 'board:1006:Backlog:1')
|
|
132
|
+
|
|
133
|
+
expect(nextBoard.columns[0]!.tasks).toEqual([])
|
|
134
|
+
expect(nextBoard.columns[1]!.tasks.map((task) => task.id)).toEqual(['t-1'])
|
|
135
|
+
})
|
|
100
136
|
})
|
|
@@ -229,6 +229,35 @@ describe('JiraProvider read path', () => {
|
|
|
229
229
|
expect(decodeColumnStatusIds(cols[0]!)).toEqual(['10001', '10002'])
|
|
230
230
|
})
|
|
231
231
|
|
|
232
|
+
test('sync keeps duplicate Jira board column names as distinct cached columns', async () => {
|
|
233
|
+
const { provider } = makeProviderWithBoard(
|
|
234
|
+
standardRoutes({
|
|
235
|
+
boardCfg: {
|
|
236
|
+
id: 1006,
|
|
237
|
+
name: 'ENG Board',
|
|
238
|
+
columnConfig: {
|
|
239
|
+
columns: [
|
|
240
|
+
{ name: 'Backlog', statuses: [{ id: '10001' }] },
|
|
241
|
+
{ name: 'Backlog', statuses: [{ id: '10002' }] },
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
}),
|
|
246
|
+
1006,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
await provider.getBoard()
|
|
250
|
+
|
|
251
|
+
const cols = getCachedColumns(db)
|
|
252
|
+
expect(cols.map((column) => column.id)).toEqual(['board:1006:Backlog', 'board:1006:Backlog:1'])
|
|
253
|
+
expect(cols.map((column) => decodeColumnStatusIds(column))).toEqual([['10001'], ['10002']])
|
|
254
|
+
await expect(provider.listTasks({ column: 'Backlog' })).rejects.toMatchObject({
|
|
255
|
+
code: ErrorCode.COLUMN_NOT_FOUND,
|
|
256
|
+
message: expect.stringContaining('ambiguous'),
|
|
257
|
+
})
|
|
258
|
+
expect(await provider.listTasks({ column: 'board:1006:Backlog' })).toEqual([])
|
|
259
|
+
})
|
|
260
|
+
|
|
232
261
|
test('sync populates columns from statuses when boardId is absent', async () => {
|
|
233
262
|
const { provider } = makeProvider(standardRoutes({}))
|
|
234
263
|
await provider.getBoard()
|
|
@@ -67,7 +67,7 @@ function makeIssue(
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function standardRoutes(): StubRoute[] {
|
|
70
|
+
function standardRoutes(opts: { boardCfg?: unknown } = {}): StubRoute[] {
|
|
71
71
|
const issues = [makeIssue()]
|
|
72
72
|
const comments: Record<
|
|
73
73
|
string,
|
|
@@ -100,6 +100,19 @@ function standardRoutes(): StubRoute[] {
|
|
|
100
100
|
match: (url) => url.includes('/rest/api/3/project/ENG'),
|
|
101
101
|
handler: () => jsonResponse(projectFixture),
|
|
102
102
|
},
|
|
103
|
+
{
|
|
104
|
+
match: (url) => url.includes('/rest/agile/1.0/board/'),
|
|
105
|
+
handler: () =>
|
|
106
|
+
jsonResponse(
|
|
107
|
+
opts.boardCfg ?? {
|
|
108
|
+
id: 1006,
|
|
109
|
+
name: 'ENG Board',
|
|
110
|
+
columnConfig: {
|
|
111
|
+
columns: [{ name: 'Done', statuses: [{ id: '10' }, { id: '20' }] }],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
),
|
|
115
|
+
},
|
|
103
116
|
{
|
|
104
117
|
match: (url) => url.includes('/rest/api/3/user/assignable/search'),
|
|
105
118
|
handler: () => jsonResponse([{ accountId: 'a1', displayName: 'Alice', active: true }]),
|
|
@@ -334,6 +347,51 @@ describe('postgres jira provider', () => {
|
|
|
334
347
|
expect(body.fields?.labels).toEqual(['garage-smoke', 'garage-owner-local'])
|
|
335
348
|
})
|
|
336
349
|
|
|
350
|
+
pgTest('keeps duplicate Jira board column names as distinct cached columns', async () => {
|
|
351
|
+
expect(sql).not.toBeNull()
|
|
352
|
+
if (!sql) throw new Error('expected postgres test connection')
|
|
353
|
+
const stub = jiraFetchStub(
|
|
354
|
+
standardRoutes({
|
|
355
|
+
boardCfg: {
|
|
356
|
+
id: 1006,
|
|
357
|
+
name: 'ENG Board',
|
|
358
|
+
columnConfig: {
|
|
359
|
+
columns: [
|
|
360
|
+
{ name: 'Backlog', statuses: [{ id: '10' }] },
|
|
361
|
+
{ name: 'Backlog', statuses: [{ id: '20' }] },
|
|
362
|
+
],
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
}),
|
|
366
|
+
)
|
|
367
|
+
globalThis.fetch = stub.fn
|
|
368
|
+
const provider = new PostgresJiraProvider(sql, {
|
|
369
|
+
baseUrl: 'https://example.atlassian.net',
|
|
370
|
+
email: 'user@example.com',
|
|
371
|
+
apiToken: 'token',
|
|
372
|
+
projectKey: 'ENG',
|
|
373
|
+
boardId: 1006,
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const board = await provider.getBoard()
|
|
377
|
+
|
|
378
|
+
expect(board.columns.map((column) => column.id)).toEqual([
|
|
379
|
+
'board:1006:Backlog',
|
|
380
|
+
'board:1006:Backlog:1',
|
|
381
|
+
])
|
|
382
|
+
expect(board.columns.map((column) => column.tasks.map((task) => task.externalRef))).toEqual([
|
|
383
|
+
['ENG-1'],
|
|
384
|
+
[],
|
|
385
|
+
])
|
|
386
|
+
await expect(provider.listTasks({ column: 'Backlog' })).rejects.toMatchObject({
|
|
387
|
+
code: 'COLUMN_NOT_FOUND',
|
|
388
|
+
message: expect.stringContaining('ambiguous'),
|
|
389
|
+
})
|
|
390
|
+
expect(
|
|
391
|
+
(await provider.listTasks({ column: 'board:1006:Backlog' })).map((task) => task.id),
|
|
392
|
+
).toEqual(['jira:10001'])
|
|
393
|
+
})
|
|
394
|
+
|
|
337
395
|
pgTest('moves Jira tasks through Postgres storage', async () => {
|
|
338
396
|
const moved = expectOk<Task>(await run(['task', 'move', 'ENG-1', 'Done']))
|
|
339
397
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import { ErrorCode, KanbanError } from '../errors'
|
|
2
3
|
import type { BoardView, ProviderTeamInfo, Task } from '../types'
|
|
3
4
|
|
|
4
5
|
// Column ids are prefixed to avoid collisions across sources:
|
|
5
|
-
// - board-sourced columns: 'board:<boardId>:<columnName>'
|
|
6
|
+
// - board-sourced columns: 'board:<boardId>:<columnName>' with an index suffix
|
|
7
|
+
// only when Jira returns duplicate board column names
|
|
6
8
|
// - status-fallback columns: 'status:<statusId>'
|
|
7
9
|
// The provider (T04) picks ONE source per sync, so mixed-source boards
|
|
8
10
|
// do not occur in practice.
|
|
@@ -30,6 +32,67 @@ export interface JiraCacheConfig {
|
|
|
30
32
|
issueTypes: Array<{ id: string; name: string }>
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
export interface JiraBoardColumnInput {
|
|
36
|
+
name: string
|
|
37
|
+
statuses: Array<{ id: string }>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function jiraBoardColumnRows(
|
|
41
|
+
boardId: number,
|
|
42
|
+
columns: JiraBoardColumnInput[],
|
|
43
|
+
): Array<{
|
|
44
|
+
id: string
|
|
45
|
+
name: string
|
|
46
|
+
position: number
|
|
47
|
+
statusIds: string[]
|
|
48
|
+
source: 'board'
|
|
49
|
+
}> {
|
|
50
|
+
const seen = new Set<string>()
|
|
51
|
+
return columns.map((column, index) => {
|
|
52
|
+
const baseId = `board:${boardId}:${column.name}`
|
|
53
|
+
let id = baseId
|
|
54
|
+
if (seen.has(id)) {
|
|
55
|
+
id = `${baseId}:${index}`
|
|
56
|
+
let suffix = 2
|
|
57
|
+
while (seen.has(id)) {
|
|
58
|
+
id = `${baseId}:${index}:${suffix}`
|
|
59
|
+
suffix += 1
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
seen.add(id)
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
name: column.name,
|
|
66
|
+
position: index,
|
|
67
|
+
statusIds: column.statuses.map((status) => status.id),
|
|
68
|
+
source: 'board',
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Resolves a user-supplied column reference to a cached column id, trying
|
|
74
|
+
// (1) exact id, (2) case-insensitive name, (3) raw status id containment.
|
|
75
|
+
// A name that matches multiple columns (possible when Jira returns duplicate
|
|
76
|
+
// board column names) is rejected as ambiguous so the caller picks an id.
|
|
77
|
+
export function resolveJiraColumnId(columns: JiraColumnRow[], input: string): string {
|
|
78
|
+
const byId = columns.find((column) => column.id === input)
|
|
79
|
+
if (byId) return byId.id
|
|
80
|
+
const lower = input.toLowerCase()
|
|
81
|
+
const byName = columns.filter((column) => column.name.toLowerCase() === lower)
|
|
82
|
+
if (byName.length === 1) return byName[0]!.id
|
|
83
|
+
if (byName.length > 1) {
|
|
84
|
+
throw new KanbanError(
|
|
85
|
+
ErrorCode.COLUMN_NOT_FOUND,
|
|
86
|
+
`Jira column name '${input}' is ambiguous; use one of these column ids: ${byName
|
|
87
|
+
.map((column) => column.id)
|
|
88
|
+
.join(', ')}`,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
const byStatus = columns.find((column) => decodeColumnStatusIds(column).includes(input))
|
|
92
|
+
if (byStatus) return byStatus.id
|
|
93
|
+
throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Jira column matching '${input}'`)
|
|
94
|
+
}
|
|
95
|
+
|
|
33
96
|
interface JiraIssueRow {
|
|
34
97
|
id: string
|
|
35
98
|
key: string
|
package/src/providers/jira.ts
CHANGED
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
getCachedTask,
|
|
33
33
|
getCachedTasks,
|
|
34
34
|
initJiraCacheSchema,
|
|
35
|
+
jiraBoardColumnRows,
|
|
36
|
+
resolveJiraColumnId,
|
|
35
37
|
loadJiraSyncMeta,
|
|
36
38
|
loadTeamInfo,
|
|
37
39
|
pruneJiraIssuesMissingUpstream,
|
|
@@ -122,13 +124,7 @@ export class JiraProvider implements KanbanProvider {
|
|
|
122
124
|
if (this.config.boardId !== undefined) {
|
|
123
125
|
const boardCfg = await this.client.getBoardColumns(this.config.boardId)
|
|
124
126
|
const boardId = this.config.boardId
|
|
125
|
-
const rows = boardCfg.columnConfig.columns
|
|
126
|
-
id: `board:${boardId}:${col.name}`,
|
|
127
|
-
name: col.name,
|
|
128
|
-
position: i,
|
|
129
|
-
statusIds: col.statuses.map((s) => s.id),
|
|
130
|
-
source: 'board' as const,
|
|
131
|
-
}))
|
|
127
|
+
const rows = jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns)
|
|
132
128
|
replaceJiraColumns(this.db, rows)
|
|
133
129
|
} else {
|
|
134
130
|
const statusCats = await this.client.getProjectStatuses(project.key)
|
|
@@ -275,18 +271,7 @@ export class JiraProvider implements KanbanProvider {
|
|
|
275
271
|
}
|
|
276
272
|
|
|
277
273
|
private resolveColumnId(input: string): string {
|
|
278
|
-
|
|
279
|
-
// Priority 1: exact id.
|
|
280
|
-
const byId = columns.find((c) => c.id === input)
|
|
281
|
-
if (byId) return byId.id
|
|
282
|
-
// Priority 2: case-insensitive name.
|
|
283
|
-
const lower = input.toLowerCase()
|
|
284
|
-
const byName = columns.find((c) => c.name.toLowerCase() === lower)
|
|
285
|
-
if (byName) return byName.id
|
|
286
|
-
// Priority 3: status_ids containment (raw status id).
|
|
287
|
-
const byStatus = columns.find((c) => decodeColumnStatusIds(c).includes(input))
|
|
288
|
-
if (byStatus) return byStatus.id
|
|
289
|
-
throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Jira column matching '${input}'`)
|
|
274
|
+
return resolveJiraColumnId(getCachedColumns(this.db), input)
|
|
290
275
|
}
|
|
291
276
|
|
|
292
277
|
private async buildBoardConfig(): Promise<BoardConfig> {
|
|
@@ -14,7 +14,13 @@ import type {
|
|
|
14
14
|
TaskComment,
|
|
15
15
|
} from '../types'
|
|
16
16
|
import { JIRA_CAPABILITIES } from './capabilities'
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
decodeColumnStatusIds,
|
|
19
|
+
jiraBoardColumnRows,
|
|
20
|
+
resolveJiraColumnId,
|
|
21
|
+
type JiraActivityRow,
|
|
22
|
+
type JiraColumnRow,
|
|
23
|
+
} from './jira-cache'
|
|
18
24
|
import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
|
|
19
25
|
import { JiraClient, normalizeJiraLabels, type JiraComment, type JiraIssue } from './jira-client'
|
|
20
26
|
import type { JiraProviderConfig } from './jira'
|
|
@@ -605,15 +611,7 @@ export class PostgresJiraProvider implements KanbanProvider {
|
|
|
605
611
|
if (this.config.boardId !== undefined) {
|
|
606
612
|
const boardCfg = await this.client.getBoardColumns(this.config.boardId)
|
|
607
613
|
const boardId = this.config.boardId
|
|
608
|
-
await this.replaceColumns(
|
|
609
|
-
boardCfg.columnConfig.columns.map((column, index) => ({
|
|
610
|
-
id: `board:${boardId}:${column.name}`,
|
|
611
|
-
name: column.name,
|
|
612
|
-
position: index,
|
|
613
|
-
statusIds: column.statuses.map((status) => status.id),
|
|
614
|
-
source: 'board' as const,
|
|
615
|
-
})),
|
|
616
|
-
)
|
|
614
|
+
await this.replaceColumns(jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns))
|
|
617
615
|
} else {
|
|
618
616
|
const statusCats = await this.client.getProjectStatuses(project.key)
|
|
619
617
|
const seen = new Set<string>()
|
|
@@ -741,15 +739,7 @@ export class PostgresJiraProvider implements KanbanProvider {
|
|
|
741
739
|
}
|
|
742
740
|
|
|
743
741
|
private async resolveColumnId(input: string): Promise<string> {
|
|
744
|
-
|
|
745
|
-
const byId = columns.find((column) => column.id === input)
|
|
746
|
-
if (byId) return byId.id
|
|
747
|
-
const lower = input.toLowerCase()
|
|
748
|
-
const byName = columns.find((column) => column.name.toLowerCase() === lower)
|
|
749
|
-
if (byName) return byName.id
|
|
750
|
-
const byStatus = columns.find((column) => decodeColumnStatusIds(column).includes(input))
|
|
751
|
-
if (byStatus) return byStatus.id
|
|
752
|
-
throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Jira column matching '${input}'`)
|
|
742
|
+
return resolveJiraColumnId(await this.getColumns(), input)
|
|
753
743
|
}
|
|
754
744
|
|
|
755
745
|
private async buildBoardConfig(): Promise<BoardConfig> {
|