@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andypai/agent-kanban",
3
- "version": "0.5.0",
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 { replaceTask, upsertTaskInColumn } from '../../ui/src/components/boardUtils'
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
@@ -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.map((col, i) => ({
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
- const columns = getCachedColumns(this.db)
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 { decodeColumnStatusIds, type JiraActivityRow, type JiraColumnRow } from './jira-cache'
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
- const columns = await this.getColumns()
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> {