@andypai/agent-kanban 0.6.3 → 0.6.5
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 +11 -3
- package/src/__tests__/index.test.ts +27 -1
- package/src/__tests__/jira-cache.test.ts +68 -0
- package/src/__tests__/jira-provider-read.test.ts +130 -0
- package/src/__tests__/jira-wiring.test.ts +41 -0
- package/src/__tests__/linear-provider-comment.test.ts +81 -0
- package/src/__tests__/linear-provider-sync.test.ts +107 -0
- package/src/__tests__/linear-state-resolution.test.ts +60 -0
- package/src/__tests__/local-provider-core.test.ts +245 -0
- package/src/__tests__/postgres-jira-provider.test.ts +55 -0
- package/src/__tests__/postgres-linear-provider.test.ts +90 -0
- package/src/__tests__/postgres-local-provider.test.ts +26 -0
- package/src/__tests__/provider-sync-core.test.ts +150 -0
- package/src/__tests__/provider-team-info.test.ts +37 -0
- package/src/__tests__/ui-board-slice.test.ts +14 -0
- package/src/__tests__/use-cases.test.ts +21 -42
- package/src/api.ts +78 -132
- package/src/column-roles.ts +3 -2
- package/src/index.ts +135 -92
- package/src/mcp/core.ts +7 -8
- package/src/provider-runtime.ts +12 -56
- package/src/providers/cache-task-mappers.ts +123 -0
- package/src/providers/factory.ts +103 -0
- package/src/providers/index.ts +4 -25
- package/src/providers/jira-cache.ts +41 -99
- package/src/providers/jira-core.ts +81 -81
- package/src/providers/linear-cache.ts +42 -69
- package/src/providers/linear-core.ts +44 -56
- package/src/providers/local-core.ts +226 -0
- package/src/providers/local.ts +6 -182
- package/src/providers/postgres-batch.ts +10 -0
- package/src/providers/postgres-jira-cache.ts +255 -213
- package/src/providers/postgres-jira.ts +2 -20
- package/src/providers/postgres-linear-cache.ts +279 -183
- package/src/providers/postgres-linear.ts +2 -20
- package/src/providers/postgres-local.ts +22 -47
- package/src/providers/sqlite-local-store.ts +121 -0
- package/src/providers/sync-core.ts +91 -0
- package/src/providers/team-info.ts +24 -0
- package/src/providers/warn-once.ts +15 -0
- package/src/use-cases.ts +7 -131
- package/src/webhook-events.ts +29 -1
- package/ui/dist/assets/index-DcZH7fI3.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-Cigv8a9S.js +0 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
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": {
|
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
},
|
|
35
35
|
"packageManager": "bun@1.3.3",
|
|
36
36
|
"license": "MIT",
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
37
40
|
"scripts": {
|
|
38
41
|
"dev": "bun --watch src/index.ts",
|
|
39
42
|
"start": "bun src/index.ts",
|
|
@@ -53,17 +56,22 @@
|
|
|
53
56
|
"dev:ui": "bun scripts/dev.ts",
|
|
54
57
|
"build:ui": "bun run ui:build",
|
|
55
58
|
"prepare": "if [ \"${HUSKY:-1}\" != \"0\" ] && command -v husky >/dev/null 2>&1; then husky install; fi",
|
|
56
|
-
"prepack": "cd ui && bun install --frozen-lockfile && bun run build"
|
|
59
|
+
"prepack": "cd ui && bun install --frozen-lockfile && bun run build",
|
|
60
|
+
"changeset": "changeset",
|
|
61
|
+
"version": "changeset version",
|
|
62
|
+
"release": "changeset publish"
|
|
57
63
|
},
|
|
58
64
|
"dependencies": {
|
|
59
65
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
60
66
|
"postgres": "^3.4.4"
|
|
61
67
|
},
|
|
62
68
|
"devDependencies": {
|
|
69
|
+
"@changesets/changelog-github": "^0.7.0",
|
|
70
|
+
"@changesets/cli": "^2.31.0",
|
|
63
71
|
"@eslint/js": "^9.39.1",
|
|
72
|
+
"@types/bun": "^1.1.22",
|
|
64
73
|
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
|
65
74
|
"@typescript-eslint/parser": "^8.18.0",
|
|
66
|
-
"@types/bun": "^1.1.22",
|
|
67
75
|
"eslint": "^9.39.1",
|
|
68
76
|
"eslint-config-prettier": "^10.1.8",
|
|
69
77
|
"eslint-plugin-prettier": "^5.5.4",
|
|
@@ -5,7 +5,7 @@ import { join } from 'node:path'
|
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { ErrorCode, KanbanError, type ErrorCodeValue } from '../errors'
|
|
7
7
|
import type { Task, TaskComment } from '../types'
|
|
8
|
-
import { parseServeArgs, run } from '../index'
|
|
8
|
+
import { parseMcpArgs, 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-'))
|
|
@@ -120,6 +120,26 @@ describe('parseServeArgs', () => {
|
|
|
120
120
|
else process.env['KANBAN_ALLOWED_ORIGIN'] = prevOrigin
|
|
121
121
|
}
|
|
122
122
|
})
|
|
123
|
+
|
|
124
|
+
test('rejects unknown serve options', () => {
|
|
125
|
+
expect(() => parseServeArgs(['serve', '--bogus'])).toThrow(KanbanError)
|
|
126
|
+
try {
|
|
127
|
+
parseServeArgs(['serve', '--bogus'])
|
|
128
|
+
} catch (err) {
|
|
129
|
+
expect((err as KanbanError).code).toBe(ErrorCode.INVALID_ARGUMENT)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('parseMcpArgs', () => {
|
|
135
|
+
test('rejects unknown mcp options', () => {
|
|
136
|
+
expect(() => parseMcpArgs(['mcp', '--bogus'])).toThrow(KanbanError)
|
|
137
|
+
try {
|
|
138
|
+
parseMcpArgs(['mcp', '--bogus'])
|
|
139
|
+
} catch (err) {
|
|
140
|
+
expect((err as KanbanError).code).toBe(ErrorCode.INVALID_ARGUMENT)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
123
143
|
})
|
|
124
144
|
|
|
125
145
|
describe('run', () => {
|
|
@@ -375,4 +395,10 @@ describe('run', () => {
|
|
|
375
395
|
)
|
|
376
396
|
})
|
|
377
397
|
})
|
|
398
|
+
|
|
399
|
+
test('rejects unknown top-level options before opening a runtime', async () => {
|
|
400
|
+
const err = await run(['--bogus']).catch((e: unknown) => e)
|
|
401
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
402
|
+
expect((err as KanbanError).code).toBe(ErrorCode.INVALID_ARGUMENT)
|
|
403
|
+
})
|
|
378
404
|
})
|
|
@@ -12,10 +12,12 @@ import {
|
|
|
12
12
|
replaceJiraColumns,
|
|
13
13
|
replaceJiraIssueTypes,
|
|
14
14
|
replaceJiraPriorities,
|
|
15
|
+
resolveJiraColumnId,
|
|
15
16
|
saveJiraSyncMeta,
|
|
16
17
|
upsertJiraIssues,
|
|
17
18
|
upsertJiraUsers,
|
|
18
19
|
} from '../providers/jira-cache'
|
|
20
|
+
import type { JiraColumnRow } from '../providers/jira-cache'
|
|
19
21
|
|
|
20
22
|
let db: Database
|
|
21
23
|
|
|
@@ -358,3 +360,69 @@ describe('jira-cache', () => {
|
|
|
358
360
|
expect(getCachedTasks(db, { columnId })).toEqual([])
|
|
359
361
|
})
|
|
360
362
|
})
|
|
363
|
+
|
|
364
|
+
describe('resolveJiraColumnId', () => {
|
|
365
|
+
const col = (id: string, name: string, statusIds: string[] = []): JiraColumnRow => ({
|
|
366
|
+
id,
|
|
367
|
+
name,
|
|
368
|
+
position: 0,
|
|
369
|
+
status_ids: JSON.stringify(statusIds),
|
|
370
|
+
source: 'status',
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test('matches by exact id first', () => {
|
|
374
|
+
const columns = [col('status:12136', 'To Do'), col('status:12137', 'In Progress')]
|
|
375
|
+
expect(resolveJiraColumnId(columns, 'status:12137')).toBe('status:12137')
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
test('matches by case-insensitive name', () => {
|
|
379
|
+
const columns = [col('status:12136', 'To Do')]
|
|
380
|
+
expect(resolveJiraColumnId(columns, 'to do')).toBe('status:12136')
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test("resolves a separator-collapsed trigger string ('Todo') to the 'To Do' status column", () => {
|
|
384
|
+
const columns = [col('status:12136', 'To Do'), col('status:12137', 'In Progress')]
|
|
385
|
+
expect(resolveJiraColumnId(columns, 'Todo')).toBe('status:12136')
|
|
386
|
+
expect(resolveJiraColumnId(columns, 'ToDo')).toBe('status:12136')
|
|
387
|
+
expect(resolveJiraColumnId(columns, 'In-Progress')).toBe('status:12137')
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test('exact case-insensitive name wins over a separator-insensitive collision', () => {
|
|
391
|
+
// 'To Do' and 'Todo' both normalize to 'todo'; an exact lowercase hit must
|
|
392
|
+
// not be derailed into the ambiguity branch.
|
|
393
|
+
const columns = [col('status:1', 'To Do'), col('status:2', 'Todo')]
|
|
394
|
+
expect(resolveJiraColumnId(columns, 'todo')).toBe('status:2')
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test('rejects a separator-insensitive match that collapses two distinct columns', () => {
|
|
398
|
+
const columns = [col('status:1', 'To Do'), col('status:2', 'To-Do')]
|
|
399
|
+
expect(() => resolveJiraColumnId(columns, 'Todo')).toThrow(/ambiguous/)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test('falls back to raw status id containment', () => {
|
|
403
|
+
const columns = [col('board:1:Backlog', 'Backlog', ['10001', '10002'])]
|
|
404
|
+
expect(resolveJiraColumnId(columns, '10002')).toBe('board:1:Backlog')
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test('raw status id wins over a column whose name normalizes to that id', () => {
|
|
408
|
+
// A numeric status-id reference must resolve by containment, not be hijacked
|
|
409
|
+
// by a column whose name only matches after the fuzzy normalized pass
|
|
410
|
+
// ('1-0-0-0-3' → '10003').
|
|
411
|
+
const columns = [
|
|
412
|
+
col('status:1', '1-0-0-0-3', ['99999']),
|
|
413
|
+
col('board:1:Backlog', 'Backlog', ['10003']),
|
|
414
|
+
]
|
|
415
|
+
expect(resolveJiraColumnId(columns, '10003')).toBe('board:1:Backlog')
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test('empty / separator-only input does not match a separator-only column name', () => {
|
|
419
|
+
const columns = [col('status:1', '---'), col('status:2', 'To Do')]
|
|
420
|
+
expect(() => resolveJiraColumnId(columns, ' ')).toThrow(/No Jira column matching/)
|
|
421
|
+
expect(() => resolveJiraColumnId(columns, '!!!')).toThrow(/No Jira column matching/)
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test('throws COLUMN_NOT_FOUND when nothing matches', () => {
|
|
425
|
+
const columns = [col('status:12136', 'To Do')]
|
|
426
|
+
expect(() => resolveJiraColumnId(columns, 'Done')).toThrow(/No Jira column matching/)
|
|
427
|
+
})
|
|
428
|
+
})
|
|
@@ -3,6 +3,7 @@ import { Database } from 'bun:sqlite'
|
|
|
3
3
|
import { ErrorCode, KanbanError } from '../errors'
|
|
4
4
|
import { JiraClient } from '../providers/jira-client'
|
|
5
5
|
import { JiraProvider, type JiraProviderConfig } from '../providers/jira'
|
|
6
|
+
import { resetWarnOnce } from '../providers/warn-once'
|
|
6
7
|
import {
|
|
7
8
|
getCachedColumns,
|
|
8
9
|
getCachedTasks,
|
|
@@ -176,6 +177,7 @@ beforeEach(() => {
|
|
|
176
177
|
db = new Database(':memory:')
|
|
177
178
|
originalFetch = globalThis.fetch
|
|
178
179
|
originalDateNow = Date.now
|
|
180
|
+
resetWarnOnce()
|
|
179
181
|
})
|
|
180
182
|
|
|
181
183
|
afterEach(() => {
|
|
@@ -837,6 +839,46 @@ describe('JiraProvider read path', () => {
|
|
|
837
839
|
expect(getCachedTasks(db)).toHaveLength(150)
|
|
838
840
|
})
|
|
839
841
|
|
|
842
|
+
test('sync fetches Jira changelogs with bounded concurrency', async () => {
|
|
843
|
+
const issues = Array.from({ length: 6 }, (_, i) =>
|
|
844
|
+
makeIssue({
|
|
845
|
+
id: String(i + 1),
|
|
846
|
+
key: `ENG-${i + 1}`,
|
|
847
|
+
statusId: '10001',
|
|
848
|
+
updated: '2026-01-02T00:00:00Z',
|
|
849
|
+
}),
|
|
850
|
+
)
|
|
851
|
+
const searchHandler: StubHandler = () => jsonResponse({ isLast: true, issues })
|
|
852
|
+
let calls = 0
|
|
853
|
+
let inFlight = 0
|
|
854
|
+
let maxInFlight = 0
|
|
855
|
+
const changelogHandler: StubHandler = async () => {
|
|
856
|
+
calls += 1
|
|
857
|
+
inFlight += 1
|
|
858
|
+
maxInFlight = Math.max(maxInFlight, inFlight)
|
|
859
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
860
|
+
inFlight -= 1
|
|
861
|
+
return jsonResponse({
|
|
862
|
+
startAt: 0,
|
|
863
|
+
maxResults: 100,
|
|
864
|
+
total: 0,
|
|
865
|
+
isLast: true,
|
|
866
|
+
values: [],
|
|
867
|
+
})
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const { provider } = makeProviderWithBoard(
|
|
871
|
+
standardRoutes({ searchHandler, changelogHandler }),
|
|
872
|
+
3,
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
await provider.getBoard()
|
|
876
|
+
|
|
877
|
+
expect(calls).toBe(6)
|
|
878
|
+
expect(maxInFlight).toBeGreaterThan(1)
|
|
879
|
+
expect(maxInFlight).toBeLessThanOrEqual(5)
|
|
880
|
+
})
|
|
881
|
+
|
|
840
882
|
test('saveTeamInfo / loadTeamInfo roundtrip', () => {
|
|
841
883
|
initJiraCacheSchema(db)
|
|
842
884
|
saveTeamInfo(db, { id: '10000', key: 'ENG', name: 'Engineering' })
|
|
@@ -895,4 +937,92 @@ describe('JiraProvider read path', () => {
|
|
|
895
937
|
|
|
896
938
|
expect(calls.some((c) => c.url.includes('/rest/api/3/search/jql'))).toBe(true)
|
|
897
939
|
})
|
|
940
|
+
|
|
941
|
+
function captureUnmappedWarnings(): {
|
|
942
|
+
messages: string[]
|
|
943
|
+
restore: () => void
|
|
944
|
+
} {
|
|
945
|
+
const all: string[] = []
|
|
946
|
+
const origWarn = console.warn
|
|
947
|
+
console.warn = (...args: unknown[]) => {
|
|
948
|
+
all.push(args.map(String).join(' '))
|
|
949
|
+
}
|
|
950
|
+
return {
|
|
951
|
+
get messages() {
|
|
952
|
+
return all.filter((m) => m.includes('maps to no column'))
|
|
953
|
+
},
|
|
954
|
+
restore: () => {
|
|
955
|
+
console.warn = origWarn
|
|
956
|
+
},
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
test('warns when an issue status maps to no board column and keeps it off the board', async () => {
|
|
961
|
+
// Board maps statuses 10001/10002 to 'Done'; 10003 is on no column.
|
|
962
|
+
const searchHandler: StubHandler = () =>
|
|
963
|
+
jsonResponse({
|
|
964
|
+
startAt: 0,
|
|
965
|
+
maxResults: 100,
|
|
966
|
+
total: 1,
|
|
967
|
+
issues: [makeIssue({ id: '7', key: 'ENG-7', statusId: '10003' })],
|
|
968
|
+
})
|
|
969
|
+
const warn = captureUnmappedWarnings()
|
|
970
|
+
try {
|
|
971
|
+
const { provider } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
|
|
972
|
+
const board = await provider.getBoard()
|
|
973
|
+
expect(warn.messages).toHaveLength(1)
|
|
974
|
+
expect(warn.messages[0]).toContain("status 'Status 10003' (10003)")
|
|
975
|
+
expect(warn.messages[0]).toContain('not on board 3')
|
|
976
|
+
// Cached but invisible: present in jira_issues, absent from every column.
|
|
977
|
+
expect(getCachedTasks(db).some((t) => t.externalRef === 'ENG-7')).toBe(true)
|
|
978
|
+
expect(board.columns.flatMap((c) => c.tasks)).toHaveLength(0)
|
|
979
|
+
} finally {
|
|
980
|
+
warn.restore()
|
|
981
|
+
}
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
test('warns when an issue status is absent from the status-fallback catalog', async () => {
|
|
985
|
+
// Status-fallback columns come from statuses 10001/10002/10003; 10099 is absent.
|
|
986
|
+
const searchHandler: StubHandler = () =>
|
|
987
|
+
jsonResponse({
|
|
988
|
+
startAt: 0,
|
|
989
|
+
maxResults: 100,
|
|
990
|
+
total: 1,
|
|
991
|
+
issues: [makeIssue({ id: '8', key: 'ENG-8', statusId: '10099' })],
|
|
992
|
+
})
|
|
993
|
+
const warn = captureUnmappedWarnings()
|
|
994
|
+
try {
|
|
995
|
+
const { provider } = makeProvider(standardRoutes({ searchHandler }))
|
|
996
|
+
await provider.getBoard()
|
|
997
|
+
expect(warn.messages).toHaveLength(1)
|
|
998
|
+
expect(warn.messages[0]).toContain("status 'Status 10099' (10099)")
|
|
999
|
+
expect(warn.messages[0]).toContain('absent from the project status catalog')
|
|
1000
|
+
} finally {
|
|
1001
|
+
warn.restore()
|
|
1002
|
+
}
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
test('unmapped-status warning fires once across repeated syncs', async () => {
|
|
1006
|
+
const searchHandler: StubHandler = () =>
|
|
1007
|
+
jsonResponse({
|
|
1008
|
+
startAt: 0,
|
|
1009
|
+
maxResults: 100,
|
|
1010
|
+
total: 1,
|
|
1011
|
+
issues: [makeIssue({ id: '9', key: 'ENG-9', statusId: '10003' })],
|
|
1012
|
+
})
|
|
1013
|
+
const warn = captureUnmappedWarnings()
|
|
1014
|
+
try {
|
|
1015
|
+
const { provider, calls } = makeProviderWithBoard(standardRoutes({ searchHandler }), 3)
|
|
1016
|
+
await provider.getBoard()
|
|
1017
|
+
const origNow = originalDateNow
|
|
1018
|
+
Date.now = () => origNow() + 31_000
|
|
1019
|
+
await provider.getBoard()
|
|
1020
|
+
// Both reads must actually sync (the +31s skip clears the poll throttle);
|
|
1021
|
+
// otherwise a single warning here would prove nothing about dedup.
|
|
1022
|
+
expect(calls.filter((c) => c.url.includes('/rest/api/3/search/jql'))).toHaveLength(2)
|
|
1023
|
+
expect(warn.messages).toHaveLength(1)
|
|
1024
|
+
} finally {
|
|
1025
|
+
warn.restore()
|
|
1026
|
+
}
|
|
1027
|
+
})
|
|
898
1028
|
})
|
|
@@ -182,6 +182,47 @@ describe('jira-wiring', () => {
|
|
|
182
182
|
expect(err.message).toContain('Bulk commands')
|
|
183
183
|
})
|
|
184
184
|
|
|
185
|
+
test('kanban board init under KANBAN_PROVIDER=jira keeps the SQLite remote local-only error', async () => {
|
|
186
|
+
const { dbPath } = makeDb()
|
|
187
|
+
setJiraRequiredEnv()
|
|
188
|
+
const result = await run(['--db', dbPath, 'board', 'init']).catch((err: unknown) => ({
|
|
189
|
+
error: err as KanbanError,
|
|
190
|
+
}))
|
|
191
|
+
expect('error' in result).toBe(true)
|
|
192
|
+
const err = (result as { error: KanbanError }).error
|
|
193
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
194
|
+
expect(err.code).toBe(ErrorCode.UNSUPPORTED_OPERATION)
|
|
195
|
+
expect(err.message).toContain('Board initialization is only available in local mode')
|
|
196
|
+
expect(err.message).not.toContain('KANBAN_STORAGE=postgres')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('kanban board reset under KANBAN_PROVIDER=jira keeps the SQLite remote local-only error', async () => {
|
|
200
|
+
const { dbPath } = makeDb()
|
|
201
|
+
setJiraRequiredEnv()
|
|
202
|
+
const result = await run(['--db', dbPath, 'board', 'reset']).catch((err: unknown) => ({
|
|
203
|
+
error: err as KanbanError,
|
|
204
|
+
}))
|
|
205
|
+
expect('error' in result).toBe(true)
|
|
206
|
+
const err = (result as { error: KanbanError }).error
|
|
207
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
208
|
+
expect(err.code).toBe(ErrorCode.UNSUPPORTED_OPERATION)
|
|
209
|
+
expect(err.message).toContain('Board reset is only available in local mode')
|
|
210
|
+
expect(err.message).not.toContain('KANBAN_STORAGE=postgres')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('kanban unknown board action under KANBAN_PROVIDER=jira remains an unknown command', async () => {
|
|
214
|
+
const { dbPath } = makeDb()
|
|
215
|
+
setJiraRequiredEnv()
|
|
216
|
+
const result = await run(['--db', dbPath, 'board', 'nope']).catch((err: unknown) => ({
|
|
217
|
+
error: err as KanbanError,
|
|
218
|
+
}))
|
|
219
|
+
expect('error' in result).toBe(true)
|
|
220
|
+
const err = (result as { error: KanbanError }).error
|
|
221
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
222
|
+
expect(err.code).toBe(ErrorCode.UNKNOWN_COMMAND)
|
|
223
|
+
expect(err.message).toContain("Unknown board command 'nope'")
|
|
224
|
+
})
|
|
225
|
+
|
|
185
226
|
test('kanban config set-member under KANBAN_PROVIDER=jira exits with UNSUPPORTED_OPERATION', async () => {
|
|
186
227
|
const { dbPath } = makeDb()
|
|
187
228
|
setJiraRequiredEnv()
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import { Database } from 'bun:sqlite'
|
|
3
|
+
import type { BoardConfig, BoardView, Task } from '../types'
|
|
4
|
+
import { LinearClient } from '../providers/linear-client'
|
|
5
|
+
import { LinearProviderCore, type LinearCachePort } from '../providers/linear-core'
|
|
3
6
|
import { LinearProvider } from '../providers/linear'
|
|
4
7
|
import {
|
|
5
8
|
initLinearCacheSchema,
|
|
@@ -8,6 +11,68 @@ import {
|
|
|
8
11
|
upsertIssues,
|
|
9
12
|
} from '../providers/linear-cache'
|
|
10
13
|
|
|
14
|
+
function taskWithEmptyProviderId(): Task {
|
|
15
|
+
return {
|
|
16
|
+
id: 'linear:issue-1',
|
|
17
|
+
providerId: '',
|
|
18
|
+
externalRef: 'ENG-1',
|
|
19
|
+
url: 'https://linear.app/x/issue/ENG-1',
|
|
20
|
+
title: 'Issue 1',
|
|
21
|
+
description: '',
|
|
22
|
+
column_id: 'state-1',
|
|
23
|
+
position: 0,
|
|
24
|
+
priority: 'low',
|
|
25
|
+
assignee: '',
|
|
26
|
+
assignees: [],
|
|
27
|
+
labels: [],
|
|
28
|
+
comment_count: 0,
|
|
29
|
+
project: '',
|
|
30
|
+
metadata: '{}',
|
|
31
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
32
|
+
updated_at: '2026-01-02T00:00:00Z',
|
|
33
|
+
version: '2026-01-02T00:00:00Z',
|
|
34
|
+
source_updated_at: '2026-01-02T00:00:00Z',
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fakeLinearCache(task: Task): LinearCachePort {
|
|
39
|
+
const meta = {
|
|
40
|
+
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
|
|
41
|
+
lastSyncAt: new Date().toISOString(),
|
|
42
|
+
lastFullSyncAt: null,
|
|
43
|
+
lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
|
|
44
|
+
lastWebhookAt: null,
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
ready: Promise.resolve(),
|
|
48
|
+
loadSyncMeta: async () => meta,
|
|
49
|
+
saveSyncMeta: async () => {},
|
|
50
|
+
replaceStates: async () => {},
|
|
51
|
+
upsertUsers: async () => {},
|
|
52
|
+
upsertProjects: async () => {},
|
|
53
|
+
upsertIssues: async () => {},
|
|
54
|
+
deleteIssue: async () => {},
|
|
55
|
+
pruneIssues: async () => {},
|
|
56
|
+
adjustIssueCommentCount: async () => {},
|
|
57
|
+
saveActivity: async () => {},
|
|
58
|
+
getCachedActivity: async () => [],
|
|
59
|
+
getCachedColumns: async () => [],
|
|
60
|
+
getCachedBoard: async (): Promise<BoardView> => ({ columns: [] }),
|
|
61
|
+
getCachedTask: async () => task,
|
|
62
|
+
getCachedTasks: async () => [task],
|
|
63
|
+
getCachedConfig: async (): Promise<BoardConfig> => ({
|
|
64
|
+
members: [],
|
|
65
|
+
projects: [],
|
|
66
|
+
provider: 'linear',
|
|
67
|
+
discoveredAssignees: [],
|
|
68
|
+
discoveredProjects: [],
|
|
69
|
+
}),
|
|
70
|
+
findUserIdByName: async () => null,
|
|
71
|
+
findProjectIdByName: async () => null,
|
|
72
|
+
resolveIssueId: async () => 'issue-1',
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
11
76
|
let db: Database
|
|
12
77
|
let originalFetch: typeof fetch
|
|
13
78
|
let requests: Array<{ query: string; variables: Record<string, unknown> }>
|
|
@@ -206,6 +271,22 @@ describe('LinearProvider.comment', () => {
|
|
|
206
271
|
])
|
|
207
272
|
})
|
|
208
273
|
|
|
274
|
+
test('uses the unprefixed task id when providerId is empty', async () => {
|
|
275
|
+
const provider = new LinearProviderCore(
|
|
276
|
+
fakeLinearCache(taskWithEmptyProviderId()),
|
|
277
|
+
'team-1',
|
|
278
|
+
new LinearClient('lin_api_test'),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
await provider.listComments('ENG-1')
|
|
282
|
+
|
|
283
|
+
expect(requests[0]?.query).toContain('query IssueComments')
|
|
284
|
+
expect(requests[0]?.variables).toEqual({
|
|
285
|
+
issueId: 'issue-1',
|
|
286
|
+
after: null,
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
209
290
|
test('queries a single Linear comment by id', async () => {
|
|
210
291
|
const provider = new LinearProvider(db, 'team-1', 'lin_api_test')
|
|
211
292
|
|
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import { Database } from 'bun:sqlite'
|
|
3
3
|
import { LinearProvider } from '../providers/linear'
|
|
4
4
|
import {
|
|
5
|
+
getCachedLinearActivity,
|
|
5
6
|
getCachedTasks,
|
|
6
7
|
initLinearCacheSchema,
|
|
7
8
|
loadSyncMeta,
|
|
@@ -666,6 +667,112 @@ describe('LinearProvider sync', () => {
|
|
|
666
667
|
expect(loadSyncMeta(db).lastFullSyncAt).not.toBeNull()
|
|
667
668
|
})
|
|
668
669
|
|
|
670
|
+
test('history ingest preserves activity from completed batches before a later batch fails', async () => {
|
|
671
|
+
saveSyncMeta(db, {
|
|
672
|
+
team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
|
|
673
|
+
lastSyncAt: '2026-01-01T00:00:00Z',
|
|
674
|
+
lastFullSyncAt: '2026-01-01T00:00:00Z',
|
|
675
|
+
lastIssueUpdatedAt: '2026-01-01T00:00:00Z',
|
|
676
|
+
})
|
|
677
|
+
const issues = Array.from({ length: 6 }, (_, i) =>
|
|
678
|
+
linearIssue({
|
|
679
|
+
id: `issue-${i + 1}`,
|
|
680
|
+
identifier: `R2P-${i + 1}`,
|
|
681
|
+
updatedAt: '2026-01-02T00:00:00Z',
|
|
682
|
+
}),
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
686
|
+
const body = JSON.parse(String(init?.body)) as {
|
|
687
|
+
query: string
|
|
688
|
+
variables: Record<string, unknown>
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (body.query.includes('query TeamSnapshot')) {
|
|
692
|
+
return new Response(
|
|
693
|
+
JSON.stringify({
|
|
694
|
+
data: {
|
|
695
|
+
team: {
|
|
696
|
+
id: 'team-1',
|
|
697
|
+
key: 'R2P',
|
|
698
|
+
name: 'R2pi',
|
|
699
|
+
states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
}),
|
|
703
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
704
|
+
)
|
|
705
|
+
}
|
|
706
|
+
if (body.query.includes('query Users')) {
|
|
707
|
+
return new Response(
|
|
708
|
+
JSON.stringify({
|
|
709
|
+
data: { users: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
710
|
+
}),
|
|
711
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
712
|
+
)
|
|
713
|
+
}
|
|
714
|
+
if (body.query.includes('query Projects')) {
|
|
715
|
+
return new Response(
|
|
716
|
+
JSON.stringify({
|
|
717
|
+
data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
718
|
+
}),
|
|
719
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
720
|
+
)
|
|
721
|
+
}
|
|
722
|
+
if (body.query.includes('query Issues')) {
|
|
723
|
+
return new Response(
|
|
724
|
+
JSON.stringify({
|
|
725
|
+
data: { issues: { nodes: issues, pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
726
|
+
}),
|
|
727
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
728
|
+
)
|
|
729
|
+
}
|
|
730
|
+
if (body.query.includes('query IssueHistory')) {
|
|
731
|
+
const issueId = String(body.variables.issueId)
|
|
732
|
+
if (issueId === 'issue-6') {
|
|
733
|
+
return new Response('history failed', { status: 500 })
|
|
734
|
+
}
|
|
735
|
+
return new Response(
|
|
736
|
+
JSON.stringify({
|
|
737
|
+
data: {
|
|
738
|
+
issue: {
|
|
739
|
+
history: {
|
|
740
|
+
nodes: [
|
|
741
|
+
{
|
|
742
|
+
id: `hist-${issueId}`,
|
|
743
|
+
createdAt: '2026-01-02T00:00:00Z',
|
|
744
|
+
fromState: { id: 'state-0' },
|
|
745
|
+
toState: { id: 'state-1' },
|
|
746
|
+
},
|
|
747
|
+
],
|
|
748
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
}),
|
|
753
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
754
|
+
)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return new Response(`Unexpected query: ${body.query}`, { status: 500 })
|
|
758
|
+
}) as unknown as typeof fetch
|
|
759
|
+
|
|
760
|
+
const originalDateNow = Date.now
|
|
761
|
+
Date.now = () => Date.parse('2026-01-01T00:01:00Z')
|
|
762
|
+
try {
|
|
763
|
+
const provider = new LinearProvider(db, 'R2P', 'lin_api_test')
|
|
764
|
+
await provider.getBoard()
|
|
765
|
+
} finally {
|
|
766
|
+
Date.now = originalDateNow
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
expect(
|
|
770
|
+
getCachedLinearActivity(db)
|
|
771
|
+
.map((row) => row.issue_id)
|
|
772
|
+
.sort(),
|
|
773
|
+
).toEqual(['issue-1', 'issue-2', 'issue-3', 'issue-4', 'issue-5'])
|
|
774
|
+
})
|
|
775
|
+
|
|
669
776
|
test('counts comments beyond the inline first page instead of capping at the page length', async () => {
|
|
670
777
|
saveSyncMeta(db, {
|
|
671
778
|
team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { resolveLinearState, type LinearStateRow } from '../providers/linear-cache'
|
|
4
|
+
|
|
5
|
+
describe('resolveLinearState', () => {
|
|
6
|
+
const state = (id: string, name: string): LinearStateRow => ({
|
|
7
|
+
id,
|
|
8
|
+
name,
|
|
9
|
+
position: 0,
|
|
10
|
+
color: null,
|
|
11
|
+
type: null,
|
|
12
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
13
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('matches by exact id first', () => {
|
|
17
|
+
const states = [state('state-1', 'Todo'), state('state-2', 'In Progress')]
|
|
18
|
+
expect(resolveLinearState(states, 'state-2').id).toBe('state-2')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('matches by case-insensitive name', () => {
|
|
22
|
+
const states = [state('state-1', 'In Progress')]
|
|
23
|
+
expect(resolveLinearState(states, 'in progress').id).toBe('state-1')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("resolves a separator-collapsed trigger string ('in-progress') to the 'In Progress' state", () => {
|
|
27
|
+
const states = [state('state-1', 'Todo'), state('state-2', 'In Progress')]
|
|
28
|
+
expect(resolveLinearState(states, 'in-progress').id).toBe('state-2')
|
|
29
|
+
expect(resolveLinearState(states, 'InProgress').id).toBe('state-2')
|
|
30
|
+
expect(resolveLinearState(states, 'in_progress').id).toBe('state-2')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('exact case-insensitive name wins over a separator-insensitive collision', () => {
|
|
34
|
+
// 'To Do' and 'Todo' both normalize to 'todo'; an exact lowercase hit must
|
|
35
|
+
// not be derailed into the ambiguity branch.
|
|
36
|
+
const states = [state('state-1', 'To Do'), state('state-2', 'Todo')]
|
|
37
|
+
expect(resolveLinearState(states, 'todo').id).toBe('state-2')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('rejects a separator-insensitive match that collapses two distinct states', () => {
|
|
41
|
+
const states = [state('state-1', 'To Do'), state('state-2', 'To-Do')]
|
|
42
|
+
expect(() => resolveLinearState(states, 'Todo')).toThrow(/ambiguous/)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('rejects duplicate case-insensitive state names as ambiguous', () => {
|
|
46
|
+
const states = [state('state-1', 'Done'), state('state-2', 'done')]
|
|
47
|
+
expect(() => resolveLinearState(states, 'DONE')).toThrow(/ambiguous/)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('empty / separator-only input does not match a separator-only state name', () => {
|
|
51
|
+
const states = [state('state-1', '---'), state('state-2', 'To Do')]
|
|
52
|
+
expect(() => resolveLinearState(states, ' ')).toThrow(/No Linear workflow state matching/)
|
|
53
|
+
expect(() => resolveLinearState(states, '!!!')).toThrow(/No Linear workflow state matching/)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('throws COLUMN_NOT_FOUND when nothing matches', () => {
|
|
57
|
+
const states = [state('state-1', 'Todo')]
|
|
58
|
+
expect(() => resolveLinearState(states, 'Done')).toThrow(/No Linear workflow state matching/)
|
|
59
|
+
})
|
|
60
|
+
})
|