@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.
Files changed (45) hide show
  1. package/package.json +11 -3
  2. package/src/__tests__/index.test.ts +27 -1
  3. package/src/__tests__/jira-cache.test.ts +68 -0
  4. package/src/__tests__/jira-provider-read.test.ts +130 -0
  5. package/src/__tests__/jira-wiring.test.ts +41 -0
  6. package/src/__tests__/linear-provider-comment.test.ts +81 -0
  7. package/src/__tests__/linear-provider-sync.test.ts +107 -0
  8. package/src/__tests__/linear-state-resolution.test.ts +60 -0
  9. package/src/__tests__/local-provider-core.test.ts +245 -0
  10. package/src/__tests__/postgres-jira-provider.test.ts +55 -0
  11. package/src/__tests__/postgres-linear-provider.test.ts +90 -0
  12. package/src/__tests__/postgres-local-provider.test.ts +26 -0
  13. package/src/__tests__/provider-sync-core.test.ts +150 -0
  14. package/src/__tests__/provider-team-info.test.ts +37 -0
  15. package/src/__tests__/ui-board-slice.test.ts +14 -0
  16. package/src/__tests__/use-cases.test.ts +21 -42
  17. package/src/api.ts +78 -132
  18. package/src/column-roles.ts +3 -2
  19. package/src/index.ts +135 -92
  20. package/src/mcp/core.ts +7 -8
  21. package/src/provider-runtime.ts +12 -56
  22. package/src/providers/cache-task-mappers.ts +123 -0
  23. package/src/providers/factory.ts +103 -0
  24. package/src/providers/index.ts +4 -25
  25. package/src/providers/jira-cache.ts +41 -99
  26. package/src/providers/jira-core.ts +81 -81
  27. package/src/providers/linear-cache.ts +42 -69
  28. package/src/providers/linear-core.ts +44 -56
  29. package/src/providers/local-core.ts +226 -0
  30. package/src/providers/local.ts +6 -182
  31. package/src/providers/postgres-batch.ts +10 -0
  32. package/src/providers/postgres-jira-cache.ts +255 -213
  33. package/src/providers/postgres-jira.ts +2 -20
  34. package/src/providers/postgres-linear-cache.ts +279 -183
  35. package/src/providers/postgres-linear.ts +2 -20
  36. package/src/providers/postgres-local.ts +22 -47
  37. package/src/providers/sqlite-local-store.ts +121 -0
  38. package/src/providers/sync-core.ts +91 -0
  39. package/src/providers/team-info.ts +24 -0
  40. package/src/providers/warn-once.ts +15 -0
  41. package/src/use-cases.ts +7 -131
  42. package/src/webhook-events.ts +29 -1
  43. package/ui/dist/assets/index-DcZH7fI3.js +40 -0
  44. package/ui/dist/index.html +1 -1
  45. 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",
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
+ })